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.
Tartalomjegyzék
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.
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.
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.
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) |