2. gyakorlat

(A tárgy oldala)

Összefoglaló

  • Értékek, pointerek, referenciák
  • Const-correctness

Segédanyag

Az órai példa a különböző paraméterátadási módokkal.

Értékek, pointerek, referenciák

Terminológia:

(informálisan) Szimbólumnak nevezek minden olyan szót, amely nem része a nyelvnek, hanem mi deklaráljuk.
Ezt általában a forráskódban tesszük meg mi magunk, vagy valamilyen beincludeolt header fájlból származnak.
A szimbólumoknak két típusát különböztetjük meg: függvény-szimbólumok és változók. Ezzel szemben azokat
a szavakat, amelyek a nyelv részei, kulcsszavaknak nevezzük.

Típusokról:

A C++ nyelvben minden szimbólumnak van valamilyen típusa. A fordítás során a fordítónak mindig pontosan
tudnia kell, hogy egy adott szimbólum milyen típusú.

A következő fejezetben feltételezzük, hogy a szimbólumaink változókat jelölnek. Az itt leírtak
ennek ellenére érvényesek a függvény-szimbólumokra is.

Tehát amikor programunk fordítása során a fordító találkozik egy szimbólummal, meg kell, hogy állapítsa
annak típusát. A szimbólumunkhoz minden esetben kötni tud valamilyen memóriacímet. Ezen a címen
található a szimbólum típusának megfelelő értéke. A szimbólumunk azonban nem feltétlenül valamilyen
egyszerű típus, mint amilyen egy int, vagy egy struktúra. A szimbólum lehet például pointer is. Például
egy int* egy int-re mutató pointer típus. Ehhez hasonlóan egy char** egy char*-re mutató típus. Látni
fogjuk nemsokára, hogy a * nem az egyetlen olyan szintaktikai elem, amellyel típusunk feldíszíthető.

Minden típushoz tartozik egy méret is. Ez határozza meg, hogy az adott típus hány byte-ot foglal
el a memóriában. (Attól a címtől számítva, ami az adott típusú szimbólumunkhoz van társítva.)
Ez a méret bármely típusra, valamint bármely szimbólumra lekérdezhető a nyelv sizeof(...) kulcsszavával.

Példák:
sizeof(int) -> 4 a 32 bites, 8 a 64 bites architektúrákon és operációs rendszereken.
sizeof(char*) -> szintén 4 vagy 8
sizeof(bool) -> 1
sizeof(valamilyen_osztaly) -> Az osztály mérete, amely bármekkora lehet, az osztálytól függően.

A méret mindig legalább 1 byte. Még az üres osztályoknál is. (Később tanulunk az osztályokról)
Ennek az az oka, hogy két, a programban egyszerre élő változó címe nem egyezhet meg. Ha az
üres osztály mérete 0 volna, akkor két egymás után felvett példánya miért ne eshetne ugyanarra
a memóriacímre?

Motiváció: Miért fontos a típusok méreteivel foglalkozni?

Tömören: A változók függvények számára átadása miatt.

Klasszikusan a paraméterek átadása úgy történik, hogy a függvényt hívó fél a veremre helyezi a paraméterek
értékeinek másolatát, majd meghívja a függvényt. Ez a módszer sok esetben megfelelő, azonban számos
problémát is hordoz. Nevezetesen:

  • Ha így adjuk át a paramétereket, akkor a meghívott függvény hiába módosítja a kapott értékeket, a hívó
    számára ezek változatlanok maradnak. Természetesen ez általában inkább előny, mint hátrány.
  • A veremre helyezni a dolgokat sok idő tud lenni. Ugyan említettem, hogy a verem rendkívül gyorsan
    foglal memóriát, de ezt a lokális változókra értettem. Azaz amikor a program belép egy függvénybe,
    ami sok lokális változót használ, azokat gyorsan lefoglalja, hiszen nem kell gondolkoznia azon, hogy
    hova tegye őket. Egyszerűen a verem végét jelző pointert kell átállítani, amely egyetlen gépi utasítás.
    Azonban a veremre helyezni tényleges értékeket, az sok idő tud lenni, főleg, ha olyan típusokat helyezünk
    el, amelyek nagy méretűek. A sok idő arra megy el, hogy a memóriához szóljunk a CPU-ból.
  • A verem nem túl nagy. Említettem, hogy emlékeim szerint Linuxon általában 4Mb-ot használnak, amely
    soknak tűnhet, de például egy 200 Mb-os puffert nem is lehetne átadni így, egy 512Kb-osat pedig szimplán
    nem éri meg. Minél több nagy dolgot adunk át a vermen, és minél méllyebbre hívunk egyre több függvényben,
    annál nagyobb az esély, hogy verem túlcsordulás történik. (Rekurziók esetén ez különösen veszélyes lehet!)

Természetesen más okokból is érdemes mindig szem előtt tartani, hogy mely változónk mekkora helyet foglal el,
de ahhoz, hogy megértsük a pointerek és a most megismerendő referenciák létjogosultságát, elég ennyit is látnunk.

Érték szerinti átadás

Ez lényegében az, amit fent leírtam. Az átadandó paraméterek másolatai a veremre kerülnek, majd megtörténik
a függvényhívás, és a meghívott függvény a veremről eléri a másolatokat. Tehát ha a meghívott fél módosít
az értékeken, a hívó értékei akkor is megmaradnak. Példa:


typedef struct big_structure {
    char buffer[512]; // sok memoria!
} big_structure;

void foo(int a) {

    a = 5;
}

void bar(big_structure boo) {

    strcpy("Hal",boo.buffer);
}

int main(int argc, char** argv) {

    int x = 0;

    // meghivjuk foo-t, amely modositja a kapott ertek masolatat
    foo(x);

    // kiirja, hogy 0 - meg mindig
    cout << x << endl;
    big_structure biggy;

    // kiirja, hogy 512
    cout << sizeof(biggy) << endl;

    // teszunk a bufferbe erteket
    strcpy(biggy.buffer,"Macska");

    // kiirjuk a buffer tartalmat (macska)
    cout << biggy.buffer << endl;

    bar(biggy);

    // kiirjuk ujra a buffer tartalmat - megint macska lesz.
    cout << biggy.buffer << endl;
    return 0;
}

Látható, hogy nyelvtan szempontjából az érték szerinti átadás a legegyszerűbb: A függvény
fejlécében pusztán megadjuk a típus nevét, minden féle csillag, vagy hasonló elem alkalmazása
nélkül.

Egy jobb megoldás - pointerek

A fenti példában jó volna, hogyha nem kellene az 512 byte méretű big_structure példányokat a vermen
keresztül másolással közlekedtetni a függvényeink között. Ezért a C-ből megismert pointerek segítségével
fogjuk most átadni:

typedef struct big_structure {
    char buffer[512]; // sok memoria!
} big_structure;

void bar(big_structure* boo) {

    strcpy("Hal",boo->buffer);
}

int main(int argc, char** argv) {

    big_structure biggy;

    // kiirja, hogy 512
    cout << sizeof(biggy) << endl;

    // teszunk a bufferbe erteket
    strcpy(biggy.buffer,"Macska");

    // kiirjuk a buffer tartalmat (macska)
    cout << biggy.buffer << endl;

    bar(&biggy);

    // kiirjuk ujra a buffer tartalmat - megint macska lesz.
    cout << biggy.buffer << endl;
    return 0;
}

Félkövérrel kiemeltem a lényeges változásokat. A valóságban ekkor is a vermen keresztül adunk át paramétereket,
azonban ekkor bar paramétere nem magának a struktúrának egy példánya, hanem a struktúra egy példányának a 
címe
. Tehát pusztán 4 byte. Ez a példa segít feleleveníteni azt is, hogy az & operátor az utána szereplő szimbólum
memóriacímét adja meg, a * jel egy típus után írva egy pointer típust határoz meg az utána szereplő típusra,
-> operátor pedig egy adott típusra mutató pointer által mutatott objektum egy tagját éri el. (A azon felül, hogy
típusok után szerepelhet, előfordulhat ugyebár egy pointer előtt is - ekkor a pointer által mutatott értékre oldódik
fel.)

A megvalósítás C-ben már majdnem optimális volna. Egyedül az vele a probléma, hogy ekkor a bar függvénynek minden
esetben lehetősége van módosítani az átadott paraméter értékét - ami önmagában nem nagy dolog, azonban ebben
az esetben a hívó fél számára is megváltozna az átadott paraméter értéke. Ezt a const kulcsszó meg fogja oldani. (később)

Van még egy probléma: Mi van, ha a bar számára átadott paraméter NULL? Ekkor a program a gyakorlatban általában
összeomlik. Az ilyen és ehhez hasonló helyzeteket nevezzük undefined behaviour
nak.

További kellemetlenség, hogy a bar szerzője logikailag hiába dolgozna pusztán egy big_structure példánnyal, nem
feledkezhet meg a fránya csillagról, vagy a nyílról. Pedig őt nem érdeklik egyáltalán a big_structure memóriacímek.

Az ilyen helyzetek megoldására a C++ felkínálja a referenciák alkalmazásának lehetőségét.

Referenciák

A referencia tekinthető egy rendkívül biztonságos pointernek. A referencia megoldja a fenti csillagos-nyilas szintaktikai
problémát és a NULL pointer problémáját is, mindazonáltal ugyanolyan gyors paraméterátadást tesz lehetővé, mint
egy pointer. (A lefordított programban a referencia is pusztán egy memóriacím már - a CPU a pointerek és a referenciák
közti különbségről nem tud.)

Példa egy referenciákat alkalmazó megoldásra:
 

typedef struct big_structure {
    char buffer[512]; // sok memoria!
} big_structure;

void bar(big_structure& boo) {

    strcpy("Hal",boo.buffer);
}

int main(int argc, char** argv) {

    big_structure biggy;

    // kiirja, hogy 512
    cout << sizeof(biggy) << endl;

    // teszunk a bufferbe erteket
    strcpy(biggy.buffer,"Macska");

    // kiirjuk a buffer tartalmat (macska)
    cout << biggy.buffer << endl;

    bar(biggy);

    // kiirjuk ujra a buffer tartalmat - megint macska lesz.
    cout << biggy.buffer << endl;
    return 0;
}

Látható, hogy bar hívása során úgy néz ki, mintha érték szerint adnánk át. bar deklarációja már érdekesebb:
Megjelent egy új szimbólum, az & (magyarul nem tudok rá szép szót, és-jelnek szoktam mondani). Amennyiben
ezt a típus neve után írjuk, úgy a típusból képzünk egy referencia-típust.

int  a = 3;
int& b = a; // b egy referencia a-ra (ugy kepzeld el, mint egy pointert)
b = 2;
cout << a << endl; // kiirja, hogy 2

A referencia típusok számos érdekes tulajdonsággal rendelkeznek. Nevezetesen:

  • Mindig inicializálni kell őket létrejöttükkor.
  • Nem lehet értékadással megváltoztatni, hogy mire mutatnak. Ha egy referencia egyszer létrejött, akkor
    az garantáltan mutat valamire, és élete során csak arra a valamire tud mutatni.
  • Referenciáról nem lehet referenciát képezni. Ha lehetne, akkor meg lehetne változtatni, hogy hova
    mutat egy referencia. (Gondoljatok bele, hogy miért!)
  • Nem mutathatnak átmeneti értékekre.
  • Érdekesség: Gond nélkül mutathatnak pointerekre!

Néhány példa:

int a = 3; // OK
int& b = a; // OK
int& c = 3; // NEM OK - atmeneti objektumra mutat, az utasitas vegen a 3-as megsemmisul!
int& d = b; // OK, d is a-ra mutat
int& e; // NEM OK - nincs inicializalva!
a = 2; // a,b es d is valtozik!
b = 3; // a,b es d is valtozik!
cout << &a << endl; // kiirja a cimet
cout << &b << endl; // kiirja ugyanazt a cimet!

Informálisan a referencia egy kicsit olyasmi is, mintha egy másik nevet vezetnénk be ugyanarra a változóra.

Ezzel a pointerek taglalása során felmerülő problémák nagy részét megoldottuk:

  • Nem lehet NULL-t átadni.
  • A hívott függvényben szintaktikailag úgy viselkedik a paraméterszimbólum, ahogy azt elvárjuk.

Milyen probléma maradt még?

  • A hívott fél módosíthatja az átadott paraméter értékét úgy, hogy az a hívó számára is változik.

Erre a problémára a megoldást a C nyelvből is ismert const kulcsszó adja meg.

Const-correctness

Annak kérdéskörét, hogy a program mely része mikor milyen adatba túrkálhat bele, röviden const-correctnessként
nevezzük. Egy központi, és két ritkább nyelvi kulcsszó kapcsolódik ide, nevezetesen: constconst_cast és mutable. 

A const kulcsszót típusok elé írva megadhatjuk, hogy ezt a típust nem lehet módosítani ebben a kontextusban.
Példák:

int a = 3; // OK
a = 2; // OK
const int b = 4; // OK
b = 2; // NEM OK - nem modosithato

const char* my_text = "Valami szoveg";
my_text[0] = 'a'; // NEM OK - a my_text altal mutatott szoveg nem modosithato
const char* other_text = "Mas szoveg";
my_text = other_text; // OK - azt nem mondtuk, hogy maga a memoriacim, amit my_text fog, az nem modosithato
// innentol fogva my_text is "Mas szoveg"-re mutat.

char buffer[32];  // Felvettem egy 32 elemu puffert.
char buffer2[32]; // Felvettem egy masik 32 elemu puffert.
const char* p1 = buffer; // ...es egy pointert is, ami a pufferre mutat.
p1[0] = 'A';  // NEM OK - az, amire p1 mutat, az NEM modosithato
p1 = buffer2; // OK - az, hogy mire mutat p1, az modosithato

char const* p2 = buffer;
p2[0] = 'A';  // OK - az, amire p2 mutat, az modosithato
p2 = buffer2  // NEM OK - az, hogy mire mutat p2, az NEM modosithato

const char const* p3 = buffer;
p3[0] = 'A';  // NEM OK - az, amire p3 mutat, az NEM modosithato
p3 = buffer2; // NEM OK - az, hogy mire mutat p3, az NEM modosithato

Ennek fényében a fenti példa legszebb megoldása:

typedef struct big_structure {
    char buffer[512]; // sok memoria!
} big_structure;

void bar(const big_structure& boo) {

    strcpy("Hal",boo.buffer); // ekkor boo.buffer-t csak const char[512]-kent eri el az strcpy
    // ez nem is baj, az strcpy masodik parametere const char* eredetileg is
}

int main(int argc, char** argv) {

    big_structure biggy;

    // kiirja, hogy 512
    cout << sizeof(biggy) << endl;

    // teszunk a bufferbe erteket
    strcpy(biggy.buffer,"Macska");

    // kiirjuk a buffer tartalmat (macska)
    cout << biggy.buffer << endl;

    bar(biggy);

    // kiirjuk ujra a buffer tartalmat - megint macska lesz.
    cout << biggy.buffer << endl;
    return 0;
}

Így már nem kell tartanunk attól, hogy a bar függvény módosítja az átadott paraméter valamely mezőjét.

Fontos tudni még, hogy egy T, T*, T**, stb..., T& típusból bármikor képezhetünk const T, const T*, const T**,
const stb..., const T& típust, fordítva azonban nem. Épp ez a const lényege. Ha valaha bármi miatt is meg
szeretnénk szabadulni egy const kulcsszótól, azt a const_cast<T>(amit_consttalanitani_akarsz) kifejezéssel
teheted meg - de kérlek ne tedd, ez a rossz programtervezés jele!

Elképzelhető, hogy szeretnénk olyan mezőket elhelyezni egy T struktúrában, amelyek akkor is módosíthatóak
const_cast nélkül is, ha az adott struktúrát éppen csak const T, const T*, const T**, stb..., const T&-ként érjük
el. Ekkor ezt a mezőt mutable-nek kell deklarálnunk:

typedef struct big_structure {
    char buffer[512];
    mutable int x; // x akkor is modosithato, ha az adott peldanyt csak const-kent erjuk el! 
} big_structure;

Természetesen egy struktúrának is lehetnek const adattagjai:

typedef struct big_structure {
    char buffer[512];
    mutable int x; // x akkor is modosithato, ha az adott peldanyt csak const-kent erjuk el! 
    const int y; // y nem modosithato, barhogy is adjuk at a strukturat.
} big_structure;