Az objektumok életciklusa és memóriakezelése

Dobra Gábor, Horváth János · 2019.02.27.

Jegyzet 3. fejezet: az objektumok memóriakezelése

A fejezet anyaga videó formában is elérhető, bár kevésbé részletesen.

A típusaink, osztályaink összefogásának és átlátszatlanná tételének számos előnyét láttuk. Meg tudjuk akadályozni, hogy az osztályok belsejébe kívülről bele lehessen látni, és akár beépített típusként is tudnak viselkedni.

Ötlet: Rejtsük el ilyen fekete dobozok belsejébe a memóriakezelés gyötrelmeit, és bízzuk rá magunkat az automatikusan meghívódó, nem elfelejthető destruktorra és társaira!

C-ben úton-útfélén szembe jött velünk a sztringkezelés otrombasága. Egy meglévő típusra – karaktertömb – bíztuk rá egy teljesen más szemantika – szöveg – kezelését. Ez sosem jó jel – lásd bool és nullptr –, ráadásul a memóriakezeléssel is nekünk kellett bíbelődni.

1. Egy String osztály

Kezdjünk egy egyszerű osztállyal, gyakorlásképp: legyen fix méretű a String! Lehessen ugyanúgy indexelni, mint egy karaktertömböt, de az összefűzéshez használjuk az operator+-t! Természetesen lehessen kiírni és beolvasni, valamint némi C-s kompatibilitás sem árt. A több modulra bontás itt is alapvető fontosságú – String-re szinte mindenütt szükség van.

String.h:

class String {
    char str[256];      // nullával lezárt
  public:

    String(char const * s = "");

    String operator+(String const& rhs) const;
    String& operator+=(String const& rhs);
    String& operator+=(char rhs);

    int length() const;
    char const * c_str() const {
        return str;
    }
};

std::ostream& operator<<(std::ostream& os, String const& rhs);
std::istream& operator>>(std::istream& is, String& rhs);

String.cpp:

#include <iostream>
#include <cstring>
#include <cctype>
#include "String.h"

String::String(char const * s) {
    strcpy(str, s);
}

String String::operator+(String const& rhs) const {
    String result = *this;
    result += rhs;
    return result;
}

String& String::operator+=(String const& rhs) {
    strcat(str, rhs.str);
    return *this;
}

String& String::operator+=(char rhs) {
    int len = strlen(str);
    str[len] = rhs;
    str[len+1] = '\0';
    return *this;
}

int String::length() const {
    return strlen(str);
}

std::ostream& operator<<(std::ostream& os, String const& rhs) {
    os << rhs.c_str();
    return os;
}

std::istream& operator>>(std::istream& is, String& rhs) {
    char c;
    String uj;
    while(is.get(c) && !isspace(c))
        uj += c;

    rhs = uj;
    return is;
}

main.cpp:

#include <iostream>
#include "String.h"

int main() {

    String s1, s2;
    std::cin >> s1 >> s2;

    std::cout << "s1 + s2 = " << s1 + s2 << std::endl;

    s2 += s1;
    std::cout << "s2 + s1 = " << s2 << std::endl;

    return 0;
}

Figyeljük meg a C-kompatibilitás elemeit! Egyrészt a konstruktora implicit konverziót tesz lehetővé: akár char const*-gal is hívható egy String-et váró függvény. Másrészt c_str függvénnyel konvertálható C-s "sztringgé". Lehetett volna konverziós operátor is, de annak veszélyessége miatt így jobb: muszáj kiírni, ha használjuk.

A belső megvalósításnál kikötöttük, hogy str nullával lezárt karaktertömb. A használó szempontjából ez lényegtelen: őt csak az érdekli, hogy a hogyan kell használni. A saját érdekünkben döntöttünk úgy, hogy nullával lezárva tároljuk: egyrészt a c_str másképp nagyon macerás lett volna, másrészt így használhattuk a cstring általunk már ismert függvényeit.

Miért tagfüggvényként valósítottuk meg az összefűző operátorokat? Mert így volt kényelmesebb: hozzá kell férniük az str adattaghoz.

Egy operátor még hiányzik a listából: az indexelés, mivel arról eddig nem volt szó, és egy fontos szabállyal ismertet meg minket, így kiemelt helyet kapott.

2. Az indexelő operátor

Eddig szép és jó a saját String-ünk, de mi van akkor, ha ki akarjuk olvasni vagy átírni valamelyik karakterét? C-ben a klasszikus karaktertömböknél ezt meg lehetett tenni. Nem túl meglepő módon erről itt sem kell lemondanunk. Ehhez meg kell valósítanunk az indexelő operátort (operator[]). Mivel ez egy függvény, tudjuk benne ellenőrizni a túlindexelést.

char& String::operator[](int i) {
    if (i < 0 || i >= strlen(str))
        throw std::out_of_range("String: túlindexelés");

    return str[i];
}

Álljunk meg egy pillanatra, és nézzünk meg néhány dolgot. Miért char&-t adtunk vissza? Ha simán char-t adnánk vissza, akkor lemásolódna a megfelelő karakter. Ez teljesen jó lenne olvasáshoz, de írásnál nem működne, úgy nem tudnánk módosítani pl. egy s[0] = 'X'; sorral a sztring valamely betűjét. Referenciát, azaz hivatkozást kell visszaadni a tömbben lévő karakterre, hogy az módosítható is legyen.

A következő kérdés a konstans String-ek helyzete. Mi történik az alábbi kód futtatásakor?

const String s1 = "Teszt";
std::cout << s1[2];

Válasz: fordítási hiba, ugyanis nincs konstans minősítőjű indexelő operátorunk. A hiba kijavításához ezt is meg kell írnunk:

char String::operator[](int i) const {
    if(i < 0 || i >= strlen(str))
        throw std::out_of_range("String: túlindexelés");

    return str[i];
}

Kettő különbséget kell észrevennünk az előzőhöz képest. Egyrészt a const minősítő, másrészt a visszatérési érték char& helyett char lett. Ezzel elkerültük, hogy konstans String-nél módosítsák a szöveget. A függvény visszatérési értéke lehetett volna char const& is.

Hogyhogy ezt is megírhattuk? Ezek szerint két operator[](int) is lehet? Igen, hiába ugyanaz a paraméterlistájuk, ha az egyik const, a másik nem, ez megengedett. Ez bármilyen tagfüggvényre igaz, nem csak az indexelő operátorra. Nem konstans objektumra a nem konstans tagfüggvény hívódik, ha van, egyébként a konstans tagfüggvény. Ezért nem konstans String-en működni fog az írás és az olvasás is, míg konstans String-nél csak az olvasás engedélyezett.

Itt nem használjuk ki, de amúgy az indexelő operátor paramétere nem feltétlenül kell egész szám legyen, lehetne bármi más. Később majd látunk példát rá (akár a szabványos könyvtárban is), hogy bármivel indexelhetőek az objektumaink, ha a megfelelő operator[] meg van írva hozzá.

Egy String indexelésénél az esetek túlnyomó többségében felesleges az indexhatárok ellenőrzése, C szemlélet szerint a programozó úgyis tudja, mit szeretne. Ezért nem szokás az indexelő operátorban ellenőrizni, ha viszont szükség van ellenőrzésre, akkor helyette az at tagfüggvényt használjuk.

char& String::operator[](int i) {
    return str[i];
}

char& String::at(int i) {
    if(i < 0 || i >= strlen(str))
        throw std::out_of_range("String: túlindexelés");

    return str[i];
}

3. Dinamikus String

Barbár dolog fix méretet használni egy String osztálynál, ahol a legkülönbözőbb méretekkel dolgozhatunk. Gyakran túl sok memóriát foglal, ritkán túl kicsi, és még ritkább, amikor pont jó. Cseréljük ezért le a 256 elemű tömböt egy dinamikus tömbre!

class String {
    size_t size;
    char * str;
  public:

    String();
    String(char const * s);
    ~String();

    String operator+(String const& rhs) const;
    String& operator+=(String const& rhs);
    String& operator+=(char rhs);

    int length() const {
        return size;
    }
    char const * c_str() const {
        return str;
    }
};

A belső implementáción ismét érdemes elmélkedni, még mielőtt nekiugrunk. Maradjon a nullával lezárás? A c_str miatt még mindig javasolt nullával lezárni, sőt az eddig bemutatott nyelvi elemekkel nem oldható meg elegánsan a c_str, ha nem zárjuk le nullával. Ezen felül így a cstring függvényei használhatóak maradnak.

A size-ba értsük bele a lezáró nullát, vagy sem? A C-s konvenciók miatt (strlen) úgy döntöttünk, hogy ne tartalmazza. Mindig size+1 méretű tömböt kell majd str-nek foglalni, de más műveleteknél egyszerűbb lesz a számolás. Természetesen csinálhatnánk máshogy is – a String felhasználójának mindegy.

String::String() {
    size = 0;
    str = new char[1];
    str[0] = '\0';
}

String::String(char const * s) {
    size = strlen(s);
    str = new char[size + 1];
    strcpy(str, s);
}

A konstruktor második sorában memóriát foglalunk. Ki fogja ezt felszabadítani? Az lenne a legjobb, ha automatikusan felszabadulna a memória, amint az objektum megszűnik. Emlékezzünk vissza a destruktorra, pont jó lesz nekünk: automatikusan meghívódik a megszűnéskor (a blokk végén). A memória felszabadításának a destruktorban a helye.

A destruktort a ~ karakterrel jelöljük, és nincs paramétere, nincs visszatérési értéke. Nyilvánvalóan emiatt csak egy lehet belőle. Fontos elvárás, hogy destruktor soha nem dobhat kivételt. A nyelv erre nem kötelez minket, de ebből kezelhetetlen helyzetek születnek, ezért a konvenció.

class String {
    // ...
    ~String();
}

A megvalósítása itt triviális, csak annyi a dolga, hogy felszabadítsa a karaktertömböt.

String::~String() {
    delete[] str;
}

Eddig nagyon jól hangzik, a memória felszabadítása automatikusan megtörténik, anélkül, hogy az osztály használója akárcsak a kisujját megmozdítaná. Sajnos nem vagyunk készen, a String osztályon még reszelni kell. Mi történik, ha a String-et lemásolni, értékül adni szeretnénk? Próbáljuk ki ezt a kódsort:

String a = "Hello";
String b = a;

Ennek a kódnak a hatására a String osztályunk hibásan működik. a[0] = 'X' hatására b is megváltozik, márpedig a használója nem ezt várja el. Sőt, a programunk a blokk végén elszáll, valószínűleg a memóriakezelésben vannak gondok. Hol keressük a problémát?

Ha String a = "hello"; egyenértékű String a("hello");-val, akkor String b = a; egyenértékű String b(a)-val, tehát most egy olyan konstruktort kell keresnünk, aminek String típusú objektum a paramétere. Az ilyen konstruktorról már volt szó, ez a másoló konstruktor, és nem mi írtuk. A fordító generálta, és mint tudjuk, meghívja az adattagok másoló konstruktorát.

Ez az alapértelezett viselkedés nekünk nem jó, ugyanis a String által tárolt szöveget egy külön lefoglalt memóriaterületen tároljuk (erre mutat az str pointer). Amikor a fordító által generált másoló konstruktor lefut, akkor a pointer másolódik, nem a szöveg, mindkét String objektum ugyanarra a szövegre fog mutatni a memóriában, ezért a sztringünk már nem úgy fog működni, ahogy elvárjuk. Ha megváltoztatjuk az egyik String karaktereit, a másik is változni fog. Mindkét String objektum desktuktora ugyanarra a memóriaterületre hívja meg a delete[]-et, ez egyenesen katasztrófa.

A megoldás: definiáljuk felül a másoló konstruktort, hogy az a szöveget tároló memóriaterületet is lemásolja, és a másolat erre az új területre tároljon pointert! Ezáltal az új String-nek saját karaktertömbje lesz.

String::String(const String& the_other) {
    size = the_other.size;
    str = new char[size + 1];
    strcpy(str, the_other.str);
}

A másoló konstruktor feladata mindig az, hogy egy másik azonos típusú objektum lemásolásával új objektumot hozzon létre (inicializáljon). A másoló konstuktor elég sokszor meghívódik, olyan esetekben is, amikor nem mondjuk egyértelműen, hogy itt másolást szeretnénk:

  • azonos típussal inicializálunk egy objektumot (pl.: String a = "hello"; String b = a;)
  • függvény érték szerinti paraméterének átadásakor
  • függvény érték szerinti visszatérési értékének átadásakor
  • kivételek kezelésekor

Fontos elidőzni egy kicsit a paraméter típusán (const Típus&). Miért nem jó a sima Típus? Az előbb már láttuk, hogy érték szerinti paraméterátadáskor másoló konstruktor hívódik. Szóval ahhoz, hogy a másoló konstruktor meghívódhasson, először meg kell hívni a másoló konstruktort, hogy a paraméterébe bemásolja a másolandó objektumot. Ahhoz, hogy az a másoló konstruktor meghívódhasson... Fontos tanulság: a másoló konstuktor paramétere mindig konstans referencia.

A programunk még mindig elszáll, valahol a beolvasás környékén. Az extractor operátorban van egy ilyen sor:

rhs = uj;

Ez az értékadó operátor (operator=). Szintén hibás, a fordító által írt értékadó operátor sem úgy működik, ahogy elvárjuk. Mert a fordító által generált értékadó operátor adattagonként másol:

String& String::operator=(const String& the_other) {
    // a fordító által generált értékadó operátor
    // meghívja az adattagokra az értékadó operátort
    size = the_other.size;
    str = the_other.str;
    return *this; // Erre a sorra nemsokára kitérünk
}

Eredeti állapot:

Értékadó operátor után:

Ez – csakúgy mint a másoló konstruktor esetében – most sem jó nekünk. Ugyanis így az "assign"-t tároló memóriaterületet elveszítjük, nem tudjuk felszabadítani, és a "Hello"-t tároló memóriaterületre ismét két destruktor fog delete[]-et hívni.

A megoldás: definiáljuk felül az értékadó operátort is, hogy az először szabadítsa fel a felülírt objektum szövegét tároló memóriaterületet, ezután másolja le a használandó szöveget tároló memóriaterületet, és a felülírt objektum erre az új területre tároljon pointert.

String& String::operator=(const String& the_other) {
    if(this != &the_other) {            // (*)
        delete[] str;                   // régi felszabadítása
        size = the_other.size;
        str = new char[size + 1];       // új hely foglalása
        strcpy(str, the_other.str);     // sztring másolása
    }
    return *this;                       // magyarázat nemsokára
}

Így már helyesen működik az értékadó operátor, azonban néhány dolog magyarázatra szorul. Először nézzük meg a (*)-gal jelölt sort. Ezt az ellenőrzést arra az esetre tettük be, hogyha esetleg (általában nem szándékosan, nem triviális helyen, de előfordul az ilyesmi) önértékadás történne (pl.: a = a;). Ebben az esetben tilos a memóriaterületet felszabadítani, ilyenkor semmit nem csinálunk. Azt ellenőrizzük le, hogy a this pointer a paraméterként kapott objektumra mutat-e, és nem azt, hogy a két sztring tartalma megegyezik-e.

Miért String& a visszatérési típus, miért kell a return *this? Ezzel tettük az értékadást láncolhatóvá. Ezt használjuk ki, amikor több változónak adunk értéket egy utasításban.

String a("A"), b("B"), c("C");
a = b = c;

Itt először c-vel íródik felül b, majd b-vel íródik felül a. Ezt a láncolhatóságot az operátor bal oldalán álló operandus visszaadásával érjük el, ahogy az összes értékadás jellegű operátornál szoktuk (+=, -= stb.) Tagfüggvény lévén, ez a *this objektum. Ahogy az operator+=-nél is, itt is referenciát kell visszaadni.

A hármas szabály

Mi az amit megtanultunk edddig? Ennyiből biztosan látszik: nem mindig jók a fordító által írt tagfüggvények, ezeket mindig ellenőrizzük!

A destruktor azt mondja: ha a sztring objektum megszűnik, akkor megszűnik a karaktertömb is. Ebből az következik, hogy minden sztringnek saját karaktertömb kell, amiből pedig az, hogy másoló konstruktornak is léteznie kell. Mert mindegy, hogy char const*-ból, vagy String-ből inicializálunk egy másik sztringet, kell neki saját tömb. Általában a destruktor létezéséből következni szokott, hogy a másoló konstruktornak is léteznie kell.

Érdemes összehasonlítani a destruktor és a másoló konstruktor kódját az operator= kódjával. Lényegében ugyanazok, mert az operator=-nek az a feladata, hogy elfelejtsen mindent a sztring, és felvegye egy másiknak az értékét. Tehát le kell dózerolni nullára az objektumot (== destruktor), és építeni kell egy újat (== másoló konstruktor). Ebből a kettőből áll össze a lényegében az operator=, és ha ebben a kettőben kellett lennie kódnak, akkor az operator=-ben is kell lennie kódnak, tehát meg kell írnunk.

Az operator= ezért általában így néz ki:

String& String::operator=(const String& the_other) {
    if(this != &the_other) {
        // ... destruktor tartalma
        // ... másoló konstruktor tartalma
    }
    return *this;
}

Általánosságban elmondható: ha egy osztályban a másoló konstruktor, destruktor, értékadó operátor hármasból bármelyiket felül kell definiálnunk, akkor mindhármat kell! Vannak nagyon ritka kivételek, de ebben a tárgyban nem lesz ilyenről szó.

Nézzük, hogyan változott a többi függvény!

String String::operator+(String const& rhs) const {
    String uj;
    delete[] uj.str; // enélkül memóriaszivárgás lenne, házi feladat átgondolni
    uj.size = size + rhs.size;
    uj.str = new char[uj.size + 1];
    strcpy(uj.str, str);
    strcat(uj.str, rhs.str);
    return uj;
}

String& String::operator+=(String const& rhs) {
    return (*this = *this + rhs);
}

String& String::operator+=(char c) {
    // nem a leghatékonyabb megvalósítás
    char tomb[2] = {c, '\0'};
    return (*this = *this + String(tomb));
}

char String::operator[](int i) const {
    if(i < 0 || i >= size)
        throw std::out_of_range("String: túlindexelés");

    return str[i];
}

char& String::operator[](int i) {
    if(i < 0 || i >= size)
        throw std::out_of_range("String: túlindexelés");

    return str[i];
}

std::ostream& operator<<(std::ostream& os, String const& str) {
    os << str.c_str();
    return os;
}

std::istream& operator>>(std::istream& is, String& str) {
    // okosabb (pl. kétszeresére) nyújtogatás hatékonyabb lenne
    char c;
    String uj;
    while(is.get(c) && !isspace(c))
        uj += c;

    rhs = uj;
    return is;
}

Nézzük meg, hogyan változott ennek megfelelően a main.cpp!

#include <iostream>
#include "String.h"

int main() {

    String s1, s2;
    std::cin >> s1 >> s2;

    std::cout << "s1 + s2 = " << s1 + s2 << std::endl;

    s2 += s1;
    std::cout << "s2 + s1 = " << s2 << std::endl;

    return 0;
}

Sehogy! A String csak belül változott, a rá épülő kód ugyanúgy működik, mint eddig, de már nem száll el 256-nál hosszabb sztringekre, és nem pazarolja a memóriát rövid sztringeknél.

Tapintani lehet, hogy az itt bemutatott String osztályra úton-útfélén szükség van. Épp ezért a C++ standard library is tartalmaz egy erre való osztályt, ez az std::string. Ez sokkal okosabb, mint a fenti példakód. A használatához include-olni kell a string header-t.

4. Az objektumok életciklusa

Inicializálás

Egy objektum életciklusa a létrehozásával kezdődik. Ilyenkor memória foglalódik az objektum számára, – ez lehet a stack-en vagy a heap-en, ez az objektum szempontjából lényegtelen, – majd meghívódik a konstruktor. C-ből emlékszünk, hogy a memória tartalma lefoglaláskor memóriaszemét. Éppen a konstruktornak kell gondoskodnia arról, hogy miután lefutott (létrejött az objektum), már értelmes, használható adat legyen az objektumban.

Mi történik, ha például az Ember osztálynak egyik adattagja egy másik osztály (String)?

Az előző fejezetben megtanultuk, hogy a default konstruktort megírja helyettünk a fordító: meghívja az adattagok default kostruktorát. Ugyanígy a copy ctor és az op= sem feltétlenül a mi dolgunk.

Ebben a példában nincs default konstruktora a belső osztálynak, mert írtunk saját konstruktort. Akkor a külső osztály default konstruktora mit fog meghívni?

class String {
    char str[42];

    public:
    String(const char * str) {
        // Az egyszerűség kedvéért itt most kihagytuk a hibakezelést!
        strcpy(this->str, str);
    }
};

class Ember {
    String nev;

    public:
    Ember(const char * nev) { // ERROR
        this->nev = nev;
    }
};

Ember e1("Teszt Elek");

Ez a kód hibás, ugyanis a String-nek nincs default konstruktora, vagyis a fordító default konstruktora nem tudja inicializálni a nev adattagot. De hát ott van világosan leírva: this->nev = nev; Ez miért nem elég?

Az Ember konstruktorának törzsében a nev adattag már biztosan inicializálva van. Mire a kapcsos zárójelek közötti részhez, a programozott törzshöz ér a végrehajtás, addigra az adattag objektumok használhatóak. Ezért itt igazából az értékadó operátora hívódna. Akkor pontosan mikor fut le nev konstruktora?

Mindenképpen az e1 objektum számára történő memóriafoglalás után, hiszen abban lesz majd a nev adattagja. Viszont az előbb már megbeszéltük, hogy mire a konstruktor törzséhez ér, addigra inicializálva kell lennie az adattagoknak. A kettő között elvileg nincs semmi, memóriafoglalás után jön a konstruktorhívás.

Akkor hol inicializálódott a nev adattag?

A konstruktor első dolga, hogy az objektum tagváltozóit inicializálja. Még mielőtt a konstruktor törzse lefutna, a fordító által generált része inicializálja az objektumnak, jelen esetben az e1-nek minden adattagját. Beépített, primitív típusoknál (double, bool, int, stb.) ez semmit nem csinál, tehát memóriaszemét lesz bennük, ha a konstruktorban nem adunk nekik értéket! Objektumoknál viszont konstruktor hívódik. Mi történik azonban, ha ezt nem tudja automatikusan megtenni vagy felül akarjuk bírálni az alapértékeket?

Az inicializáló lista

Ahhoz, hogy az ilyen eseteket kezelni tudjuk, felül kell bírálni az adattagok inicializálását. A konstruktor általunk megadott részében ehhez már késő lenne, ott már rendelkezésünkre kell hogy álljanak ezek az értékek.

Erre ad megoldást az inicializáló lista, amivel felül tudjuk bírálni az adattagok inicializálását. Ennek szintaxisa a következő:

class Ember {
    String nev;
public:
    Ember(const char * nev) : nev(nev) {
    }
};

A konstruktor neve és a paramétereket tartalmazó zárójelek után egy kettőspontot követve adhatóak meg a tagváltozó(érték) párosok vesszővel elválasztva. Ezután, mint ahogy eddig is, következik a konstruktor törzse.

Vegyük észre, hogy ennek a szintaxisa a konstruktorhívással megegyezik, ez nem véletlen, ugyanis valóban ez történik (az adattagok megfelelő konstruktorát hívjuk). Szintén megjegyzendő, hogy az inicializáló listán nem kellett jelölnünk, hogy nev az adattagra vonatkozik vagy a paraméterre. A zárójelen kívül nyilván az adattagra, hiszen csak annak adhatunk értéket, azon belül pedig a paraméterre, mint általában.

Nagyon fontos, hogy az inicializáló lista csak konstruktoroknál használható (persze másoló konstruktornál is), mivel csak a konstruktor hívásnál történik meg a tagváltozók inicializálása. Az inicializáló lista előbb fut le, mint a konstruktor törzse, a konstruktor törzsének futásakor már nincs inicializálatlan adattag. Azokat az adattagokat, amiket nem adunk meg az inicializáló listában, a fordító alapértelmezett konstruktorral inicializálja (vagy beépített típus esetén sehogy). Ha pedig ez nem lehetséges (például a lenti 3 ok egyike miatt), akkor hibát kapunk. Általában ha lehetséges, akkor érdemes az inicializáló listát használni az adattagok beállítására. Így ugyanis az inicializálás + értékadás helyett rögtön jó értékkel inicializálódhatnak az adattagok. Ha a konstruktor költséges, mert pl. memóriafoglalás van benne (lásd String), akkor ez kiemelten fontos. Illusztrációként vegyük az alábbi kódot:

String s1;
s1 = "alma";

String s2("alma");

Nem mindegy, hogy csinálok egy sztringet, eleve alma értékkel, vagy előbb csinálok egy üres sztringet (new[] ott van olyankor is!), aztán eldobom a kukába (akkor minek csináltam?), hogy aztán megint csináljak egy újat.

Az inicializáló listán az adattagok sorrendje is fontos. Akármilyen sorrendben írjuk oda az inicializálandó adattagokat, mindig a memóriakép (azaz a struktúra definíciója) szerinti sorrendben kerülnek inicializálásra. Mivel ez – hogy a leírt sorrenddel eltérő sorrendben történhet valami – minden programozónak életidegen, könnyen írhatnánk hibás kódot. Ezért minden rendes fordító warninggal jutalmaz, ha nem jó sorrendben írtuk az inicializáló listát.

Akkor is az inicializáló listát kell használnunk, ha az alábbi 3 eset közül bármelyik fennáll:

  • referencia adattagot kéne inicializálni (nem tudja a fordító, hogy mire hivatkozzon a referencia)
  • konstans adattagot kéne inicializálni (nem tudja a fordító, hogy milyen értékre akarjuk beállítani)
  • objektum adattagot kéne inicializálni (meghívódik a konstruktora), de nincs default konstruktora (nem tudja a fordító, hogy milyen értékekkel hívja a konstruktorát) vagy attól eltérő paraméterezéssel szeretnénk meghívni
class C {
    int& ref;
    const int cvalue;

    public:
    C(int& ref, int cvalue) : ref(ref), cvalue(cvalue) {
        std::cout << "Itt már inicializálva van ref: " << ref << " és cvalue: " << cvalue << std::endl;
    }
};

int a = 1;
C c(a, 2);

Destruktor

Ez eddig szép és jó, de mi a helyzet a tagváltozók destruktoraival? Azoknak is le kell futniuk valamikor.

És le is futnak. Miután a destruktor általunk definiált része lefutott, a fordító által generált része meghívja minden adattagra a destruktorát, a deklarálás sorrendjéhez képest fordított sorrendben. Azért pont akkor, mert az általunk írt részben szükség lehet még az adattagokra (például ha egy fájlt kezelő adattagnál a desktruktorban be akarnánk zárni a fájlt).

A teljes életciklus

Tehát egy objektum életciklusa a következő:

  1. Memóriaterület foglalódik az objektum számára. (Itt lesznek többek között az adattagok is eltárolva.)
  2. A konstruktor fordító által generált része inicializálja az adattagokat (lefut a konstruktoruk). Az inicializáló listával adhatjuk meg, hogy melyik adattagnak hogyan paraméterezzük a konstruktorát.
  3. Ezután lefut a konstruktor törzse.
  4. Innentől az objektum élőnek számít, dolgozunk vele, tagfüggvényeit hívjuk stb.
  5. Amikor az objektum megszűnik, az első dolog, hogy lefut a destruktor törzse.
  6. Utána a destruktor fordító által generált része meghívja az adattagok destruktorát, fordított sorrendben.
  7. Végül felszabadul az objektum memóriaterülete.