25.3 noexcept határok és hibapolicy

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.

25.3.1 A noexcept mint vállalás és a terminate következménye

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:

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.

25.3.2 Feltételes noexcept sablonokban

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 condnoexcept

A 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.

25.3.3 Destruktor és swap garanciája

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:

  1. Copy-and-swap idiomban a strong garancia akkor is tartható, ha a másolás dob, de a swap nem.
  2. A standard tárolók áthelyezéskor a 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.
  3. A 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á.

25.3.4 Explicit close/commit: hol legyen a hiba jelentése

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 sink

A 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.

25.3.5 Callback, thread entry és a C ABI határa

Három helyen kötelező a kivétel lefordítása hibakóddá, mert a kivétel nem mehet át:

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.

25.3.6 Exceptions nélküli build külön profilként

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.

25.3.7 Tipikus hibák és javításuk

25.3.8 Feladatok

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?

25.3.9 Megoldások

25.3.9.1 1. megoldás

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())).

25.3.9.2 2. megoldás

#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.

25.3.9.3 3. megoldás

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ó.

25.3.9.4 4. megoldás

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.

25.3.10 Összefoglaló

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.