Adatok, címek, memóriakezelés: kukacoskodjunk!

2018.06.17. · tudomány

Ebben a részben kukacoskodni fogunk. Olyan kérdéseket fogunk feltenni, amik nem biztos, hogy eszébe jutottak annak, aki a programozós sorozat eddigi részeit olvasta. Eddig ugyanis természetesnek vettük, hogy a képzeletbeli világunkban lényeket alkotunk (valójában a memóriában lefoglalunk valamennyi helyet, és ott valamilyen adatokkal ábrázoljuk a lényeinket), és mindig egy-egy névvel hivatkozunk rájuk. Például a kez név (más néven: szimbólum, változó) egy bizonyos helyzetben tizenhárom kártyalap sorozatát jelölheti (ami a valóságban huszonhat számnyi adat a memóriának egy alkalmas zugában tárolva). Alig szóltunk arról, hogy mit tudunk tenni ezekkel a lényekkel. Meg tudjuk például változtatni őket?

Lények megváltoztatása

Láttunk már példát arra, hogy a lényeinket alakítottuk, megváltoztattuk úgy, hogy közben a nevük ugyanaz maradt, tehát nem új, megváltozott lényeket hoztunk létre, csak a nekik megfelelő adatokat módosítottuk. Például így raktuk sorba a tizenhárom kártyalapot: sorbarak(kez). Ismétlésképpen: a sorbarak nevű eljárásnak a paramétere a tizenhárom kártyalapból álló tömb. Ez az eljárás egyáltalán nem produkált értéket (nincs visszatérési értéke, ezt több programozási nyelvben a neve elé írt void kulcsszó jelöli), hanem csak mellékhatása volt: az, hogy a kez névvel jelölt tömbben a kártyalapok sorrendje megváltozott.

photo_camera Grafika: Tóth Róbert Jónás

Kezdjük a kukacoskodást azzal a kérdéssel, amit már az antik filozófusok is feltettek hasonló esetekben: vajon ugyanaz a lény maradt-e a kártyapaklink annak ellenére, hogy így megváltoztattuk? A programozásban, legalábbis az előző példánk esetében szerencsére viszonylag egyszerű választ adhatunk erre: igen, ugyanaz a lény maradt, mert a változtatás után továbbra is ugyanazon a helyen tartózkodnak az adatok, és ugyanúgy tizenhárom kártyalapot jelölnek. Vagyis a programozásban egy lény önazonosságát az határozza meg, hogy a neki megfelelő adatok a memóriának melyik zugát foglalják el, és hogy hogyan értelmezzük azokat az adatokat. Megtehettük volna, hogy a kártyalapokat nem destruktív módon rendeztük volna sorba, hanem változatlanul hagyjuk őket, és létrehozunk egy másik paklit, amiben már sorba vannak rakva ugyanazok a lapok: az már egy másik lény lett volna. Vagy ha a memóriának azt a részét, ahol az eredeti paklinkat tároltuk, valami egészen más célra kezdtük volna el használni, akkor is másik lényről vagy lényekről kellett volna beszélni.

Rögtön meg kell említenem azt is, hogy nem minden programozási nyelvben ilyen egyszerű a lények destruktív, helyben történő megváltoztatása. Például vannak olyan nyelvek, amikben általában nem lehet helyben megváltoztatni a lényeket, hanem újakat kell létrehozni, ha a módosított változatukra van szükségünk. Ilyenek a szigorúan funkcionális nyelvek, ahol tehát az eljárások közelebb állnak a matematikai függvényekhez. Gondoljunk csak bele: a matematikában sem tudják a függvények megváltoztatni azt, amire alkalmazzuk őket, hanem csak arra jók, hogy azokhoz hozzárendeljenek, kiszámoljanak egy értéket, és az érték egy új adat, egy új lény. (Ilyen nyelv például a Lisp nyelv sok változata, vagy a Haskell nyelv.) Ezért van az, hogy ezekben a nyelvekben minden eljárásnak van visszatérési értéke, az adatokat pedig nem lehet megváltoztatni. (A megváltoztathatóságot ebben az értelemben angolul mutability-nek mondják, az ilyen módon változtatható adatokat mutable-nek mondják, ha az olvasó rá akarna keresni.) És olyan nyelvek is vannak, ahol külön (a változó deklarálásánál) ki kell jelenteni, ha változtatható (mutable) adatra van szükségünk (ilyen például a Rust programozási nyelv).

Lakók és lakcímek

Az előbb láttuk, hogy a lényeknek, így a kártyapaklinknak az önazonossága azon alapul, hogy hol tároljuk a nekik megfelelő adatokat. Folytassuk akkor a kukacoskodást azzal, hogy mit is jelöl pontosan a fenti kez szimbólum? Az adatsort, ami a tizenhárom kártyalapunkat ábrázolja, vagy pedig azt a helyet a memóriában, ahol ezt az adatsort tároljuk? A kettő egyáltalán nem ugyanaz, sem elméletben, sem a gyakorlatban. Az első egy huszonhat számnyi bitsorozat, míg a második egy úgynevezett cím, olyan mint valakinek a lakáscíme vagy az e-mailcíme, ami csak azt adja meg, hogy hol érhető el az illető, vagyis itt azt, hogy hol lehet megtalálni azt a bizonyos bitsorozatot.

A programozási nyelvek abban is eltérnek, hogy hogyan értelmezik a szimbólumokat. Például a Java nyelvben minden szimbólumot „lakcímként” kell érteni, míg például a C vagy a C++ nyelvben minden szimbólum deklarálásába bele kell foglalni, hogy adatot, vagy címet jelöl-e. A Python nyelvben pedig bármelyik változó jelölhet adatot vagy az illető adat címét is, a helyzettől függően a rendszer kitalálja, hogy melyik értelmére gondoltunk.

Ez a második kérdés az előzővel is összefügg. Ha olyan eljárásra van szükségünk, ami helyben alakítja át valamelyik lényünket (ehhez persze megváltoztathatónak kell lennie), akkor annak nem lehet az adatsorunk a paramétere, muszáj, hogy megmondjuk neki, hol találja a feldolgozandó adatokat, hiszen különben nem tudná ott helyben megváltoztatni őket, tehát magának az illető helynek (címnek) kell a paraméternek lennie. A Javában ezzel nem kellett szórakoznunk, mert amúgy is minden szimbólum címet jelöl; Pythonban szintén nem kellene ezzel törődnünk, mert az ottani sorbarak eljárás tartalmából a rendszer rájönne, hogy destruktív (helyben történő) változtatásról van szó, tehát hogy az eljárás paraméterét lakcímként kell értelmeznie. Más nyelvekben viszont külön vigyáznunk kell arra, hogy ilyenkor ne az adatokat, hanem a címet adjuk meg paraméterként. Hogy hogyan, arra lejjebb lesz egy példa, de nyelvenként persze ez is változhat.

A virtuális lények élettartama

Talán ez is kukacoskodásnak tűnik, de egyszer muszáj beszélni a virtuális lények élettartamáról is. Mert ez nagyon fontos: minden lényünk valamennyi helyet foglal el a memóriában, és lehet, hogy a programunk nagyon sok lényt hoz létre. Ha elrontunk egy programot, és nem szabadul fel minden elfoglalt hely, amire már nincs szükség, akkor egy idő után bajt csinálhat, lelassíthatja a számítógépünk működését. Ha ezt a hibát követjük el, akkor az történik, amit úgy hívnak, hogy „memóriarepedés”, angolul memory leak. Emiatt a lényeink nem élhetnek örökké: kíméletesen, de időben meg kell őket semmisítenünk, az általuk elfoglalt helyet fel kell szabadítanunk.

A legegyszerűbb eset az, amikor egy lény élettartama az a blokk, amiben helyet foglaltunk neki. Ezt az esetet egészen hibásan „helyi (lokális) változóknak” szokták nevezni; ezt a buta elnevezést csak azért említem meg, hogy ha valaki rá akar keresni, megtalálja. Az elnevezés abból származik, hogy az ilyen lényeket nyilván olyan szimbólumokkal nevezzük el, amik ezt az értelmezésüket csak az adott blokkon belül (például egy eljáráson vagy cikluson belül) tartják meg, csak ott van értelmük. Például (az alábbi kis program C nyelven van):

#include // kell a nyomtatáshoz

int main() // eljárás értékének típusa, neve, paraméterei

{

{ // blokk kezdete (csak úgy, nincs sok teteje)

char allat[] = "majom"; // helyi változó és értéke

puts( allat ); // a változó értékét a képernyőre nyomtatja

} // blokk vége

} // a 'main' eljárás törzsének vége

Az allat szimbólum nem használható a blokkon kívül, tehát a következő program hibás:

///////////

// HIBÁS!!!

///////////

#include // kell a nyomtatáshoz

int main() // eljárás értékének típusa, neve, paraméterei

{

{ // blokk kezdete (csak úgy, nincs sok teteje)

char allat[] = "majom"; // helyi változó és értéke

} // blokk vége

puts( allat ); // !!! EZ A HIBÁS SOR!

} // a 'main' eljárás törzsének vége

Szóval itt az allat változónak megfelelő lény (a majom string) élettartama az a névtelen blokk, amiről azt írtam a kommentárba, hogy nincs sok teteje. Amikor a program végrehajtásában erre a blokkra kerül sor, a rendszer lefoglal annyi helyet, amennyibe a majom string elfér, és amint a blokk végrehajtása megtörtént, a rendszer felszabadítja a számára lefoglalt helyet. (A valóság egy picit bonyolultabb is, meg egyszerűbb is. A C nyelvben ugyanis csak akkor használhatunk ilyen helyi adatot, ha a programból pontosan kiderül, hogy mennyi helyre van szükség a tárolásához – ahogy a fenti példában is. Így valójában nem a program futásakor gondoskodik a rendszer a hely lefoglalásáról, hanem már az előzetes feldolgozása, a fordítása idején. A program kezdettől fogva „tud róla”, hogy ha majd ehhez a ponthoz ér, hol találja azt a helyet, ahol a majom string helyet foglal.)

Hogy miért rossz az elnevezés? Hát azért, mert a lényünk élettartama nem azon múlik, hogy maga a változó csak a blokkon belül tud rá utalni, hanem azon, hogy egyáltalán csak a blokkon belül hozzáférhető, azon belül létezik. Valójában nem az a lényeg, hogy a változó helyi, hanem az, hogy az adat csak ott helyben áll rendelkezésre. Helyi változókkal (amik tehát egy blokkon belül rendelkeznek egy bizonyos értelmezéssel) valójában nemcsak helyi adatokat jelölhetünk, hanem olyanokat is, amik már azelőtt is elérhetők voltak, hogy a program a blokkhoz ért volna, és esetleg azután is hozzáférhetőek, miután az illető blokkon már túlment a program futása.

Dinamikus adatok

A helyi adatok tehát úgy működnek, hogy a rendszer gondoskodik róla, hogy a program futásának egy bizonyos szakaszában a rendelkezésünkre a tárolásukhoz szükséges hely, és annak a szakasznak a végén az fel is szabaduljon. (Egyébként ezek a helyek a memóriának egy elkülönített részében vannak, amit stack-nek neveznek.) Vagyis teljesen felszabadultan, gondtalanul használhatjuk őket, a rendszer a gondjukat viseli helyettünk. Sőt, az olyan nyelvekben, mint a C, ahol előre meg kell mondanunk a szükséges hely méretét is, iszonyú hatékony a helyi adatok használata, mert nem is a program futásakor gondoskodik róluk a rendszer.

A másikféle adatot, ami nem helyi, csak akkor érdemes használni, ha nem tudjuk előre, hogy mennyi helyet fog elfoglalni (vagy ha a programon belül változtatni akarjuk a méretét, akkor nem tudjuk, hogy mekkora lehet a maximális mérete). Viszont megvan az az óriási hátránya, hogy a program futása közben kell a rendszernek helyet keresnie neki, hiszen nem tudhatja előre, mennyi lesz a helyigénye. Ráadásul sok programozási nyelvben hibák forrása lehet, mert a használatakor a programozóra van bízva, hogy amikor már nincs rá szükség, a megfelelő utasítással felszabadítsa a helyet, amit elfoglalt. Az ilyen adatokat dinamikusnak nevezik, és a rendszer szintén a memóriának egy elkülönített részében, az ún. heap-ben tárolja őket.

Még egyszer az elnevezésről: Mint mondtam, helyi változók is utalhatnak ilyen dinamikus adatra. Tehát amolyan bogár – rovar viszonyról van szó: helyi adatot csak helyi (a blokkon belül) értelmezett változóval adhatunk meg, de a helyi változó másféle adatot is jelölhet.

Vegyünk példaként egy rövid C++ nyelvű programot:

int* oszt( int lapszam ) // érték típusa, eljárás neve, paramétere
{
int* kez = new int[lapszam]; // dinamikusan tárolt tömb
int i; // helyi adat
for ( i = 0; i < lapszam; ++i ) // ciklus
{
kez[i] = i; // adatok változtatása
}
return( kez ); // az érték a lefoglalt hely címe
}

A C++ nyelvben az int* az olyan memóriahelyek típusa, ahonnan kezdve valahány egész számot tárolunk a memóriában. A kez helyi változó, de dinamikusan lefoglalt („allokált”) helyre utal. Ezt mutatja a példában a new kulcsszó használata, és az is, hogy a hely méretét nem tudhatjuk előre (az attól függ, hogy a lapszam paraméter mekkora egész szám lesz). Az oszt eljárás végrehajtása után ez a memóriarész nem szabadul fel, és ez csak azért nem hiba ebben a programban, mert az értékként visszaadott memóriacím nem vész el, így később még felszabadítható (ez a C++-ban a delete kulcsszóval történik):

int main()
{
int* lapok = oszt( 13 );
// ...
delete [] lapok;
}

Ez egyébként nem szép programozási stílus: mindenkinek azt tanácsolom, hogy a dinamikus helyfoglalás és a hely felszabadítása mindig egyetlen blokkon (eljáráson) belül történjen, hogy könnyebb legyen ellenőrizni, mindent felszabadítottunk-e, amit lefoglaltunk. Így:

// paraméterként adjuk meg a lefoglalt hely címét:
void oszt( int* kez, int lapszam )
{
int i;
for ( i = 0; i < lapszam; ++i )
{
kez[i] = i;
}
}

int main()
{
int *lapok = new int[13]; // itt foglaljuk le a helyet
oszt( lapok, 13 );
// ...
delete [] lapok; // és itt szabadítjuk fel
}

Vannak programozási nyelvek (ilyen például a Java és a Python is), amik megkönnyítik a programozó munkáját azzal, hogy nem kell az ilyen dinamikusan tárolt adatok helyét külön felszabadítanunk, amikor már nincs szükség az adatokra. Vagyis ezeknél a nyelveknél maga a rendszer, anélkül, hogy nekünk erről gondoskodnunk kellene, vagy figyelnünk kellene rá, előbb-utóbb észreveszi, ha azokra az adatokra nincs szükség, és felszabadítja a helyüket (amit aztán újra igénybe vehet egy új lény létrehozásához). És honnan tudja, hogy nincs rájuk szükség? Onnan, hogy be tudja bizonyítani: úgysem tudnánk többé hozzájuk férni, nem létezik másik szimbólum, ami az ott őrzött adatokra utal. Ezt a műveletet szemétgyűjtésnek vagy takarításnak (angolul: garbage collection) nevezzük. Nagyon kényelmessé teszi a programozást, de persze nincs ingyen ebéd: a már feleslegessé vált memóriarészek megtalálása és felszabadítása időigényes, ennyivel lassabb lesz a program futása. Ráadásul nem biztos, hogy a rendszer az első adandó alkalommal kitakarítja a memóriát, és megszabadul a már nem használható adatoktól, amiknek korábban helyet foglalt. Lehet, hogy csak akkor kezd takarítani, amikor már fogytán a hely (vagy ha már az egész program lefutott), és akkor bizonyosan belassítja a gépünket. Ezért van az, hogy „az igazi programozók” nem bízzák a dinamikus memóriakezelést a rendszerre.

A szerző nyelvész, az MTA Nyelvtudományi Intézetének főmunkatársa. A Qubiten futó Nyelv/gép/ember sorozatának összes írása itt olvasható.