25.5 Hibakezelési és exception safety labor

Ez a labor a fejezet eddigi garanciafogalmait fordítja át mérhető, összehasonlítható kódba. Egyetlen funkcionális szerződést — egy kulcs–érték parser és egy erőforrás-gyártó műveletet — valósítunk meg két hibamódban: kivétellel és explicit eredménnyel. A cél nem az, hogy eldöntsük, melyik „jobb”, hanem hogy megmutassuk: a két változat megfigyelhető viselkedése azonos lehet, és a választott garancia (no-throw, strong, basic) állapoton ellenőrizhető. A labor az error_code, system_error és expected (25.4) hibarecord-fogalmára, a No-throw, strong és basic exception guarantee (25.2) garanciadefinícióira és az Osztály- és operátortervezési labor (23.5) értéktípus-mintáira épül; ezeket itt nem ismételjük meg, csak felidézzük.

25.5.1 A labor szerződése: egy viselkedés, két hibamód

Rögzítsük pontosan, mit jelent az, hogy két implementáció ugyanazt a szerződést teljesíti. Legyen a specifikáció hármas: bemenetek halmaza II, sikeres kimenetek halmaza OO, hibajelzések halmaza EE. Két implementáció, AA (kivételes) és BB (explicit eredmény), funkcionálisan ekvivalens, ha minden i∈Ii \in I bemenetre:

  1. AA akkor és csak akkor tér vissza o∈Oo \in O-val, amikor BB is oo-val tér vissza;
  2. AA akkor és csak akkor dob e∈Ee \in E hibát, amikor BB ee-t jelzi;
  3. a hívás után megfigyelhető erőforrás-állapot mindkettőnél azonos: a hívó által birtokolt erőforrások száma megegyezik, és a labor saját, belső számlálói nullára állnak vissza.

A harmadik pont az, ami a labort laborkká teszi: nem elég a visszatérési értéket összevetni, a hibás út mellékhatásait is ellenőrizni kell. Ehhez olyan megfigyelhető állapot kell, amelyet a program a hiba után is ki tud írni. A legegyszerűbb ilyen a statikus példányszámláló; egyszálú laborban ez elég, többszálú környezetben atomikus számláló vagy szálankénti gyűjtés kellene.

A labor buildparancsa a rögzített C++20 profillal:

g++ -std=c++20 -O2 -g -Wall -Wextra -pedantic parse_lab.cpp -o parse_lab
# vagy
clang++ -std=c++20 -O2 -g -Wall -Wextra -pedantic parse_lab.cpp -o parse_lab

A példa nem használ C++23-as library funkciót a fő útvonalon; az std::expected változatot külön, jelölt módon adjuk meg. A kód fordítási és futási eredményét itt nem állítjuk igazoltnak: a labor a tanuló saját mérési jegyzőkönyvében rögzíti a tényleges kimenetet.

25.5.2 A parser eredménye és az error offset élettartama

A parser bemenete egy key=<decimális szám> alakú szöveg. A hiba két adatból áll: egy hibakódból és egy error offset-ből. Az offset szemantikája pontosan az, amit a fél-nyílt tartományoknál megszoktunk: a bemenet első hibás byte-jának indexe, és megengedett az in.size() érték is, ami a bemenet végére mutat, de nem dereferálható. Ez a konvenció akkor hasznos, ha a hibát egy kurzorpozícióhoz akarjuk kötni.

A lifetime-kérdés nem az offsetnél, hanem a hibaüzenetnél jelentkezik. Ha a hibarecord std::string_view-t tartalmazna, akkor a rekord érvényessége a bemenet — vagy rosszabb esetben egy lokális std::string — élettartamához kötődne. A parse_kv ezért szándékosan nem hordoz pointert: a hívó birtokolja a bemenetet, a hiba rekord viszont önmagában érvényes marad.

// parse_lab.cpp -- C++20, ISO mód
#include <cstddef>
#include <cstdint>
#include <exception>
#include <stdexcept>
#include <string_view>
#include <utility>
#include <vector>

namespace lab {

enum class Errc { none = 0, empty_input, bad_syntax, bad_number, overflow };

struct ParseError {
    Errc code = Errc::none;
    std::size_t offset = 0;
    explicit operator bool() const noexcept { return code != Errc::none; }
};

struct ParseResult {
    bool ok = false;
    int value = 0;
    ParseError error{};
};

inline ParseResult parse_kv(std::string_view in) noexcept {
    if (in.empty()) return {false, 0, {Errc::empty_input, 0}};
    const std::size_t eq = in.find('=');
    if (eq == std::string_view::npos)
        return {false, 0, {Errc::bad_syntax, in.size()}};
    if (eq == 0)
        return {false, 0, {Errc::bad_syntax, 0}};
    std::int64_t acc = 0;
    bool any = false;
    for (std::size_t i = eq + 1; i < in.size(); ++i) {
        const char c = in[i];
        if (c < '0' || c > '9') return {false, 0, {Errc::bad_number, i}};
        acc = acc * 10 + (c - '0');
        if (acc > INT32_MAX) return {false, 0, {Errc::overflow, i}};
        any = true;
    }
    if (!any) return {false, 0, {Errc::bad_number, eq + 1}};
    return {true, static_cast<int>(acc), {}};
}

struct ParseException : std::runtime_error {
    explicit ParseException(ParseError e)
        : std::runtime_error("parse error"), error(e) {}
    ParseError error;
};

inline int parse_kv_or_throw(std::string_view in) {
    const ParseResult r = parse_kv(in);
    if (!r.ok) throw ParseException{r.error};
    return r.value;
}

} // namespace lab

Figyeljük meg: a parse_kv noexcept, allokációmentes, és a hibarecord két skalár. A kivételes változat ehhez képest csak fordít — nem tartalmaz saját parserlogikát. Ez a labor egyik legfontosabb mintája: egy igazságforrás, két hibamód.

A parse_kv_or_throw nem noexcept, és nem is lehet az: a throw maga allokálhat a kivételobjektum számára, tehát a hibás út is hibázhat (std::bad_alloc). Ezért a kivétel típusa kicsi, és a kivételes API-t csak ott használjuk, ahol a hívó amúgy is kivételkezelő kontextusban van.

25.5.3 A resource factory két változata

Az erőforrás itt egy fd-szerű, csak mozgatható típus. A számláló az élő példányokat méri; a valódi erőforrás-lezárást külön, nyitott handle-eket számláló változóval lehetne ellenőrizni, ezt a feladatok között kitérjük.

namespace lab {

struct Resource {
    static inline long live = 0;
    int handle = -1;

    Resource() noexcept { ++live; }
    explicit Resource(int h) noexcept : handle(h) { ++live; }
    Resource(Resource&& o) noexcept : handle(o.handle) {
        o.handle = -1;
        ++live;
    }
    Resource& operator=(Resource&& o) noexcept {
        if (this != &o) { handle = o.handle; o.handle = -1; }
        return *this;
    }
    Resource(const Resource&) = delete;
    Resource& operator=(const Resource&) = delete;
    ~Resource() { --live; }
    explicit operator bool() const noexcept { return handle >= 0; }
};

struct MakeOutcome {
    Resource resource;
    Errc code = Errc::none;
    explicit operator bool() const noexcept { return code == Errc::none; }
};

// Kozos mag: nem dob, hibakodot ad.
inline MakeOutcome try_make(std::size_t size) noexcept {
    if (size == 0)    return {Resource{}, Errc::bad_syntax};
    if (size > 4096)  return {Resource{}, Errc::overflow};
    return {Resource{static_cast<int>(size)}, Errc::none};
}

// Kiveteles valtozat: csak fordit.
inline Resource make(std::size_t size) {
    auto out = try_make(size);
    if (!out) throw std::runtime_error("make failed");
    return std::move(out.resource);
}

} // namespace lab

A MakeOutcome igazságértéke a hibakódra épül, nem a handle-re, így a kettő nem mondhat ellent egymásnak, amíg a mag konzisztens. A hibaágban is létrejön egy Resource (érvénytelen handle-lel), ami a MakeOutcome megszűnésekor lezárul — ez a minta pazarló, valós kódban std::optional<Resource> vagy std::expected a helyes választás. C++23-ban, a feature-testtel ellenőrizve:

#if defined(__cpp_lib_expected) && __cpp_lib_expected >= 202202L
#include <expected>
namespace lab {
inline std::expected<Resource, Errc> try_make23(std::size_t size) noexcept {
    auto out = try_make(size);
    if (!out) return std::unexpected(out.code);
    return std::move(out.resource);
}
} // namespace lab
#endif

Ez a változat C++23-as library szolgáltatás; C++20 alapkönyvtárral nem elérhető, ezért a példa fő útvonala a saját kimeneti típus. A std::expected dostringje nem hordoz értéket hiba esetén, így a hibarecord itt maga az Errc — az offsetet külön típusba kellene csomagolni, ha a szerződés része.

25.5.4 Throwing copy/move teszttípus, allokációs hiba és a no-leak feltétel

Ahhoz, hogy a garanciákat megfigyeljük, kell egy típus, amely kérésre dob. A Probe másoló konstruktora adott számú másolás után dob; ez modellezi azt, amikor egy konténer közepén fut ki egy allokáció vagy egy elem bemásolása.

namespace lab {

struct Probe {
    static inline long live = 0;
    static inline int copies_before_throw = -1; // -1: soha nem dob
    int id = 0;

    Probe() noexcept { ++live; }
    explicit Probe(int v) noexcept : id(v) { ++live; }
    Probe(const Probe& o) : id(o.id) {
        if (copies_before_throw == 0) throw std::runtime_error("probe copy");
        if (copies_before_throw > 0) --copies_before_throw;
        ++live;
    }
    Probe(Probe&& o) noexcept : id(o.id) { ++live; }
    Probe& operator=(const Probe&) = default;
    Probe& operator=(Probe&&) noexcept = default;
    ~Probe() { --live; }
};

// Erős garanciau epitkezes: a lokalis vector vagy teljesen felepul, vagy a
// kivetel elhagyja a fuggvenyt, es a mar megepult elemek lezarulnak.
inline std::size_t build(std::size_t reserve) {
    std::vector<Probe> v;
    v.reserve(reserve);
    for (int i = 0; i < 4; ++i) v.emplace_back(i);
    return v.size();
}

inline bool no_leak() noexcept {
    return Probe::live == 0 && Resource::live == 0;
}

} // namespace lab

Bizonyításvázlat (strong garantia). Állítás: ha egy művelet először az új állapotot egy elkülönített helyen építi fel, és csak egy nem dobó lépésben cseréli felül a régit, akkor hiba esetén a hívó számára megfigyelhető állapot változatlan. Vázlat: a hiba a felépítési fázisban keletkezik, ekkor a régi állapot érintetlen; a felépített rész a verem unwindingja során lezárul; a cserelépés nem dob, tehát nem keletkezhet félig végrehajtott állapot. A feltétel a „nem dobó csere” — ha ez dob, a vázlat nem áll. A std::vector::push_back újrafelosztásnál ezt a mintát követi, de csak akkor, ha a mozgatás nem dob (vagy a másolás elérhető); erre a pontos szabványfeltételre itt nem hivatkozunk ellenőrzött forrásként.

A no-leak feltétel a teszt végén: Probe::live == 0 && Resource::live == 0. Ez szükséges, de nem elégséges: a számláló a példányokra érzékeny, nem a tényleges erőforrásra. Egy fd-leaket csak akkor látunk meg, ha a Resource destruktora a nyitott handle-ek számát is csökkenti, és azt is ellenőrizzük.

25.5.5 Az unwinding megfigyelése és az optimalizálás hatása

Az unwinding során a lokális objektumok a konstrukció fordított sorrendjében szűnnek meg. Ezt naplózással lehet láthatóvá tenni. Két korlátot külön kell tartanunk. Egyrészt a naplózás mellékhatás; ha a destruktor csak egy nem olvasott számlálót növel, a fordító az as-if szabály alapján elhagyhatja. Másrészt a trace a nyelvi objektumok megszűnését mutatja, nem a gépi szintű unwindelést — a kettő nem azonos.

Ezért a méréshez a számlálót a program végén olvassuk és írjuk ki, vagy a destruktorban volatile írást, illetve std::fprintf-et használunk. noexcept függvényből kifelé menő kivétel std::terminate-hez vezet; ezért a destruktornak és a move konstruktornak noexcept-nak kell lennie — a Resource és a Probe ezt teljesíti. Azt, hogy -O2 alatt melyik változat marad meg, a saját laborban kell megfigyelni; itt nem állítjuk, hogy bármit mértünk volna.

25.5.6 A C API adapter: a határ

A C ABI határán minden kivételt el kell kapni, mert a C hívó nem tud unwindingot kezelni. Az adapter extern "C" függvény, amely try/catch (...)-ban hívja a C++ magot, és hibakódot ad vissza; a catch után nem dob újra.

extern "C" int lab_parse_kv(const char* p, std::size_t n, int* out) noexcept {
    if (p == nullptr || out == nullptr) return -1;
    try {
        const auto r = lab::parse_kv(std::string_view{p, n});
        if (!r.ok) return -static_cast<int>(r.error.code);
        *out = r.value;
        return 0;
    } catch (...) {
        return -99;
    }
}

A catch (...) az utolsó védelem, de nem mindenható: a std::terminate-et okozó eseteket (például egy noexcept megsértését vagy destruktorból dobást) nem fogja meg. A kivétel C ABI-n való átengedésének kérdését ez a labor nem tárgyalja — éppen azt mutatja meg, hogyan kerüljük el.

25.5.7 Tipikus hibák

  1. A hibaüzenet string_view-ként temporálisra mutat. A hibarecord túléli a függvényt, a nézet dangling lesz. Javítás: kód + offset, vagy owning std::string.
  2. A két API külön logikát valósít meg. Az error-code-os és a kivételes változat eltérő hibakódot ad ugyanarra a bemenetre. Javítás: közös noexcept mag, a wrapperek csak fordítanak.
  3. A számlálót nem olvassuk ki. A fordító elhagyja a destruktort, a teszt hamis zöldet ad. Javítás: a számláló legyen megfigyelhető a program végén.
  4. A catch (...) után a C függvény tovább dob. Az adapter szerződése sérül; a catch minden ágában hibakódot kell visszaadni.

25.5.8 Feladatok

  1. Magyarázó feladat. Adott egy ParseError, amely std::string_view message tagot tartalmaz. Mutassa meg egy konkrét hívási láncon, hol keletkezik dangling, és adja meg két lehetséges javítást az élettartam-szerződés szempontjából.
  2. Kódolási feladat. Egészítse ki a Resource típust egy nyitott handle-eket számláló statikus változóval, és írjon olyan tesztet, amely a try_make hibaágában is no_leak()-et vár.
  3. Diagnosztikai feladat. A -O2 fordítás mellett az unwinding trace-ből eltűnnek a Probe destruktor sorai, -O0 mellett megjelennek. Adjon magyarázatot, és javítsa a mérést.
  4. Adapter-feladat. Írjon extern "C" wrappert a make függvényhez, amely minden kivételt elkap, és a handle értékét egy int* kimeneten adja vissza.

25.5.9 Megoldások

1. megoldás. A dangling ott keletkezik, ahol a hibarecord túléli a forrást: például egy parse függvény lokális std::string msg-t tesz a message nézetbe, majd visszatér. A visszatérési érték másolása nem menti meg, mert a nézet a megszűnt bufferre mutat. Két javítás: (a) a message legyen std::string, ami saját tárolót birtokol — ez allokálhat, tehát a noexcept parser nem használhatja; (b) a hibarecord maradjon Errc + offset, és a naplózó réteg generálja a szöveget. Ellenőrzési feltétel: a hibarecord élettartama független a bemenetétől, és a parser továbbra is noexcept.

2. megoldás. Vezessünk be static inline long open_handles = 0; számlálót. A konstruktor növeli, ha handle >= 0; a destruktor csökkenti, ha handle >= 0; a move konstruktor nem változtatja a számot, csak átveszi a handle-t és a forrásban -1-re állítja. A teszt: copies_before_throw beállítása nélkül hívjuk a try_make(0)-t, majd ellenőrizzük, hogy no_leak() igaz és open_handles == 0. Ellenőrzési feltétel: a hibaágban keletkező Resource a MakeOutcome megszűnésekor lezárul, és a számláló nem marad pozitív.

3. megoldás. A destruktor csak egy nem olvasott statikus számlálót növel, ezért a fordító az as-if szabály alapján elhagyhatja; -O0-nál a naplózó hívás még megmarad. A javítás: a számlálót a program végén olvassuk ki és írjuk ki, vagy a destruktorban volatile írást, illetve std::fprintf-et használjunk. Fontos: a trace sorrendje az unwinding során a nyelvi objektumok megszűnésének sorrendje, nem a gépi unwindelés lépéssorozata. Ellenőrzési feltétel: a -O2 és -O0 futás ugyanazt a számlálóértéket adja.

4. megoldás. Az adapter:

extern "C" int lab_make(std::size_t size, int* out_handle) noexcept {
    if (out_handle == nullptr) return -1;
    try {
        lab::Resource r = lab::make(size);
        *out_handle = r.handle;
        return 0;
    } catch (...) {
        return -2;
    }
}

A catch (...) minden C++ kivételt elkap; a nullptr ellenőrzés a try előtt történik, hogy ne kerüljön kivétel-dobás a pointer-ellenőrzésbe. Ellenőrzési feltétel: hiba esetén out_handle érintetlen marad, és a Resource::live a hívás után visszaáll a kiinduló értékre. A kivétel C ABI-n való átengedése továbbra is tilos; ezt a labor nem enyhíti.

25.5.10 Összefoglaló

A labor egyetlen funkcionális szerződést valósít meg két hibamódban, és a megfigyelhető állapoton ellenőrzi az ekvivalenciát. A parser hibarecordja kód + offset, nem nézet, így élettartama független a bemenettől. A resource factory közös noexcept magra épül, a kivételes és az explicit változat csak fordít. A Probe hibainjektálással teszi láthatóvá a strong garanciát és a no-leak feltételt. Az unwinding trace csak megfigyelhető mellékhatással mérhető, és a noexcept határán a kivétel std::terminate-hez vezet. A C ABI adapter minden kivételt elkap, és hibakódot ad vissza; a kivétel átengedése nem megoldás, hanem a szerződés megsértése.