6. fejezet - Tömbök és mutatók


Az eddigiekben egyszerű változókkal foglalkoztam. A programozás során azonban felmerülhetnek olyan esetek, amikor ez nem elég, például mert sok adatot kell kezelni. Ebben a fejezetben a tömbökről és a mutatókról (pointerekről) lesz szó. Látható lesz, hogy használat szempontjából a két dolog annyira nem is különbözik.



Az automatikus változók, amikkel eddig volt dolgunk az úgynevezett végrehajtási vermen (stack) foglalódnak le. Ezt úgy lehet elképzelni mint egy dobozt, amibe csak a tetején lehet elemet berakni és kivenni (aki még emlékszik a PEZ nevű cukorka adagolójára az tudja mire gondolok). Az itt lefoglalt változókat azért hívják automatikusnak, mert a helyfoglalásba és felszabadításba nem lehet beleszólni, automatikusan történik. Ilyen változóknál fordítási időben ismerni kell a változó méretét és ez alapján a fordító a szimbólumtáblában nyilván tudja tartani a (a verem aljához képest relatív) címét. Az ilyen változók az adott scope végén megsemmisülnek.

Ezzel szemben a statikus változó nem itt foglalódik le, hanem a statikus memóriaterületen ; ezt is a fordító intézi el, viszont csak egyszer foglalja le, és a program befejezéséig létezni fog, függetlenül attól, hogy milyen scope-ban deklaráltuk. Ehhez majd kapcsolódni fog egy külön fejezet ami a hatókört, a láthatóságot és az élettartamot magyarázza el. Rosszabb helyeken az automatikus változókat is statikusnak hívják (van benne logika, de alapvetően helytelen).

De most nézzünk egy gyakorlatiasabb problémát. Írjunk egy olyan programot, ami beolvas a standard inputról tíz darab számot, majd kiírja őket fordított sorrendben. Hogy csinálná ezt egy buta programozó? Felvenne tíz darab változót, majd beolvasná a számokat valahogy így:

CODE
std::cin >> v1 >> v2 >> v3 ...

Ez nem túl kényelmes; mi van ha nem tíz, hanem száz számra akarnám ugyanezt? Ember legyen a talpán aki lekódolja egyesével a változókat (láttam már rá példát...). A legtöbb programnyelvben van erre egy sokkal kényelmesebb eszköz, a tömb. A tömbök első ránézésre szokványos változóknak tűnnek, csak a nevük mögött ott van egy furcsa szögletes zárójel és közötte egy szám, a tömb elemszáma. Ez egy összefüggő memóriaterület az adott típusból. A tömb egy elemére szintén a [] zárójellel lehet hivatkozni, közéírva az elem indexét. Az indexelés nullától indul, tehát egy tíz elemű tömb nullától kilencig indexelhető.

CODE
int myarray[10]; for( int i = 0; i < 10; ++i ) std::cin >> myarray[i]; // ...

Amit erről fontos tudni, hogy mivel a stacken foglalódik le, ezért a méretének fordítási időben ismertnek kell lennie, tehát a deklarációkor konstans kifejezésnek kell állnia a szögletes zárójelek között. A méretét futási időben nem lehet megváltoztatni.

Az ügy szempontjából egy fontos tömb a karaktertömb. Eddig amikor ki akartam írni valami szöveget, azt macsakakörmök közé tettem. Ezt úgy hívják, hogy string literál vagy zero terminated string azaz nullára végződő füzér. Ezek a statikus memóriaterületen foglalódnak le, tehát például a szám literálokkal ellentétben ténylegesen ott vannak a memóriában, viszont csak olvashatóak. Amit tehetünk velük, hogy kimásoljuk őket egy karaktertömbbe.

CODE
char a1[6] = "hello"; char a2[] = { 'h', 'e', 'l', 'l', 'o', '\0' };

Látható, hogy az automatikus tömböknek lehet inicalizáló listával kezdőértéket adni. Ha több elemet adunk meg, az fordítási hiba lesz. Amennyiben az inicializáló listából a fordító ki tudja következtetni a tömb elemeinek számát, akkor a a szögletes zárójel üresen hagyható.

Ha egy karaktertömböt inicializáló listával deklarálunk, akkor nekünk kell gondoskodni a lezáró nulla karakterről. Ha hiányzik, abból futási idejű hiba lehet.

Sokan írnak ilyen kódot, hogy int t[10] = { 0 };, mert azt hiszik, hogy így a tömb minden eleme majd nulla lesz. Hát nem, csak az első elem, a többi marad definiálatlan. Némely fordító viszont alapból 0-val tölti fel a tömböket.


Ez egyeseknek nehezen érthető része a C-nek és egyben a legveszélyesebb is. A programok lefagyásának leggyakoribb oka egy rossz helyre mutató pointer. A változókról azt írtam a második fejezetben, hogy memóriaterületek. A számítógépekről tudjuk, hogy a memóriát címzéssel érik el, mintha egy bazinagy tömb lenne. Egy mutató egy ilyen címet képes tárolni. Valójában ez is egy teljesen szokványos változó (többnyire 32/64 bites előjel nélküli szám), csak ez memóriacímet tárol. A deklaráció konvencionálisan úgy történik, hogy a típus után, vagy a változó elé egy csillagot teszel.

CODE
int* p1; int *p2;

Az első módszer kicsit csalóka. Deklaráljunk két karakterre mutató pointert, majd nézzük meg a méretüket a sizeof operátorral (ez egy fordítási időben ismert típus vagy változó memóriában elfoglalt méretét adja meg, bájtokban).

CODE
char* p1, p2; std::cout << sizeof(p1) << " " << sizeof(p2) << "\n"; // output: 4 1

Nem egészen ezt akartuk; az első még oké, de a második mintha nem pointer lenne. Mert nem is az; deklaráltunk egy karakterre mutató pointert, és egy karaktert. Ebből kifolyólag a második jelölést lenne célszerű használni, az viszont látszólag ellentmond a szemléletnek, miszerint a típust írjuk előre (márpedig a típus char*, gondoljunk egy függvénydeklarációra ahol nem írtuk ki az argumentumok neveit). A mondás az, hogy mindenki úgy deklarál pointert, ahogy akar. Én sima deklarációknál az elsőt használom, a lista deklarációknál pedig a másodikat. Tehát az eredeti feladatra valami ilyesmi a megoldás:

CODE
char *p1, *p2;

Najó, de mit lehet ezzel kezdeni? Egy változó címét elkérhetjük a & (et vagy címe) operátorral. Egy pointer által mutatott memóriaterületre pedig a * (dereference vagy csillag) operátorral hivatkozhatunk. A példában nézzük meg miért is lehet veszélyes egy pointer:

CODE
int i = 5; int* pi = &i; // elkérjük i címét *pi = 7; // a memóriaterületnek új értéket adunk std::cout << i << "\n"; // output: 7

Megváltoztattam a változó értékét úgy, hogy hozzá sem nyúltam! Oké ezt így még észrevesszük, de ha ez a pointer ötezer kódsorral arréb lenne? Netán egy másik fordítási egységben? Az még mindig a könnyebbik eset. A katasztrófa az amikor egy másik szálban van (párhuzamosan futó program). A mutatók mégis nélkülözhetelen eszközök a dinamikus memóriakezeléshez.


A változók eddig a stacken jöttek létre, azaz automatikusan foglalódtak és szabadultak fel. Amit tudni kell, hogy a stack mérete limitált (kb. 200 KB), tehát nem lehet akármekkora buffert allokálni. Egy másik probléma, hogy - mint láttuk - az automatikus tömbök méretének fordítási időben ismertnek kell lenni. Itt el is értünk a programozás egy nagyon fontos de borzasztó veszélyes részéhez, a dinamikus memóriakezeléshez.

A dinamikus memóriaterületet röviden heap-nek (kupac) hívják. Az itt létrehozott változók felszabadításáról neked kell gondoskodni. Dinamikus változót a new operátorral (nem keverendő az operator new-el) lehet létrehozni. A kifejezés eredménye az a memóriacím lesz, ahol a változó létrejött. Fontos: ezt már nem a fordító csinálja, hanem az operációs rendszer. Az alábbi példában lefoglalok egy int-et a heapen, majd amikor már nem kell felszabadítom a delete operátorral.

CODE
int* i = new int(); // a zárójelek között megadható kezdőérték *i = 5; std::cout << *i << "\n"; delete i; // ha már nem kell, felszabadítjuk

Sokan elfelejtik felszabadítani a dinamikus változókat, így azok a program befejezéséig léteznek. Az olyan dinamikusan lefoglalt memóriaterületeket, ahova már nem mutat pointer (tehát a kódból elérhetetlen) memory leak-nek nevezik. Például vegyük az alábbi kódot:

CODE
{ int* i = new int(); } // itt i már nem létezik, tehát a lefoglalt memóriát nem lehet többé elérni!!

Amint a program elhagyja a blokkot, a pointer megszűnik, de a lefoglalt memória még mindig ott van a heapen, viszont már semmiképpen nem lehet elérni. Erre is igaz az, hogy itt még látjuk, de egy több ezer soros kódban nagyon nem.
Módosítsuk kicsit a tömbös programot: a felhasználó mondhassa meg, hogy hány számot akar beírni. Ehhez szükség lesz egy dinamikus tömbre. Az előny világos; a szögletes zárójel között állhat változó.

CODE
int n; std::cout << "Mennyit akarsz beirni?: "; std::cin >> n; int* myarray = new int[n]; for( int i = 0; i > n; ++i ) { std::cout << "Kerem az " << i << ". szamot: "; std::cin >> myarray[i]; } // ... delete[] myarray;

Nem kell sokat magyarázni, csak a létrehozás és a felszabadítás más, egyébként ugyanúgy lehet használni mint a rendes tömböket.


Amikor deklarálsz egy pointert, és nem adsz neki kezdőértéket, akkor nem definiált az értéke, tehát egy tetszőleges memóriacímet tárol. Semmiféle lehetőség nincs arra, hogy ezt kiderítsük, ezért érdemes a pointert 0-val inicializálni. A legtöbb C++ fordítóban definiálva van a NULL makró azért, hogy némileg megkülönböztessük az integertől (valójában ez is 0, vagy (void*)0). Egy biztonságos pointerkezelési módszer például az alábbi:

CODE
int* a = 0; // ... a = new int(5); // ... if( a ) { delete a; a = 0; } // ...

Látható, hogy a pointerek implicit módon konvertálódnak logikai értékre. Fontos, hogy ha a new-nak nem sikerül lefoglalni a memóriát, akkor kivételt dob (std::bad_alloc, későbbi fejezet). A delete nullpointerre nem csinál semmit.


Amikor létrehozunk egy dinamikus tömböt, akkor ugyanúgy egy memóriacímet kapunk vissza (ami a tömb első elemére mutat). A pointereket lehet tologatni egy adott értékkel, viszont a pointer típusa határozza meg, hogy egy eltolás konkrétan hány bájtot is fog jelenteni. Ezt pointer aritmetikának nevezik. Nézzük az alábbi kódot:

CODE
int* t = new int[10]; // ... *t = 5; // a tömb első eleme *(t + 2) = 2; // a tömb harmadik eleme

A [] operátor ugyanezt csinálja, sőt már fordítási időben kifejtődik erre, tehát ígyis hivatkozhatnék egy tömbelemre: 3[t]. Ez tehát a pointertől három integer-nyi bájtra levö memóriaterület lesz, azaz összesen 12 bájttal arrébb. Most kasztoljuk át a pointert char* -ra:

CODE
char* bt = (char*)t; std::cout << (int)*bt << "\n"; std::cout << (int)*(bt + 2 * sizeof(int)) << "\n"; // output: 5 2

Látható, hogy mivel megváltoztattam a pointer típusát, ezért az eredeti érték kinyeréséhez figyelembe kell vennem, hogy mit is akarok kiolvasni. Érdemes mindig bájtokban gondolkodni, ugyanis világos, hogy innentől a fiktív tömbnek különböző típusú elemei is lehetnek. Tipikusan fájlkezelésnél jön ez elő.

Dinamikus memóriakezeléssel akár többdimenziós tömböket is lehet szimulálni. Csináljunk például egy 3x3-as integerekből álló táblázatot:

CODE
int* t = new int[9]; for( int i = 0; i < 3; ++i ) { for( int j = 0; j < 3; ++j ) { t[i * 3 + j] = (i + j); std::cout << t[i * 3 + j] << " "; } std::cout << "\n"; } delete[] t; /* output: 0 1 2 1 2 3 2 3 4 */

Oké ez jó, csak kényelmetlen. Mennyivel jobb lenne, ha t[i][j] -t tudnánk írni. Automatikus tömbnél működik a dolog, például deklarálhatnám a tömböt így: int t[3][3];, és akkor igy is kell hivatkozni az elemekre. A fordító majd kifejti a kétszeres indexelést a fenti kódra. De dinamikus tömbnél, hogy lehet ezt?

A helyzet az, hogy szép megoldás nincs, bár makrókkal lehet bűvészkedni. Ennek az az oka, hogy a C++ balról jobbra értékeli ki a kifejezést, tehát a t[i] mint részkifejezés értéke a tömb eleme, márpedig annak nincs [] operátora, amit a t[i][j] elvárna. Tehát ha dinamikusan akarsz többdimenziós tömböt, akkor egyrészt használhatod a példakódbeli szimulációs módszert (ha egy memóriaterületen kell az adat), vagy lehet használni pointerre mutató pointereket (ebben az esetben egy pointerekből álló tömbre mutató pointert):

CODE
int** t = new int*[3]; for( int i = 0; i < 3; ++i ) { t[i] = new int[3]; for( int j = 0; j < 3; ++j ) { t[i][j] = (i + j); std::cout << t[i][j] << " "; } std::cout << "\n"; } // ... for( int i = 0; i < 3; ++i ) delete[] t[i]; delete[] t;

Ez a kód létrehoz egy pointerekből álló tömböt, majd minden tömbelem egy integereket tartalmazó tömb lesz. Világos, hogy ekkor viszont a fenti *(i * 3 + j) -s módszer nem használható, mert az adat nem egy összefüggő memóriaterület: szét van szórva a memóriában.


Mivel az automatikus és a dinamikus tömb használatban eléggé hasonló felmerül a kérdés, hogy vajon ugyanaze-e a kettő? Látszólag igen, de ha egy automatikus tömbnek elkérem a címét, az vajon mi lesz? Hogyan deklarálnék egy olyan változót, ami a tömb címét el tudja tárolni? Annyira nem triviális a dolog, de meg lehet csinálni:

CODE
int t[3] = { 4, 5, 6 }; int (*pt)[3] = &t; std::cout << (*pt)[1] << "\n"; // output: 5

Ezek alapján azt lehet mondani, hogy C++ -ban van automatikus tömb és van a pointer (amibe beleeértem a dinamikus tömböt is). A kettő nem ugyanaz, de használatban eléggé hasonlóak.


Megtanultuk használni a tömböket és a dinamikus memóriakezelés alapjait. A pointereken kívül a C++ bevezetett egy új koncepciót is, a referenciát. Erről a következő fejezetben írok majd.


Höfö:

  • Írj egy olyan programot, ami bekér n darab számot, majd kiszámolja tetszőleges kettőnek az lnko()-ját (ezt is a felhasználó adhassa meg)!

Irodalomjegyzék

http://en.cppreference.com/w/cpp
http://www.cplusplus.com/
http://www.parashift.com/c++-faq-lite/ (ezt különösen ajánlott elolvasni)
http://aszt.inf.elte.hu/~gsd/halado_cpp/ (ezt is)

back to homepage

Valid HTML 4.01 Transitional Valid CSS!