Kivételkezelés és RAII

Czirkos Zoltán · 2019.02.27.

Mire is jó a kivételkezelés? Miért több, mint egy lehetséges módja a hibajelzésnek?

When I give talks on EH [exception handling], I teach people two things:
- POINTERS ARE YOUR ENEMIES, because they lead to the kinds of problems that unique_ptr is designed to eliminate.
- POINTERS ARE YOUR FRIENDS, because operations on pointers can't throw.
Then I tell them to have a nice day :-)

– Scott Meyers

A C++ nyelv támogatja a kivételkezelést. Bármely függvény számára megengedett az, hogy a szokásos visszatérési értéke helyett – ha valamilyen hibát szeretne jelezni – egy kivételobjektummal „térjen vissza”. A kivételobjektumokat a hiba észlelésének helyén a throw kulcsszóval kell eldobni. A dobás hatására a végrehajtás a hívási sorban (vagyis inkább: veremben) a dobáshoz legközelebbi olyan catch{} utasításblokkhoz kerül, amely az adott típusú kivételt el tudja kapni.

A kivételkezelés által a függvények hívóját kényszeríteni tudjuk arra, hogy az észlelés helyén kezelhetetlennek bizonyuló hibával foglalkozzon. Ez a kényszerítés éppen a vezérlésátadásban jelenik meg. Vegyünk egy egyszerű példát, amelyben egy tömb objektumot indexel a használója:

int & IntTomb::operator[] (size_t idx) {
    if (idx >= meret)
        throw "hiba: tulindexeles";
    return dintomb[idx];
}

int main() {
    IntTomb v(... valamilyen ctor parameterek ...);

    try {
        v[12] = 93;
    } catch (char const * hibaszoveg) {
        std::cerr << hibaszoveg << std::endl;
    }
}

Ha a hívó olyan indexet adna az operátornak, amely hibás, túlmutat a tömb méretén, akkor a függvény nem képes helyes visszatérési értéket adni: muszáj lenne visszatérnie egy egész típusú változóval, de nem létezik az a változó, amely helyes lehetne. Ha átadja a hívónak a tömb egyik elemét, azzal becsapja, mert nem azt az indexű elemet adja neki, amit kért. Ha egy másik változót ad, azzal is, mert a hívó nem fog róla tudni, hogy hibát követett el. Márpedig a hibát a hívó okozza.

Normál visszatérés esetén a visszatérési érték egy int &, a tömb egyik eleme. Ilyenkor a v[12]=93 értékadás is megtörténik. Kivétellel „visszatérés” esetén azonban már az értékadás sem történik meg: a v[12]=93 kifejezés végrehajtása megszakad, és helyette a végrehajtás az std::cerr-re történő kiírásnál folytatódik, mivel az a catch{} blokk képes elkapni a dobott szövegre mutató pointert. Ez jogos, hiszen az értékadás úgysem produkálhatna helyes eredményt: láttuk az előbb, hogy ha az indexelő operátor bármivel is visszatért volna, az mindenképp csakis helytelen lehetett volna.

1. A megbolygatott vezérlés

A kivételkezelés bizonyos szempontból nagyon kényelmes. A bonyolult műveletsorok kódját, amely sorra kritikus, potenciálisan sikertelen műveleteket tartalmaz (pl. memóriafoglalás, fájlból olvasás stb.), nem kell teletűzdelnünk hibakezeléssel, a mindenféle függvényektől visszakapott hibakódok vizsgálatát tartalmazó if()-ekkel. Helyette az egész kritikus műveletsort betesszük egy try{} blokkba, és biztosak lehetünk abban, hogy ha valamelyik művelet sikertelen volt, az utána lévők nem lesznek végrehajtva az érvénytelen adatokon.

Más szempontból azonban elég veszélyesen is hangzik. A programunk végrehajtása olyan útra terelődhet, amelyre nem is számítunk; a függvényeinkből olyan helyeken térhetünk vissza, amelyekre ránézésre nem is gondolnánk. Vegyük példának az alábbi függvényt:

void foo(ValamiTomb & tomb, Valami const & v) {
    tomb[12] = v;
    std::cout << "Siker.\n";
}

Ezt a pici kódrészletet vizsgálva evidensnek tűnik az, hogy a szöveg a képernyőn meg fog jelenni. Ez azonban közel sincs így, hiába nem szerepel a return kulcsszó sehol! Az alábbiak bármelyike megtörténhet:

  • Ha minden rendben van, természetesen végrehajtódik az értékadás, és a kiírás is.
  • Előfordulhat azonban, hogy a tömböt túlindexeljük, és az indexelő operátor kivételt dob. Ebben az esetben már az értékadás előtt visszatérünk a függvényből, mivel a kivételt itt nem kaptuk el. Sem az értékadás, sem a kiírás nem történt meg, el sem kezdtük őket.
  • Előfordulhat az is, hogy a Valami osztály értékadó operátora dobott egy kivételt. Itt nem nagyon derül ki, a Valami osztály mit csinál, de lehet dinamikus adattagja, amely esetben az értékadása egy összetett, explicit memóriakezelést igénylő művelet, amely nem biztos, hogy sikerrel jár.
  • Előfordulhat, hogy a kiírás közben történik valamilyen hiba – bár azon segíteni úgysem tudunk.

Ha ezt a kódot az alábbi módon egészítjük ki, kész a baj: ha valamelyik függvényben kivétel dobódik, memóriaszivárgásunk lesz.

void foo(ValamiTomb & tomb, Valami const & v) { // rossz lehet
    int * t = new int[tomb.meret()];
    tomb[12] = v;
    std::cout << "Siker.\n";
    delete[] t;
}

2. Kivételbiztos függvények, RAII

Látjuk tehát, hogy a kivételkezelés miatt új szempontból kell vizsgálnunk a kódunkat, ha a helyességéről szeretnénk megbizonyosodni. Bár jó dolog, hogy kivétel keletkezése esetén az utasítások végrehajtása megszakad, és alternatív útra kényszerülünk, ennek váratlan bekövetkezése mégis gondot jelenthet.

A fenti programrészben az indexelés és az értékadás a kritikus művelet (talán még a kiírás is, de azzal most ne foglalkozzunk). Javíthatjuk például így:

void foo(ValamiTomb & tomb, Valami const & v) {
    int * t = new int[tomb.meret()];
    try {
        tomb[12] = v;
    } catch (...) {
        delete[] t;
        throw;
    }
    std::cout << "Siker.\n";
    delete[] t;
}

A kritikus kódrészletet egy try{} blokkba tettük, hogy a keletkező kivételt el tudjuk kapni. Erről a kivételről nem sokat tudunk (bár az indexelő és az értékadó operátorok dokumentációjában elvileg le van írva, milyen hibák keletkezhetnek), úgyhogy simán csak továbbdobjuk a hívónak. De mindenképp el kell kapnunk, mivel a dinamikus memóriáért, az int tömbért, amelyet létrehoztunk, mi felelünk.

A javítás másik, jobban kezelhető módja a következő:

void foo(ValamiTomb & tomb, Valami const & v) {
    std::vector<int> t(tomb.meret());           // RAII
    tomb[12] = v;
    std::cout << "Siker.\n";
}

Tehát a nyers, általunk kezelt dinamikus memóriaterületet (int tömböt) lecseréljük egy tömb objektumra. Ez azért lesz így helyes, mert ugyan az el nem kapott kivétel hatására ebből a függvényből kikerülünk – de még mielőtt a hívónak automatikusan továbbdobódna a kivétel, ennek a függvénynek a lokális változói megszűnnek, ami egyben azt is jelenti, hogy a t objektum destruktora lefut, amely pedig a felelősségi körébe tartozó dinamikus tömböt felszabadítja. Így egyébként még az ezidáig utolsó sorban éktelenkedő delete[] műveletet is megúsztuk: a destruktor tartalmazza azt.

A tanulság: ha valamilyen erőforrást foglalunk, egyből adjuk oda azt egy objektumnak, mert akkor az erőforrás felszabadítása automatikus lesz, az objektum fog felelni érte. Még kivételek ide-oda röpködése esetén is! Ennek az elvnek a neve RAII, Resource Acquisition Is Initialization. Bár ez arra utal, hogy az erőforrás foglalását az objektum létrejöttéhez kötjük, a hangsúly igazából a destruktoron és a felszabadításon van.

3. Kivételbiztos osztályok

Az előbb az RAII elv alapján az erőforráskezelés problémáját az std::vector<> osztályra toltuk ki. Nézzük meg egy kicsit a problémakört ennek az osztálynak a szemszögéből: egy egyszerű dinamikus tömb sablonosztályon keresztül.

template <typename T>
class Vektor {
private:
    T *adat;
    size_t meret;
public:
    explicit Vektor(size_t s = 0) {
        meret = s;
        adat = new T[s];
    }
    ~Vektor();
    Vektor(Vektor const & masolando);
    Vektor & operator=(Vektor const & masolando);
};

A konstruktorban már látszik, hogy ez az osztály egy dinamikusan foglalt tömbért felel, emiatt tudjuk, hogy a destruktor így kell kinézzen:

template <typename T>
Vektor<T>::~Vektor() {
    delete[] adat;
}

A destruktorról egyből eszünkbe jut az ökölszabály is, miszerint ha egy osztálynak a destruktor, másoló konstruktor, értékadó operátor közül bármelyik kell, akkor szinte biztos, hogy mind a három kell. Gyorsan egy másoló konstruktort:

template <typename T>
Vektor<T>::Vektor(Vektor<T> const & masolando) {    // rossz
    meret = masolando.meret;
    adat = new T[meret];
    for (size_t i = 0; i < meret; ++i)
        adat[i] = masolando.adat[i];
}

Az előző részben leírtakból okulva itt azonban egyből be is ránthatjuk a kéziféket. A helyzet ugyanis: ebben a pársoros függvényben egy csomó helyen keletkezhet kivétel. Némelyek olyanok, amelyekről nem is tudunk semmit, mivel a T típus a sablon kódban ismeretlen számunkra!

  • A new T[meret] kifejezés hatására először lefoglalódik meret darab T-nek elegendő memóriaterület. Ha ez a foglalás nem sikerül, akkor egy std::bad_alloc kivétel dobódik.
    • Gond ez nekünk? Igen, mert nem tud létrejönni a vektor. De a kivételt szándékosan nem kapjuk el, mert a konstruktorból csak így tudjuk jelezni a hibát.
  • Ezek után lefut meret darab T() alapértelmezett konstruktor. Ezek közül bármelyik dobhat kivételt.
    • Vajon ez gond? Fogasabb kérdés, hiszen a dinamikus memóriaterület már le van foglalva, sőt az objektumok egy része inicializálva van, a másik részük pedig még memóriaszemét. De ezt a kivételt sem szabad elkapnunk: a fordító biztosítja számunkra, hogy tömb foglalásakor a már inicializált elemek destruktora lefut, és a dinamikus memóriaterület automatikusan felszabadul. (Ez csakis erre az esetre igaz! Máskor a tömb nem szabadul fel magától.)
  • Végül jönnek a másolások. A T osztály operator= értékadó operátora fog lefutni meret-szer. Ezek közül bármelyik kivételt dobhat. Mivel ilyenkorra a tömb foglalása megtörtént, ezért ezen a ponton a tömbért már mi felelünk!

Tehát az utóbbi esetet kezelnünk kell. Mivel itt most nem szeretnénk egy std::vector-t használni (épp azt hivatott bemutatni az írás, hogy annak a belsejében mi történik), kénytelenek vagyunk elkapni ezt a kivételt, és a tömb felszabadítását „kézzel” elvégezni:

template <typename T>
Vektor<T>::Vektor(Vektor<T> const & masolando) {
    meret = masolando.meret;
    adat = new T[meret];
    try {
        for (size_t i = 0; i < meret; ++i)
            adat[i] = masolando.adat[i];
    } catch (...) {
        delete[] adat;
        throw;
    }
}

Így már nem fog memóriaszivárgást okozni az, ha a Vektor objektumot nem lehet létrehozni. A hívó pedig, aki megpróbálta a másoló konstruktorát lefuttatni, kivételt fog kapni.

A new T[meret] rész nem került bele a try{} blokkba, mivel annak memóriaszivárgás-mentességét a fentiek szerint a fordító biztosította. Nem is lenne szabad beletennünk! Ha az is a try{} blokkban lenne, akkor elkapnánk azt a kivételt is, amely a new-tól származik. Ez a kivétel azonban az adat pointer értékadása előtt keletkezik, tehát a catch{} blokkba úgy kerülnénk, hogy az adat pointer még memóriaszemét. A delete[]-en pedig ilyenkor elhasalna a program.

Nézzük végül az értékadó operátort. Tudjuk, hogy az összeollózható a destruktorból és a másoló konstruktorból, de nézzük nagyon kritikusan az eredményt:

template <typename T>
Vektor<T> & Vektor<T>::operator=(Vektor<T> const & masolando) {    // rossz
    if (this != &masolando) {
        delete[] adat;
        meret = masolando.meret;
        adat = new T[meret];
        try {
            for (size_t i = 0; i < meret; ++i)
                adat[i] = masolando.adat[i];
        } catch (...) {
            delete[] adat;
            throw;
        }
    }
    return *this;
}

Bár a kivételbiztos másoló konstruktort másoltuk be ide, rá kell jönnünk, hogy ez így egyáltalán nem lesz jó. Ugyanis bármelyik helyen dobódik kivétel, akár a new-nál, akár a for() ciklusban, meg vagyunk lőve: a régi tömböt, a régi T-ket már felszabadítottuk, az újakat pedig nem tudjuk létrehozni! Ilyenkor a régi T-k felszabadítását már nem tudjuk visszacsinálni. A Vektor használója pedig hiába teszi az értékadást try-catch blokkba:

Vektor<std::string> v1 = ....., v2 = .....;

try {
    v1 = v2;
} catch (...) {
    std::cerr << "Nem sikerült az értékadás.\n";
}

std::cerr << "De most itt mi van az v1-ben?!?!\n";

Addig ugyan rendben, hogy ő értesül a hibáról, és tudja, hogy az értékadás nem sikerült. De mi lesz ezután a v1 objektumával? Az az objektum ilyenkor érvénytelen, félholt állapotban van: nincsen dinamikus tömbje. A v1.adat pointer memóriaszemét. Még ha nem is használja ezt az v1 objektumot semmire a hívó, akkor is előbb-utóbb le kell futnia a destruktorának, amely pedig azt várja, hogy értelmes adatot lásson a pointerben.

Ez megengedhetetlen. A legjobb megoldás az lenne, ha el lehetne valahogyan érni azt, hogy az értékadás atomi, bonthatatlan legyen. Azaz vagy történjen meg teljes egészében, hibátlanul, vagy pedig dobódjon kivétel, de akkor az objektum maradjon „sértetlen”, sőt lehetőleg tartsa is meg az eredetileg tárolt adatokat. Ezt úgy tudjuk megoldani, ha a destruktorból és a másoló konstruktorból összeollózott részeket megcseréljük, vagyis előbb végezzük el a kritikus lépéseket, tehát a másolást, és csak utána a felszabadítást. Valahogy így:

template <typename T>
Vektor<T> & Vektor<T>::operator=(Vektor<T> const & masolando) {    // jó (jó bonyolult)
    if (this != &masolando) {
        size_t ujmeret = masolando.meret;
        T * ujadat = new T[ujmeret];
        try {
            for (size_t i = 0; i < ujmeret; ++i)
                ujadat[i] = masolando.adat[i];
        } catch (...) {
            delete[] ujadat;
            throw;
        }
        delete[] adat;
        adat = ujadat;
        meret = ujmeret;
    }
    return *this;
}

Nézzük meg, ez helyes-e. Ha a new T[] kifejezés dob, azt nem kell elkapnunk. A hívó megkapja, és tudja, hogy az értékadás sikertelen. Se a meret, se az adat adattaghoz nem nyúltunk, így az objektum változatlanul maradt. Ezután jön a T-k másolása. Ha valamelyik T::operator= dob, elkapjuk, felszabadítjuk az elkezdett másolat tömböt, és továbbdobjuk a kivételt. A hívó értesül, a vektor továbbra is érvényes és változatlan.

Ha ezek is mind sikerültek, készen áll a másolat. Innentől jön a régi adatok törlése: a delete[] adat. Dobhat ez kivételt? Nem! A szabvány megköveteli, hogy a destruktorok ne dobjanak kivételt, és ha a T osztályt szabványkövető módon írták meg, akkor az sem dob. (Amúgy is, miért dobna, a memória felszabadítása egyszerű művelet, a foglalás a necces.) Végül két értékadással fejezzük be, amelyek közül az egyik egy egész számot, a másik pedig egy pointert másol. Ezek közben sem keletkezhet kivétel, a világ legegyszerűbb műveletei.

4. A „copy and swap” nyelvi fordulat

Feltűnhet, hogy megint kódrészletet duplikáltunk, amiről pedig tudjuk, hogy nem helyes. Ezt a kódduplikációt itt egy trükkel el tudjuk kerülni – ennek a trükknek a neve a „copy and swap”, vagyis a másolás és csere. A trükk a következő:

template <typename T>
Vektor<T> & Vektor<T>::operator=(Vektor<T> const & masolando) {    // jó!
    if (this != &masolando) {
        Vektor<T> masolat(masolando);
        std::swap(this->adat, masolat.adat);
        std::swap(this->meret, masolat.meret);
    }
    return *this;
}

Ez a kódrészlet a következőt csinálja:

  • Átveszi a masolando objektumot, amelyből az értékadást kell végezni, tehát amelyet tulajdonképpen a *this-be be kell másolni.
  • Készít erről egy másolatot a másoló konstruktorral. Vegyük észre, hogy ez a másolás az összes kritikus műveletet magában foglalja. Ha nem sikerül, akkor ez a másoló konstruktor hívás kivételt fog dobni, amelyet ez a kódrészlet nem kap el.
  • Ezek után pedig megcseréli a másolatot saját magával, tehát a masolat objektumot a *this-szel. A csere az adattagok cseréjét jelenti, tehát a pointerét és a méretét.

Mi történt itt?! Nagyon egyszerű: az értékadó operátor készített a lemásolandó adatokról egy másolatot. Utána pedig ebből a másolat objektumból ellopta a lemásolt adatokat magának (elvette tőle az adatok pointerét). A régi, felszabadítandó adatokat pedig belecsempészte a másolat objektumba, amely az utasításblokk végén megszűnik, és így a felszabadításra szánt adatok végül tényleg megszűnnek!

Tehát közvetlenül a másolás után három objektum van:

Ezek után jön a csere, amely kicseréli a tömböket. Mire a másolat objektum törlődik, addigra a csere által hozzá kerültek a törlendő adatok. Ezeket a masolat nevű objektum destruktora fogja végül törölni:

Az értékes adatok (a másolat) pedig megmaradnak a *this-nél. És ennyi.

Ezt a trükköt még tovább el szokás vinni. Ugyanis ha már úgyis szükségünk van egy másolatra, akkor minek is vesszük át referenciával az objektumot – miért nem vesszük át értékként, hogy már eleve egy komplett másolatot kapjunk? Hiszen a paraméterek nem egyebek, mint kívülről inicializált lokális változók. A függvény tehát átírható így:

template <typename T>
Vektor<T> & Vektor<T>::operator=(Vektor<T> masolat) {    // még jobb!
    std::swap(this->adat, masolat.adat);
    std::swap(this->meret, masolat.meret);
    return *this;
}

Mire a függvény törzsébe jut a végrehajtás, addigra az összes kritikus művelet megtörtént (már a paraméterátadáskor), és nincs más hátra, mint a csere elvégzése. Az önértékadás ellenőrzését el is hagyhatjuk, hiszen a másolat objektum úgysem lehet azonos a *this-szel.

5. Feladatok

  • Az alábbi kódrészlettel találkozunk valahol. Helyes ez kivételkezelési szempontból? Ha igen, miért? Ha nem, hogyan lehet javítani?
    /* egy deklaráció valahol a kódban */
    void fv(X *egyik, X *masik);
    
    /* valahol egy függvényben */
    fv(new X, new X);
  • Mi a helyzet a kivételkezelés szempontjából az alábbi függvénnyel? Ha problémás lehet, hogyan kellene javítani?
    /* dinamikusan foglalt X-szel tér vissza. */
    X * create_new_x();
    
    void foo(ValamiTomb & tomb, Valami const & v) {
        X * ptr = create_new_x();
        tomb[12] = v;
        std::cout << "Siker.\n";
        delete ptr;
    }
  • Vajon kell-e másoló konstruktort és értékadó operátort írni az alábbi osztálynak? Kell-e neki másoló konstruktor és értékadó operátor, ha figyelembe vesszük a kivételeket?
    template <typename T1, typename T2>
    class Par {
        T1 egyik;
        T2 masik;
    };
  • Az alábbi bináris fát megvalósító osztály két pointert tartalmaz, amelyek dinamikusan foglalt gyerekekre mutatnak. Hogy kell megírni ennek a destruktorát, másoló konstruktorát és értékadó operátorát?
    class BinFa {
    private:
        int adat;
        BinFa *bal, *jobb;
    public:
        ...
    };
  • Van-e hátránya a „copy and swap” stílusú értékadó operátornak a hagyományos, destruktor-aztán-másoló-konstruktor-copypaste megoldással szemben? Ha igen, mi az?