25.1 throw, catch és stack unwinding

A C-ben megszokott hibakezelés visszaadott értékre, errno-ra és goto cleanup-ra épül: a hiba a normál vezérlési folyam része, és a hívó felelőssége észrevenni. A C++ kivétel (exception) más szerződést kínál: a hiba nem visszafelé, visszatérési értékben utazik, hanem oldalra lép ki a normál folyamból, és a hívási láncon felfelé haladva addig keres handlert, amíg talál egyet, vagy a program le nem áll. Ez a mechanizmus akkor jó, ha a hiba ritka, a hiba okát nem lehet a helyszínen kezelni, és a köztes rétegeknek nem kell tudniuk róla — tipikusan ez a helyzet egy konfigurációbetöltő, egy protokollparser vagy egy hálózati réteg esetében.

Ez az alfejezet a kivétel útját követi: hol jön létre a hibaobjektum, meddig él, hogyan illeszkedik a handlerhez, és közben milyen sorrendben futnak le a felépült RAII objektumok destruktorai. A hibastratégiák összehasonlítását (error_code, expected, noexcept határok) a noexcept, swap és container-reallocation részre építve a következő alfejezet tárgyalja részletesen; itt csak annyit használunk belőlük, amennyi az unwinding megértéséhez kell. A slicing és az ownership szempontjából támaszkodunk a Virtuális destruktor, slicing és ownership részre.

25.1.1 A throw kifejezéstől a catch handlerig

A throw kifejezés egy exception object-et hoz létre. Pontosabban: a throw operandusából a rendszer egy külön, a dobó fókusztól független tárolón lévő objektumot inicializál, és a vezérlést egy kivételként jelölt állapotba helyezi. Az exception object típusát a throw operandusának statikus (fordítás idejű) típusa határozza meg, nem a dinamikus típusa. Ez a legfontosabb egyetlen mondat az egész mechanizmusban, mert ezen múlik a catch illesztése és a slicing elkerülése.

A dobás után a program a hívási láncon felfelé keres egy catch handlert. A keresés a dobás helyét körülvevő try blokkok handlereivel kezdődik, majd a hívó függvény try blokkjaival folytatódik, és így tovább. Az illesztés szabálya szűk, szándékosan: egy H handler akkor illeszkedik egy T statikus típusú exception objecthez, ha

Semmi más nem számít: nincs int → double standard konverzió, nincs felhasználói konverziós operátor, és nincs lefelé castolás (a Derived handlert nem fogja el a Base statikus típusú exception object). Ezért a handler-sorrend nem esztétikai kérdés: a handlereket a forrásban elfoglalt sorrendben próbálja a rendszer, és az első illeszkedő nyer. Ha a bázis-handler áll elöl, a származtatott handler elérhetetlen (dead code), amit egyes fordítók -Wexceptions mellett jelezhetnek, de ez nem garantált — a sorrendet kézzel kell ellenőrizni.

25.1.2 Az exception object élettartama és a catch típusillesztése

Az exception object a throw végrehajtásakor jön létre, és akkor semmisül meg, amikor az alábbi két esemény közül a későbbi bekövetkezik ([C++20] [except.throw]/4): (1) az utolsó rá vonatkozó aktív handler kilép, de nem újradobással; (2) megszűnik az utolsó rá hivatkozó std::exception_ptr. A throw; tehát nem zárja le az élettartamot: ugyanazt az objektumot dobja tovább, és a külső handler ugyanazt kapja. A throw e; ezzel szemben a handler paraméteréből egy új exception objectet inicializál (másolással), és ha e bázistípusú referencia, ez a másolat szeletel is. Ez a hibaadat tulajdonosa: nem a dobó lokális változója, nem a handler paramétere, hanem egy külön objektum, amelyet a throw másolással/mozgatással hozott létre a lokális objektumból. Ha a dobó függvény lokális változóját dobnánk ki referenciaként (throw e; helyett throw &e; vagy hasonló trükkel), az élettartam-szerződést megszegnénk: a lokális változó a függvény elhagyásakor megszűnik, a pointer vagy referencia dangling lesz. Helyes minta: értéket dobni, referenciával elkapni.

// exception_lifetime.cpp — C++20: a kivételobjektum élettartama
#include <cstdio>
#include <exception>

struct Tracked {
    int id;
    explicit Tracked(int i) : id(i) { std::printf("ctor %d\n", id); }
    Tracked(const Tracked& o) : id(o.id + 100) { std::printf("copy %d\n", id); }
    ~Tracked() { std::printf("dtor %d\n", id); }
};

int main() {
    std::exception_ptr saved;
    try {
        try {
            throw Tracked{1};                    // kivételobjektum: id 1
        } catch (const Tracked& t) {
            saved = std::current_exception();    // második hivatkozás
            std::printf("handler 1: %d\n", t.id);
            throw;                               // ugyanaz az objektum megy tovább
        }
    } catch (const Tracked& t) {
        std::printf("handler 2: %d (nincs új másolat)\n", t.id);
    }                                            // utolsó handler kilép...
    std::puts("handlerek után, saved még él");   // ...de az exception_ptr tartja
    try {
        std::rethrow_exception(saved);
    } catch (const Tracked& t) {
        std::printf("rethrow_exception: %d\n", t.id);
    }
    saved = nullptr;                             // utolsó hivatkozás megszűnik
    std::puts("saved = nullptr után");
    try {
        try { throw Tracked{2}; }
        catch (const Tracked& t) { throw t; }    // throw t: ÚJ kivételobjektum
    } catch (const Tracked& t) {
        std::printf("throw t után: %d\n", t.id);
    }
}

Buildjegyzőkönyv (lab-gcc profil). g++ -std=c++20 -Wall -Wextra -Wpedantic -O2 exception_lifetime.cpp kimenete: ctor 1, handler 1: 1, handler 2: 1 (nincs új másolat), handlerek után, saved még él, rethrow_exception: 1, dtor 1, saved = nullptr után, majd ctor 2, copy 102, dtor 2, throw t után: 102, dtor 102. Az 1-es objektum a throw; után és mindkét handler kilépése után is élt, és csak az utolsó exception_ptr nullázásakor semmisült meg. A std::rethrow_exception a szabvány szerint másolatot is dobhat; a libstdc++ itt az eredetit dobta (1, nem 101). A throw t; új objektumot (102) hozott létre.

A handler paramétere egy lokális változó, amelyet az exception objectből inicializálunk. Ha a paraméter érték szerinti (catch (std::exception e)), akkor másolás történik a statikus handler-típusra: ez a slicing. A származtatott részobjektum adatai elvesznek, a virtual hívások a bázisra esnek vissza, és a catch blokk végén a másolat destruálódik, nem az eredeti exception object. Ezért a standard minta a catch (const T& e): nem másol, nem vág, és nem engedi módosítani a hibaadatot. A catch (T& e) akkor indokolt, ha a handlernek szándékosan módosítania kell a hibaobjektumot (ritka), a catch (...) pedig csak akkor, ha tényleg nem érdekel a hiba típusa — ilyenkor viszont a throw;-vel való újradobás a helyes továbbítás, nem a hibainformáció eldobása.

A slicing nem csak a catch oldalán fenyeget. Már a throw is vághat, ha az operandus statikus típusa bázis:

// catch_match.cpp — fordítás: g++ -std=c++20 -Wall -Wextra -O2 -o catch_match catch_match.cpp
#include <cstdio>
#include <stdexcept>

struct ConfigError : std::runtime_error {
    int line;
    ConfigError(const char* m, int l) : std::runtime_error{m}, line{l} {}
};

int main() {
    try {
        ConfigError e{"bad key", 42};
        std::runtime_error sliced = e;   // itt már elveszett a line tag
        throw sliced;                    // statikus típus: std::runtime_error
    } catch (const ConfigError& e) {     // nem illeszkedik
        std::printf("config error at %d\n", e.line);
    } catch (const std::runtime_error& e) {
        std::printf("runtime error: %s\n", e.what());
    }
}

Az illusztrált kimenet runtime error: bad key; a ConfigError handler soha nem fut le, mert a dobott statikus típus std::runtime_error. Ha a throw e; állna ott (e típusa ConfigError), akkor az első handler kapná el. Ez a különbség a throw e; és a throw sliced; között kizárólag a statikus típuson múlik, és ez a leggyakoribb oka annak, hogy egy „származtatott hibát” látszólag nem lehet elkapni.

25.1.3 Stack unwinding és RAII: a cleanup sorrendje

Amikor a kivétel elhagy egy függvényt, a program nem ugorja át a köztes állapotot: minden olyan automatikus tárolási idejű objektum, amelynek konstrukciója abban a blokkban befejeződött, destruálódik, a konstrukció sorrendjének fordítottjában. Ez a stack unwinding. A blokkok belülről kifelé ürülnek: először a legbelső blokk lokálisai, majd a külső blokk lokálisai, majd a függvényparaméterek és a függvény saját lokálisai a hívó kontextusában, és így tovább a hívási láncon. A catch blokkba már csak a hívó frame-jei élnek; a dobó függvény minden RAII objektuma addigra felszabadult.

Ez az, amiért a RAII (erőforrás-életciklus kötése objektum-élettartamhoz) és a kivétel együtt működik: a FileDescriptor, std::lock_guard, std::unique_ptr destruktora akkor is lefut, ha a köztes kód kivétellel lép ki. A szerződés két oldala: (1) a destruktornak nem szabad kivételt dobnia, különben a kivételkezelés közben újabb kivétel indul, és a program std::terminate-mel leáll; ezért a destruktorok C++11 óta implicit noexcept(true)-k, hacsak nem jelöljük őket noexcept(false)-nak — ezt a lehetőséget a gyakorlatban kerülni kell. (2) Az erőforrás tulajdonjogát a konstruktorban meg kell szerezni és tagnak kell adni, hogy az unwinding lássa.

Tekintsük a teljes példát, amely egyszerre mutatja a normál cleanup sorrendet és a konstruktorhibát:

// unwinding.cpp — fordítás: g++ -std=c++20 -Wall -Wextra -O2 -o unwinding unwinding.cpp
#include <cstdio>
#include <stdexcept>
#include <string>
#include <utility>

struct Resource {
    std::string name;
    explicit Resource(std::string n) : name(std::move(n)) {
        std::printf("acquire %s\n", name.c_str());
    }
    ~Resource() noexcept { std::printf("release %s\n", name.c_str()); }
    Resource(const Resource&) = delete;
    Resource& operator=(const Resource&) = delete;
};

struct Session {
    Resource conn{"conn"};
    Resource log{"log"};
    explicit Session(bool fail) {
        if (fail) throw std::runtime_error{"session setup failed"};
        std::printf("session ready\n");
    }
    ~Session() noexcept { std::printf("release Session\n"); }
};

void run(bool fail) {
    Resource guard{"guard"};
    Session s{fail};          // ha ez dob, a guard már felépült
    std::printf("run done\n");
}

int main() {
    try {
        run(true);
    } catch (const std::exception& e) {
        std::printf("caught: %s\n", e.what());
    }
}

Az illusztrált kimenet (run(true) esetén) a következő sorrendet mutatja:

acquire guard
acquire conn
acquire log
release log
release conn
release guard
caught: session setup failed

Érdemes végigkövetni: a guard a run blokkjában épült fel elsőként, ezért utolsóként destruálódik. A Session konstruktora dobott, ezért a Session destruktora nem fut le — az objektum soha nem épült fel teljesen. Viszont a Session két tagja, a conn és a log, amelyek konstrukciója befejeződött, igen, fordított sorrendben (log, majd conn). Ezután a run frame-jében a guard következik. A catch blokkba már csak a main try blokkja él, és ott a const std::exception& handler elkapja a std::runtime_error-t a bázisosztály-szabály miatt.

25.1.3.1 Konstruktorhiba és részlegesen felépült részobjektumok

A fenti példa a legfontosabb szerkezeti szabályt demonstrálja: ha egy konstruktor teste vagy egy tag/bázis inicializálója kivételt dob, akkor a már teljesen felépült bázisok és tagok destruálódnak, de magának az objektumnak a destruktora nem fut le. Az objektum ugyanis nem számít felépültnek, így a destruktorára vonatkozó szerződés („a konstruktor sikeres befejezése után hívd”) nem teljesül. Következmény: minden erőforrást, amit a konstruktor megszerez, tagnak kell lennie, mert a tagok destruktora az unwinding során lefut; a konstruktor törzsében „kézzel” nyitott erőforrás viszont szivárog, ha a törzs közben dob.

A delegáló konstruktor (delegating constructor) esete finomabb: ha a delegált (cél) konstruktor sikeresen lefut, az objektum teljesen felépült, tehát ha a delegáló konstruktor törzse ezután dob, a teljes objektum destruktora lefut. Ilyenkor a destruktornak fel kell készülnie arra, hogy egy „majdnem kész” állapotot takarítson el.

25.1.4 Rethrow, nested exception és exception_ptr

A throw; utasítás az aktuális exception objectet dobja újra, másolás nélkül. Csak aktív handler belsejében (vagy onnan hívott függvényben, ahol a kivétel még kezelés alatt áll) legális; ha nincs aktív kivétel, a program std::terminate-mel leáll. A rethrow a helyes eszköz arra, hogy egy handlert részlegesen kezeljünk (pl. naplózzunk, majd továbbadjuk), anélkül hogy elveszítenénk az eredeti típust vagy az exception object azonosságát.

A hiba kontextusának megőrzésére szolgál a nested exception (beágyazott kivétel): a std::throw_with_nested(new_exception) a jelenleg kezelt kivételt egy új exception objectbe csomagolja, és azt dobja. A std::rethrow_if_nested(e) a handlerben kibontja a belső kivételt, így a hibalánc megjeleníthető anélkül, hogy a rétegek egymás típusait ismernék. Az std::exception_ptr ezzel rokon: egy std::current_exception() hívással megszerezhető, másolható, akár szálak között átvihető tulajdonló fogantyú, amely életben tartja az exception objectet, és std::rethrow_exception(ptr) hívással később újradobható. Ez a minta akkor hasznos, ha a hibát egy háttérszálban keletkeztetjük, de a fő szálon kell jelenteni.

// nested.cpp — fordítás: g++ -std=c++20 -Wall -Wextra -O2 -o nested nested.cpp
#include <cstdio>
#include <exception>
#include <stdexcept>

void load_config() {
    try {
        throw std::runtime_error{"bad token at line 7"};
    } catch (...) {
        std::throw_with_nested(std::runtime_error{"config load failed"});
    }
}

int main() {
    try {
        load_config();
    } catch (const std::exception& e) {
        std::printf("outer: %s\n", e.what());
        try {
            std::rethrow_if_nested(e);
        } catch (const std::exception& inner) {
            std::printf("inner: %s\n", inner.what());
        }
    }
}

Az illusztrált kimenet outer: config load failed, majd inner: bad token at line 7. A std::exception_ptr itt nem szerepel, de a throw_with_nested mögött is egy exception_ptr-szerű kapcsolat tartja életben a belső kivételt; a különbség az, hogy a nested_exception bázis része az új exception objectnek, míg a std::current_exception() egy önálló fogantyút ad.

25.1.5 Kezeletlen kivétel: terminate és abort

Ha a keresés a main-ig eljut és nem talál illeszkedő handlert — vagy ha egy noexcept függvényből kivétel lép ki, vagy ha a kivétel terjedése közben egy destruktor dob —, akkor a program std::terminate-et hív. A terminate alapértelmezett viselkedése a std::abort, de a std::set_terminate-tel saját termináló handler állítható be, amely naplózhat vagy utolsó pillanatban diagnosztikát írhat. Fontos, hogy a szabvány nem garantálja, hogy a terminate hívása előtt a teljes stack unwinding lefut: az implementáció dönti el, mennyi cleanup történik. Ezért arra építeni, hogy „a destruktorok mindenképp lefutnak, mielőtt a program meghal”, hibás feltételezés. A noexcept függvényből kiszökő kivétel és a destruktorból kiszökő kivétel tehát nem „kezeletlen hiba”, hanem azonnali leállás — ez a noexcept garancia ára, és ezért kell a destruktorokat és a swap-eket hibamentesen megírni.

25.1.6 Tipikus hibák

25.1.7 Feladatok

  1. (magyarázó) A unwinding.cpp példában írd le a kimenet sorrendjét run(false) esetén, és indokold minden sor helyét. Mi változik, ha a Session konstruktorában a throw után egy std::printf áll?

  2. (kódolási) Az alábbi osztály exception-unsafe: a konstruktor törzsében szerez két erőforrást, és a második megszerzése dobhat. Alakítsd át úgy, hogy hiba esetén ne szivárogjon semmi, és a Connection destruktora továbbra is futtassa a szükséges tisztítást.

struct Connection {
    int a = -1;
    int b = -1;
    Connection() {
        a = open_a();      // sikerül
        b = open_b();      // dobhat
    }
    ~Connection() { if (a >= 0) close_a(a); if (b >= 0) close_b(b); }
};
  1. (diagnosztikai) Az alábbi kódrészlet három hibát tartalmaz. Nevezd meg mindet, mondd meg, mi a következményük, és írd le a javítást.
struct Base { virtual ~Base() = default; };
struct Derived : Base {};

void handle() {
    try {
        may_throw();
    } catch (std::exception e) {              // (1)
        log(e.what());
        throw new std::runtime_error{"wrapped"};   // (2)
    }
}

void g() {
    try { throw Derived{}; }
    catch (const Base&) { /* ... */ }         // (3)
    catch (const Derived&) { }
}

25.1.8 Megoldások

1. megoldás. run(false) esetén a Session teljesen felépül, ezért a destruktora is lefut. A sorrend: acquire guard, acquire conn, acquire log, session ready, run done, majd a run blokkjának végén a fordított sorrend: release Session, release log, release conn, release guard. Az indoklás: a blokkon belüli automatikus objektumok a konstrukció sorrendjének inverzében destruálódnak, és a Session saját destruktora a tagjai előtt fut, mert a tagok a Session részobjektumai. Ha a throw után egy std::printf állna, az már nem futna le, mert a throw azonnal elhagyja a konstruktor törzsét — a konstruktor nem fejeződik be, a Session destruktora nem fut, de a conn és a log tag igen. Ellenőrzési feltétel: a kimenetben a release Session hiányzik, a release log és release conn viszont megjelenik a guard felszabadítása előtt.

2. megoldás. A két erőforrást külön RAII tagba kell tenni, hogy a tagok destruktora végezze a tisztítást, és a Connection törzsében ne maradjon „kézi” erőforrás:

struct Handle {
    int fd = -1;
    explicit Handle(int f) : fd{f} {}
    ~Handle() noexcept { if (fd >= 0) close_handle(fd); }
    Handle(const Handle&) = delete;
    Handle& operator=(const Handle&) = delete;
};

struct Connection {
    Handle a{open_a()};    // ha ez dob, nincs mit takarítani
    Handle b{open_b()};    // ha ez dob, az a destruktora lefut
};

Indoklás: a Connection konstruktora most a taginicializáló listán keresztül épül fel; ha az open_b() dob, akkor az a tag már teljesen felépült, ezért a ~Handle lefut, a ~Connection viszont nem. A kézi if (a >= 0) close_a(a) logika a ~Handle-be került, így az unwinding „látja”. Ellenőrzési feltétel: egy olyan tesztben, ahol az open_b() kivételt dob, az open_a() által lefoglalt erőforrás felszabadul, és a folyamat nem szivárog (a laborban pl. számlálóval vagy strace-csel ellenőrizhető, hogy a close megtörtént).

3. megoldás. (1) Érték szerinti catch: std::exception e lemásolja és levágja az exception objectet, így a catch blokkban a dinamikus típus elveszik, a catch végén pedig a másolat destruálódik. Javítás: catch (const std::exception& e). (2) throw new std::runtime_error{...}: a dobott pointer tulajdonosa ismeretlen, a hívó nem tudja, hogy delete-elni kell-e; ráadásul a new maga is dobhat std::bad_alloc-ot, ami elfedheti az eredeti hibát. Javítás: throw std::runtime_error{"wrapped"};, vagy ha meg akarjuk őrizni az eredeti kontextust, std::throw_with_nested(std::runtime_error{"wrapped"}). (3) A catch (const Base&) előbb áll, mint a catch (const Derived&), ezért a Derived handler soha nem fut le — a throw Derived{} statikus típusa Derived, de a bázis-handler illeszkedik rá, és az első illeszkedő nyer. Javítás: a Derived handlert a Base elé kell tenni. Ellenőrzési feltétel: a javított változatban a Derived ág fut le, és a catch blokkba lépéskor a what()/tagok a várt dinamikus típust mutatják.

25.1.9 Összefoglaló

A throw egy külön élettartamú exception objectet hoz létre, amelynek típusát a throw operandusának statikus típusa határozza meg; ezért a származtatott hiba dobásához a statikus típusnak is származtatottnak kell lennie, és a handlert const&-sel kell elkapni a slicing elkerülésére. A handlerek sorrendben illeszkednek, csak azonos típusra, bázisra, pointerre és catch (...)-ra; a bázis-handler elé tett származtatott handler elérhetetlen. Az unwinding a felépült automatikus objektumokat a konstrukció fordított sorrendjében destruálja, a blokkok belülről kifelé ürülnek, és a catch blokkba már csak a hívó frame-jei élnek. Konstruktorhiba esetén a teljesen felépült bázisok és tagok destruálódnak, maga az objektum nem, ezért az erőforrásokat tagokba kell tenni. A throw; másolás nélkül dob újra, a throw_with_nested/rethrow_if_nested és az exception_ptr a hibalánc megőrzésére szolgál, a kezeletlen kivétel pedig std::terminate-hez vezet, amelynek pontos unwinding-viselkedése implementációfüggő.

Ehhez az alfejezethez nem állt rendelkezésre ellenőrzött forrásjegyzet; a szövegben szereplő szabvány-szekciónevek ([except.throw], [except.ctor], [except.handle], [except.terminate], [except.nested]) a C++20 szerkezetére utaló hivatkozások, amelyek konkrét szövegét és a fordítók viselkedését a tanulónak kell a saját laborprofiljában ellenőriznie. A kódok kimenete illusztráció, nem mért eredmény.