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.
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
H és T azonos típus (a legfelső szintű
const/volatile minősítőket figyelmen kívül
hagyva), vagyH a T egy egyértelmű, hozzáférhető
bázisosztálya (érték vagy referencia formában), vagyH és T pointer, és a H által
mutatott típus a T által mutatott típus bázisa, illetve
H void*/const void*, vagyH a catch (...) mindent elkapó alak.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.
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.
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.
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.
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.
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.
catch (std::exception e) vághat, és a catch
blokk végén a másolatot destruálja. Helyette
catch (const std::exception& e).catch (...)-ot pedig
utolsóként hagyni.throw new T(...). A dobott pointert
senki nem fogja törölni; a hívó nem tudhatja, hogy neki kell
delete-elnie. Értéket kell dobni, nem pointert (kivéve, ha
a pointer egy létező, hosszú életű objektumra mutat, és ezt
dokumentáltuk).terminate-hez vezet. A destruktort
noexcept-ként kell megírni, és a tisztítást hibamentesen
kell elvégezni.catch (...) mindent elnyel. Ha nem
dobjuk tovább (throw;), a hiba típusa és kontextusa
elveszik, a hívó nem tud differenciálni.(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?
(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); }
};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&) { }
}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.
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.