Ez az alfejezet a hibakezelésnek arról a rétegéről szól, amelyet a
legtöbb csapat elront: nem arról, hogy hogyan dobunk és kapunk
el kivételt, hanem hogy hol van a modulunk határa, és
azon a határon ki milyen ígéretet tesz. A No-throw,
strong és basic exception guarantee (25.2) alapján már ismered a
no-throw, strong és basic garanciát; itt azt vizsgáljuk, hogyan lesz
ezekből ellenőrizhető, dokumentált szerződés a noexcept, a
destruktor, az explicit lezáró API, a C ABI-trampolin és a buildprofil
szintjén. A C oldali stabil interfészhez lásd Opaque
handle és stabil C könyvtári interfész (10.4).
A vezérelv egyetlen mondatban: a hibakezelési policy nem ott dől el, ahol a hiba keletkezik, hanem ott, ahol a hiba átlép egy modul-, szál- vagy nyelvi határt. A határ mindkét oldalán más a helyes eszköz: belül kivétel, kívül stabil hibakód; belül invariáns-megőrzés, kívül dokumentált állapotáttérés.
Definíció. Egy függvény exception
specification-je (kivétel-specifikációja) mondja meg, hogy
kiléphet-e belőle kivétel. A noexcept alak
noexcept(true)-t jelent, a noexcept(kifejezés)
alakban a kifejezés értéke dönt; a kifejezés nem értékelődik ki
futásidőben. A noexcept operátor önmagában viszont egy
bool értékű, compile-time lekérdezés:
noexcept(kif) igaz, ha a kif kiértékelése nem
dobhat.
Feltétel és következmény. Ha egy függvényre
noexcept(true) érvényes, és mégis kivétel lép ki belőle,
akkor a szabvány szerint std::terminate hívódik. Ez
nem nem definiált viselkedés, hanem definiált leállás —
éppen ezért veszélyes: a fordító nem jelzi, csak a futó program hal meg.
Két gyakorlati következmény:
noexcept függvény hívási pontja körül nincs értelmes
catch; a kivétel a hívási pontig sem ér el.std::terminate előtt megtörténik-e a vermet
kibontó (stack unwinding) mechanizmus, az implementáció dönti el. Ezért
nem lehet arra építeni, hogy a noexcept függvényben
lévő objektumok destruktora lefut. Debuggerben ezért látod néha
hiányos backtrace-t.C++17 óta a noexcept a függvény
típusának része. Következmények:
void (*)(int) noexcept és void (*)(int)
különböző típus; a noexcept függvényre mutató pointer
implicit módon átalakítható a nem-noexcept változatra,
visszafelé nem. Overload-feloldásban és std::function-be
való behelyezésnél ez számít.
A tipikus félreértés: a noexcept nem
optimalizálási tipp, hanem ígéret. Csak akkor írd ki,
ha a függvény törzsének minden kivétel-forrását ismered és kizártad. A
noexcept hiánya nem hiba: a függvények alapértelmezett
specifikációja noexcept(false), kivéve a destruktorokat és
néhány speciális esetet.
Sablonban nem tudod előre, hogy a paramétertípus művelete dobhat-e. A helyes minta: a wrapper soha ne ígérjen többet, mint amit a belső művelet ígér:
// condnoexcept.cpp
#include <utility>
template <class Fn>
void invoke_once(Fn&& f) noexcept(noexcept(std::forward<Fn>(f)()))
{
std::forward<Fn>(f)();
}
// A lambdák operator()-je alapból noexcept(false) — ezt írni kell!
static_assert(!noexcept(invoke_once([]{})));
static_assert( noexcept(invoke_once([]() noexcept {})));
int main() { invoke_once([]() noexcept {}); }g++ -std=c++20 -Wall -Wextra -Wpedantic -O2 condnoexcept.cpp -o condnoexceptA kettős noexcept(noexcept(...)) szerkezet nem elírás: a
külső a deklaráció, a belső a compile-time lekérdezés. A
static_assert-ek itt a szerződést rögzítik, nem futásidejű
viselkedést.
Fontos csapda: a noexcept a
teljes műveletre vonatkozik, nem csak a típus mozgatására. Egy
std::vector<T>::push_back például allokálhat, tehát
noexcept(false); ha egy wrappert noexcept-ra
deklarálsz, mert „a T másolása nem dob”, a bad_alloc miatt
a programod std::terminate-nel hal meg. A helyes megoldás:
vagy noexcept(noexcept(...)) a tényleges kifejezésre, vagy
reserve()-ral előre kizárt allokáció, vagy egyszerűen nem
noexcept a wrapper.
Destruktor. Az implicit destruktor specifikációja
noexcept(true), hacsak valamely tag vagy bázis destruktora
potenciálisan dobó. Mivel a std::terminate leggyakoribb oka
a destruktorból kilépő kivétel (különösen unwind közben), a szabály:
a destruktor ne dobjon. Ha a takarítás elbukhat (flush,
close, hálózati bontás), a destruktor csak best effort lehet, a
hibát pedig egy külön, explicit függvény jelenti — lásd a következő
szakaszt.
Swap. A noexcept swap három helyen
számít:
std::move_if_noexcept-t használják: ha a move constructor
potenciálisan dobó, és a másolás elérhető, akkor
másolnak, hogy a strong garancia megmaradjon. Egy
noexcept nélküli move constructor tehát lassabb
újraallokációt eredményez — de ez következmény, nem indok a hamis
noexcept-ra.std::swap alapértelmezett implementációja move-okra
épül; ha a move dob, a swap nem tud garanciát adni.A sorrend tehát: először valóban nem dobó move/swap
implementáció, aztán noexcept rá.
A destruktor nem tud hibát jelenteni. A minta, amit a standard
könyvtár is követ (pufferelt írás, fstream::close):
a destruktor best effort, a hibajelzés egy külön, a hívó által
kötelezően meghívandó művelethez tartozik.
// sink.cpp
#include <cstdio>
#include <cstring>
#include <new>
#include <stdexcept>
#include <string>
class file_sink {
public:
explicit file_sink(const std::string& path)
: f_(std::fopen(path.c_str(), "wb")) {
if (!f_) throw std::runtime_error("open failed: " + path);
}
~file_sink() {
if (f_) std::fclose(f_); // best effort; itt nincs hibacsatorna
}
file_sink(const file_sink&) = delete;
file_sink& operator=(const file_sink&) = delete;
void write(const char* p, std::size_t n) {
if (std::fwrite(p, 1, n, f_) != n)
throw std::runtime_error("write failed");
}
// Az egyetlen hely, ahol a flush/close hibája megfigyelhető.
void commit() {
if (!f_) throw std::logic_error("commit on closed sink");
if (std::fflush(f_) != 0) throw std::runtime_error("flush failed");
int rc = std::fclose(f_);
f_ = nullptr; // így a commit kétszer hívva nem dupláz
if (rc != 0) throw std::runtime_error("close failed");
}
private:
std::FILE* f_;
};
extern "C" int sink_append(const char* path, const char* text) noexcept {
try {
file_sink s{path};
s.write(text, std::strlen(text));
s.commit();
return 0;
} catch (const std::bad_alloc&) {
return -2;
} catch (...) {
return -1; // minden kivétel itt alakul hibakóddá
}
}
int main() {
try {
file_sink s{"out.bin"};
s.write("hello\n", 6);
s.commit();
} catch (const std::exception& e) {
std::fprintf(stderr, "error: %s\n", e.what());
return 1;
}
}g++ -std=c++20 -Wall -Wextra -Wpedantic -O2 sink.cpp -o sinkA példa nem lett lefuttatva; a fenti parancs a tanuló laborjában
végrehajtható ellenőrzés. A commit() utáni
f_ = nullptr két dolgot ad: a destruktor nem zár kétszer,
és a commit() ismételt hívása logic_error-t
dob a néma adatvesztés helyett. A write() hibája viszont
továbbra is kivétellel terjed — ez belső, C++ oldali hiba, nem
határátlépés.
Ha a hívó nem tud kivételt kezelni (pl. -fno-exceptions
build), a commit() visszatérési értéke
std::error_code legyen; a
std::error_code/std::expected részleteit az error_code, system_error és expected (25.4)
tárgyalja.
Három helyen kötelező a kivétel lefordítása hibakóddá, mert a kivétel nem mehet át:
extern "C"
függvényt C kód hívja, amely nem ismeri a C++ kivételmodellt. A
trampolin teste noexcept, belül
try/catch(...); a kifelé menő csatorna a visszatérési érték
vagy egy opaque handle (Opaque handle és stabil C
könyvtári interfész (10.4)) státuszmezője. A catch(...)
ne nyelje el a típust: naplózás vagy
std::current_exception() elmentése a konverzió előtt.try/catch-nek lennie, nem a callback belsejében — a
callback szerződése „nem dobok”, ezt a wrappernek kell
kikényszerítenie.std::thread
belépési függvényéből kilépő kivétel std::terminate-hez
vezet. A szál törzsét try/catch-be kell tenni, és az
eredményt csatornán (queue, std::promise, státuszmező)
visszaadni.A határ mindhárom esetben ugyanaz: kint hibakód, bent kivétel, és a konverzió egyetlen, rövid, ellenőrizhető helyen történik. Amit itt szándékosan nem részletezünk: mi történik, ha mégis átengedünk egy kivételt a C ABI-n — az nem támogatott, és nem képezhető belőle hordozható viselkedés.
A -fno-exceptions (GCC/Clang) nem „kikapcsolja a
hibákat”, hanem eltávolítja a hibacsatornát: a
throw, try és catch használata
fordítási hibát ad, a standard könyvtár egyes részei pedig
std::terminate-re/abort-ra váltanak (a pontos
viselkedést a választott libstdc++/libc++ kiadás dokumentációja adja meg
— ezt ellenőrizni kell). Ezért ez nem kapcsoló, hanem külön
capability profile: saját build-cél, saját hibapolicy
(mindenhol error_code/expected), saját
CI-sáv.
Ellenőrzéshez használj compile probe-ot, ne feltételezést:
# csak a feature-macro jelenlétét vizsgáljuk, nem futtatunk kódot
echo '#if defined(__cpp_exceptions)
#error "exceptions enabled"
#endif
int main(){}' | g++ -std=c++20 -fno-exceptions -x c++ -fsyntax-only -A __cpp_exceptions (199711L, ha engedélyezett) jelenléte
a szokásos jelzés, de ez implementációfüggő; a profil rögzítse a konkrét
compiler- és library-verziót. Olyan kód, amely kivétellel jelzi a hibát,
ebben a profilban nem fordítható le — ez nem hiba, hanem a profil
következménye, és a kettőt (kivételes és nem kivételes változat) külön
fordítási egységben vagy feltételes fordítással kell fenntartani.
noexcept „optimalizálásként” olyan
függvényen, amely allokál vagy könyvtári hívást végez. Javítás:
noexcept(noexcept(...)), vagy hagyd el.~T() dob, unwind
közben terminate. Javítás: best effort destruktor
+ explicit commit()/close().commit() után is zár. Javítás: állapotjelző
(f_ = nullptr), és a commit() is
ellenőrizze.catch(...) a C trampolinban, naplózás
nélkül. A hiba típusa elveszik. Javítás: típus
rögzítése a konverzió előtt.std::thread + dobó lambda → terminate, nem
kivétel a hívónál. Javítás: try/catch a szál
törzsében.-fno-exceptions mellett nincs
abort. Az allokációs hiba és a library-előfeltétel-sértés
továbbra is leállíthat.1. (magyarázó) Adott
void f() noexcept;, amelynek törzse egy dobó
g()-t hív. Mi történik, mit tud elkapni a hívó, és
lefutnak-e a f belsejében lévő objektumok destruktorai?
2. (kódolás) Írj invoke_twice sablont,
amely egy callable-t kétszer hív meg, és a noexcept-ját a
callable műveletéből vezeti le. Készíts static_assert-eket
egy dobó és egy nem dobó callable-re.
3. (diagnosztika) Egy C könyvtárnak átadott callback
std::stoi-t hív, amely
std::invalid_argument-ot dob. Írd le a következményt, és
javítsd a kódot úgy, hogy a hiba a C oldalon hibakódként jelenjen
meg.
4. (build) Hogyan bizonyítod compile probe-pal, hogy
egy adott buildben engedélyezettek-e a kivételek? Mi a teendő, ha egy
harmadik féltől származó header throw-t tartalmaz?
A g() kivétele kilép az f-ből, de
f noexcept(true), ezért
std::terminate hívódik. A hívó nem tudja
elkapni: a kivétel nem éri el a hívási pontot. A destruktorok lefutása
implementációfüggő, ezért nem tervezhetsz rájuk — ez a gyakorlati
különbség a „definiált leállás” és a „rendben lezáruló” között.
Ellenőrzés: a viselkedés akkor helyes, ha a terv nem tartalmaz
noexcept függvényt olyan műveletre, amely dobhat; a javítás
a noexcept elhagyása vagy
noexcept(noexcept(g())).
#include <utility>
template <class Fn>
void invoke_twice(Fn&& f)
noexcept(noexcept(std::forward<Fn>(f)()) &&
std::is_nothrow_copy_constructible_v<std::decay_t<Fn>>)
{
auto g = f; // a második híváshoz külön példány
g();
g();
}
static_assert(!noexcept(invoke_twice([]{})));
static_assert( noexcept(invoke_twice([]() noexcept {})));Indoklás: a noexcept csak akkor igaz, ha a
hívás és a callable másolása is nem dobó. A lambdák
operator()-je alapból noexcept(false), ezért
kell a noexcept a lambdára. Ellenőrzés: a két
static_assert forduljon le; ha a std::decay_t
helyett Fn-t írsz, referencia-típusnál hibás eredményt
kapsz — ez a gyakori hiba.
A kivétel a callbackből a C hívási láncba próbál kilépni, ami nem
támogatott; a folyamat nagy valószínűséggel
std::terminate-mel leáll. A javítás: a callback C++ oldali
burka noexcept, belül try/catch(...), és az
eredményt a context struct státuszmezőjébe írja:
struct ctx { int status; };
extern "C" void on_line(void* p, const char* s) noexcept {
auto* c = static_cast<ctx*>(p);
try {
c->status = std::stoi(s);
} catch (...) {
c->status = -1; // stabil hibakód a C oldal felé
}
}Ellenőrzés: a C oldal soha nem lát kivételt; a
status minden ágon íródik; a catch(...) előtt
legalább a hiba típusa naplózható.
A fenti -fsyntax-only próba #error-ral
jelez, ha __cpp_exceptions definiált; a fordító kilépési
kódja a válasz. Ha egy header throw-t tartalmaz, az
-fno-exceptions profilban nem fordítható — a lehetőségek:
(a) a header mögötti funkcionalitást kiváltó, hibakódos alternatíva, (b)
a modul kizárása a profilból, (c) a profil elhagyása. Fordítói
kényszerítés nincs rá; ezt build-szinten kell dokumentálni, és a
feature-macro állapotát a CI-ban rögzíteni.
A noexcept ígéret, amelynek megszegése
std::terminate; ezért csak bizonyítottan nem dobó műveletre
írjuk ki, sablonban pedig noexcept(noexcept(...)) formában
vezetjük le. A destruktor soha nem dob: a hibát vagy elnyeli (best
effort), vagy egy explicit commit()/close()
jelenti, amelynek visszatérési értéke vagy kivétele a tényleges
hibacsatorna. A C ABI, a callback és a szál belépési pont mind
kivétel-határ: kifelé hibakód, befelé try/catch, egyetlen
konverziós ponton. Az exceptions nélküli build nem kapcsoló, hanem külön
capability profile saját hibapolicyval és saját CI-sávval.