25.2 No-throw, strong és basic exception guarantee

A kivételkezelés nem önmagában a try/catch szintaxisa, hanem egy szerződés arról, hogy egy művelet meghiúsulása esetén milyen állapotban marad a program. Egy DevOps-környezetben futó komponensnél ez közvetlenül üzemeltetési kérdés: ha egy konfigurációfrissítés vagy egy kötegelt állapotátmenet félúton elhasal, attól még a szolgáltatásnak konzisztens állapotban kell maradnia, és a hiba tényét tovább kell tudni adni. Ezért a garanciákat nem elvont minőségi jelzőként, hanem az adott típus invariánsához kötött, dokumentált ígéretként kezeljük. Az alábbiakban a throw, catch és stack unwinding mechanizmusára és a mély másolás, értéktípus és exception guarantee alapfogalmaira építünk; a hibakód-alapú alternatívák részletes tárgyalása az error_code, system_error és expected részre tartozik.

25.2.1 A garanciák és az invariáns kapcsolata

Minden osztálynak van egy invariánsa (invariant): olyan állítás, amely a nyilvános műveletek között minden pillanatban igaz az objektumra. Például: „a költségvetés sorai nem tartalmaznak negatív egyenleget”, „a név és a hozzá tartozó szabálylista ugyanahhoz a verzióhoz tartozik”. Egy művelet előfeltétele (precondition) az, amit a hívónak biztosítania kell; utófeltétele (postcondition) az, ami siker esetén teljesül. Az exception guarantee ezekhez képest a hibaág szerződése.

A három szint pontosabban:

A szintek viszonya: az erős garancia implikálja az alapot (hiszen rollback esetén is konzisztens marad az állapot). A no-throw tulajdonképpen egy másik dimenzió: nem azt mondja meg, mi történik hiba esetén, hanem hogy hiba nem távozhat kivételként. Egy no-throw művelet a kivétel szempontjából triviálisan kielégíti az erős garanciát, mert nincs hibaág – gyakorlatban mégis külön szerződésként dokumentáljuk, mert a hívó kód és a noexcept alapú optimalizációk (például konténer-áthelyezés) ezt használják. Fontos: a noexcept kulcsszó nem hozza létre a no-throw garanciát, csak kikényszeríti; ha a jelölt művelet mégis dob, a program std::terminate-mel leáll. A garancia dokumentált tulajdonság, a noexcept annak nyelvi deklarációja.

25.2.2 Tranzakcionális frissítés: commit és rollback

Az erős garancia legfontosabb mintája a copy-and-swap (másolj, majd cserélj), illetve általánosabban a „dolgozz ideiglenes állapoton, és a végén commitálj” elv. A műveletet három fázisra bontjuk:

  1. Érvényesítés és előkészítés – minden olyan lépés, ami dobhat (validáció, allokáció, másolás). Itt még semmi nem változik a célobjektumon.
  2. Commit – a nem dobó lépés, ami az új állapotot láthatóvá teszi. Ez tipikusan egy swap vagy egy mutató/unique_ptr cseréje.
  3. Takarítás – a régi állapot felszabadítása, ami szintén nem dobhat (destruktorok).

A tranzakcionális frissítés kulcsa a sorrend: a dobható műveletek megelőzik a nem dobó commitot. Ha fordítva teszünk, a hibaág már módosított állapotot talál, és csak alap garanciát adhatunk.

Tekintsünk egy DevOps-hoz illő példát: egy Budget osztály tokensorokat tart, és a teljes sorozatot kell lecserélni egy új konfigurációra. A rossz változat előbb töröl, aztán újratölt:

// Rossz: csak alap garancia — a részben feltöltött vektor érvényes, de nem a régi
void replace_all_basic(const std::vector<Bucket>& next) {
    buckets_.clear();                                     // eredeti elveszett
    buckets_.insert(buckets_.end(), next.begin(), next.end()); // dobhat
}

Ha az insert allokációs hibával vagy a Bucket másoló konstruktorának dobásával elhasal, az objektum részben feltöltött állapotban marad. A helyes változat a paramétert érték szerint veszi át (a másolás a hívás helyén, a függvénytörzs előtt történik, tehát hibája nem érinti a célt), majd egy nem dobó cserével commitál:

// budget.cpp — g++ -std=c++20 -Wall -Wextra -Wpedantic -g -o budget budget.cpp
#include <algorithm>
#include <cassert>
#include <stdexcept>
#include <vector>

struct Bucket {
    int id{};
    int tokens{};
    friend bool operator==(const Bucket&, const Bucket&) = default;
};

class Budget {
public:
    // Erős garancia: hiba esetén *this változatlan.
    // Előfeltétel: Bucket másolható/mozgatható. A swap előfeltétele (egyenlő
    // allocatorok) itt mindig teljesül, mert a std::allocator mindig egyenlő.
    void replace_all(std::vector<Bucket> next) {
        validate(next);        // dobhat — még semmit nem módosítottunk
        buckets_.swap(next);   // nem dobhat (std::allocator, mindig egyenlő)
    }

    const std::vector<Bucket>& buckets() const noexcept { return buckets_; }

    bool invariant() const noexcept {
        return std::all_of(buckets_.begin(), buckets_.end(),
                           [](const Bucket& b) { return b.tokens >= 0; });
    }

private:
    static void validate(const std::vector<Bucket>& v) {
        for (const auto& b : v)
            if (b.tokens < 0) throw std::invalid_argument("negatív token");
    }
    std::vector<Bucket> buckets_{};
};

int main() {
    Budget b;
    b.replace_all({{1, 10}, {2, 20}});
    assert(b.invariant());

    const auto before = b.buckets();
    try {
        b.replace_all({{3, 5}, {4, -1}});   // validáció dob
    } catch (const std::invalid_argument&) {
        assert(b.buckets() == before);     // erős garancia ellenőrzése
    }
}

A commit egyetlen swap. A régi vektor tartalma a paraméterbe kerül, és a függvény kilépésekor a destruktora felszabadítja – ez a rollback „ingyenes” része. A validate és a paraméter másolása mind a commit előtt fut, tehát bármelyikük hibája esetén a buckets_ érintetlen.

A swap-ről két külön tényt kell rögzíteni. (1) Kivételspecifikáció: a std::vector::swap C++17 óta noexcept, ha az allocator propagate_on_container_swap vagy is_always_equal tulajdonságú — a std::allocator ilyen. (2) Előfeltétel: ha az allocator nem propagálódik cserénél, és a két vektor allocatora nem egyenlő, a swap hívása nem definiált viselkedés ([container.reqmts]). Ez nem „dobhat”, hanem érvénytelen hívás; ilyen allocator-párra a könyv nem ajánl swap-et. A copy-and-swap bizonyításához tehát hozzátartozik egy harmadik feltétel: a commit swap két oldalának allocatora egyenlő. Állapotú allocatornál (például std::pmr::polymorphic_allocator, amely nem propagálódik) ezt úgy biztosítjuk, hogy az ideiglenes másolatot a cél allocatorával építjük, akármilyen allocatorral érkezett a bemenet:

// budget_alloc.cpp — C++20: copy-and-swap állapotú (pmr) allocatorral
#include <algorithm>
#include <cassert>
#include <memory_resource>
#include <stdexcept>
#include <vector>

struct Bucket {
    int id{};
    int tokens{};
    friend bool operator==(const Bucket&, const Bucket&) = default;
};

class PmrBudget {
public:
    explicit PmrBudget(std::pmr::memory_resource* r) : buckets_(r) {}

    // Erős garancia bármilyen forrásallocatorral érkező bemenetre.
    void replace_all(const std::pmr::vector<Bucket>& next) {
        // A másolat a SAJÁT allocatorunkkal készül: így a swap előfeltétele
        // (egyenlő allocatorok) teljesül, akármi is a next allocatora.
        std::pmr::vector<Bucket> tmp(next.begin(), next.end(),
                                     buckets_.get_allocator());   // dobhat
        validate(tmp);                                            // dobhat
        buckets_.swap(tmp);                                       // noexcept
    }
    const std::pmr::vector<Bucket>& buckets() const noexcept { return buckets_; }

private:
    static void validate(const std::pmr::vector<Bucket>& v) {
        for (const auto& b : v)
            if (b.tokens < 0) throw std::invalid_argument("negatív token");
    }
    std::pmr::vector<Bucket> buckets_;
};

int main() {
    std::pmr::monotonic_buffer_resource own, other;
    PmrBudget b(&own);
    std::pmr::vector<Bucket> in({{1, 10}, {2, 20}}, &other);   // MÁS resource
    b.replace_all(in);
    assert(b.buckets().get_allocator().resource() == &own);
    assert(b.buckets() == in);

    const std::pmr::vector<Bucket> before = b.buckets();
    std::pmr::vector<Bucket> bad({{3, 5}, {4, -1}}, &other);
    try { b.replace_all(bad); } catch (const std::invalid_argument&) {}
    assert(b.buckets() == before);
    static_assert(!std::allocator_traits<std::pmr::polymorphic_allocator<Bucket>>
                      ::propagate_on_container_swap::value);
}

Buildjegyzőkönyv (lab-gcc profil). g++ -std=c++20 -Wall -Wextra -Wpedantic -g -fsanitize=address,undefined budget_alloc.cpp figyelmeztetés nélkül fordul, a futás egyik assert-et sem sérti, ASan/UBSan nem jelez: a másik memóriaforrásból érkező bemenet a saját forrásba másolódott, a hibás bemenet után az állapot változatlan, és a static_assert igazolja, hogy a polymorphic_allocator nem propagálódik cserénél. A másolás a commit előtti, dobható fázisban van, így az erős garancia megmarad.

25.2.3 A garancia függése a típustól és az allocatortól

Az erős garancia nem kizárólag a saját kódunkon múlik: a konténerműveletek garanciája a bennük tárolt típus és az allocator tulajdonságaitól függ. A std::vector::reserve(n) tipikusan erős: ha az allokáció std::bad_alloc-kal elhasal, a vektor mérete és tartalma változatlan. A push_back esetén a szabvány a garanciát feltételekhez köti: ha a kivétel nem a T másoló/mozgató konstruktorából vagy értékadásából, illetve bemeneti iterátor műveletéből származik, akkor nincs hatás; egyetlen elem végére illesztésekor akkor erős a garancia, ha T másolhatóan beszúrható, vagy a mozgató konstruktora nem dob. Ha viszont egy nem másolható T mozgató konstruktora dob, a szabvány az effekteket nem specifikáltnak hagyja – ilyenkor még az alap garancia sem garantált a konténer egészére nézve. A pontos megfogalmazás a szabvány adott verziójában ellenőrizendő, mert a megengedett esetek köre verzióról verzióra finomodott; a mérnöki következtetés viszont stabil: dokumentált garanciát csak akkor ígérj, ha a benne részt vevő típusok és az allocator tulajdonságai ismertek.

Külön figyelmet érdemel a kivételobjektum létrehozása. A throw operandusából inicializálódó kivételobjektumot a implementáció másolhatja, és a másolás elhagyása megengedett, de nem kötelező. Ha a kivételtípus másoló konstruktora dob, a folyamat leáll (std::terminate), mert egy kivétel dobása közben nem dobódhat újabb kivétel. Az allokáció meghiúsulására sincs hordozható, garantált viselkedés; a gyakorlatban az implementációk vészhelyzeti tárolóval vagy leállással kezelik. Következmény: a kivételtípus legyen kicsi, olcsón és lehetőleg nem dobóan másolható, és ne végezzen allokációt a konstruktorában. Nagy diagnosztikai payload esetén a részleteket ne a kivételben tároljuk közvetlenül, hanem egy külön, a hívó által kezelt csatornán.

25.2.4 Destruktor, cleanup és explicit hibajelzés

A destruktorok C++11 óta implicit módon noexcept, és ha egy kivétel futása közben (stack unwinding alatt) újabb kivétel lépne ki egy destruktorból, a program leáll. Ez helyes: a destruktornak nem szabad hibát kivétellel jeleznie. A gyakorlati minta, hogy a tisztítás, ami meghiúsulhat – close, flush, fsync, hálózati bontás –, explicit műveletként jelenik meg, amelynek visszatérési értéke vagy std::error_code-ja van; a destruktor csak best-effort takarít, és a hibát nem dobja tovább. Így a hívó eldöntheti, hogy a hibát naplózza, újrapróbálja, vagy a folyamatot szabályosan leállítja. Ez a fajta „kétfázisú” cleanup (explicit commit/close, majd csendben futó destruktor) a RAII mintát egészíti ki, nem helyettesíti. A részletes hibakód-API-k tárgyalása az error_code, system_error és expected részre tartozik.

25.2.5 Hibainjektálás és állapot-snapshot tesztelés

Az erős garancia állítás, ezért tesztelni kell. A leghatékonyabb módszer a hibainjektálás: olyan segédtípust használunk, amely a másolás/konstrukció adott sorszámú előfordulásakor dob, majd minden lehetséges injektálási pontra lefuttatjuk a műveletet. Minden esetben rögzítjük az állapotot a művelet előtt, elkapjuk a kivételt, és összehasonlítjuk a „snapshotot”. Az erős garancia teljesül, ha az összehasonlítás minden injektálási ponton egyenlőséget ad, és az invariáns ellenőrzése is igaz.

// fault_inject.cpp — ugyanazzal a buildparanccsal fordítható
#include <cstdio>
#include <stdexcept>
#include <string>
#include <vector>

struct CopyFault {
    static inline int countdown = -1;  // -1: nincs injektálás
    std::string host;

    explicit CopyFault(std::string h) : host(std::move(h)) {}
    CopyFault(const CopyFault& other) : host(other.host) {
        if (countdown >= 0 && countdown-- == 0)
            throw std::runtime_error("injektált másolási hiba");
    }
    CopyFault& operator=(const CopyFault&) = default;
    friend bool operator==(const CopyFault&, const CopyFault&) = default;
};

// A teszt: minden k-ra injektálunk, és ellenőrizzük a rollbacket.
template <class Op>
void check_strong(Op op, int max_steps) {
    for (int k = 0; k < max_steps; ++k) {
        CopyFault::countdown = k;
        try { op(); } catch (const std::runtime_error&) {}
        CopyFault::countdown = -1;
        // itt következne a snapshot és az invariáns összehasonlítása
    }
}

A diagnosztikai érték abban rejlik, hogy a teszt megmutatja, melyik injektálási ponton sérül a garancia. Az alap garanciát nyújtó replace_all_basic esetén jellemzően a középső pontokon bukik meg a snapshot-egyezés, míg a copy-and-swap változat minden ponton átmegy – feltéve, hogy a commit valóban nem dob. A snapshot legyen teljes: a megfigyelhető állapot minden összetevője (méret, tartalom, jelzők) szerepeljen benne, különben a teszt hamis biztonságérzetet ad. Érdemes a noexcept műveletekre külön, fordítási idejű ellenőrzést is tenni (static_assert(noexcept(...))), és a szándékosan leálló eseteket haláleset-teszttel (death test) lefedni.

25.2.6 Gyakori hibák

25.2.7 Feladatok

  1. (magyarázó) Egy std::vector<std::string> v; objektumon hívott v.push_back(s) milyen garanciát ad? Milyen feltétel mellett erős? Hasonlítsd össze a v.reserve(n) garanciájával, és fogalmazd meg, miért fontos az eltérés egy hibainjektáló tesztben!

  2. (kódolási) Készíts Config osztályt, amely egy std::string name_ és egy std::vector<Rule> rules_ tagot tart, és a kettőt együtt kell frissíteni: a name_ és a rules_ mindig ugyanahhoz a verzióhoz tartozzon. Valósítsd meg a void publish(Config next) műveletet erős garanciával, valamint az invariant() ellenőrzést, amely megköveteli, hogy a név nem üres, és a szabályok verziószáma megegyezik a névben jelzett verzióval.

  3. (diagnosztikai) A fejezet Budget osztályához írj hibainjektáló tesztet, amely k = 0..N értékekre a replace_all minden másolási pontján dob, és minden esetben ellenőrzi a snapshot-egyezést és az invariánst. Mutasd meg, melyik k értéknél bukna el a replace_all_basic, és indokold, miért!

25.2.8 Megoldások

25.2.8.1 1. megoldás

A push_back a szabvány szerint akkor ad erős garanciát egyetlen elem végére illesztésekor, ha a T másolhatóan beszúrható, vagy a mozgató konstruktora nem dob; ha a kivétel nem a T másoló/mozgató műveletéből vagy bemeneti iterátor műveletéből ered, szintén nincs hatás. Nem másolható, dobó mozgatóval rendelkező T esetén az effektek nem specifikáltak lehetnek. A reserve ezzel szemben tipikusan erős: allokációs hiba esetén a vektor mérete és tartalma változatlan. A tesztben ezért a push_back-et csak olyan típussal érdemes injektálni, amelynek a másolása dobhat, de a mozgatása nem – különben a „bukás” nem a mi kódunk, hanem a szabvány által megengedett bizonytalanság következménye. Ellenőrzési feltétel: a static_assert(std::is_nothrow_move_constructible_v<T>) teljesüljön, és a snapshot a méret mellett a tartalmat is hasonlítsa.

25.2.8.2 2. megoldás

A két tag összehangolt frissítését egyetlen State értéktípusba vonjuk össze, és azt cseréljük:

struct Rule { int id{}; bool enabled{}; friend bool operator==(const Rule&, const Rule&) = default; };

class Config {
public:
    struct State {
        std::string name;
        std::vector<Rule> rules;
    };

    // Erős garancia: a paraméter másolása a hívás helyén történik,
    // a commit egyetlen, nem dobó swap.
    void publish(State next) {
        validate(next);          // dobhat
        state_.swap(next);       // nem dobhat: std::string és std::vector swap
    }

    bool invariant() const noexcept {
        if (state_.name.empty()) return false;
        const int ver = version_of(state_.name);      // pl. "v3" -> 3
        for (const auto& r : state_.rules)
            if (r.id / 1000 != ver) return false;     // példa-összefüggés
        return true;
    }

private:
    static void validate(const State& s) {
        if (s.name.empty()) throw std::invalid_argument("üres név");
        if (s.rules.size() > 10'000) throw std::length_error("túl sok szabály");
    }
    static int version_of(const std::string&);
    State state_{};
};

Az indoklás: a State értékszemantikájú, a publish paramétere érték szerint érkezik, így a másolás és a validáció még a commit előtt lezajlik; a state_.swap(next) mindkét tagot egy lépésben cseréli, és nem dob. A régi állapot a paraméterrel együtt megsemmisül. Így a name_ és a rules_ soha nem kerülhet ellentmondásos kombinációba. Ellenőrzési feltétel: minden kivételes ágon (validate dob, másolás dob) az invariant() igaz marad, és a snapshot egyezik; a publish deklarációja nem noexcept, mert a validáció szándékosan dob – a no-throw garancia csak a commit lépésre vonatkozik.

25.2.8.3 3. megoldás

A teszt vázlata: venni egy Budget példányt ismert tartalommal, elmenteni a snapshotot, majd k = 0-tól növelve beállítani a countdown értékét, és minden k-ra meghívni a replace_all-t egy legalább k másolást igénylő bemenettel. A kivételt elkapva ellenőrizni kell, hogy a buckets() egyenlő a mentett snapshotnal, és hogy invariant() igaz. Az injektálás akkor fedi le a teljes műveletet, ha a bemenet hossza nagyobb, mint a tesztelt k maximuma; különben a teszt „nem ér el” bizonyos pontokat, és hamis biztonságérzetet ad.

A replace_all_basic a k = 1 (vagy nagyobb) értéknél bukik el: a clear() után az insert már módosított állapoton dolgozik, így a hiba után a vektor részben feltöltött, a snapshot-egyezés megsérül. A copy-and-swap változat minden k-ra átmegy, mert a dobás vagy a paraméter másolásakor (a törzs előtt), vagy a validate-ben történik, mindkettő a commit előtt – a commitáló swap pedig nem dob. Ellenőrzési feltétel: a teszt minden k-ra lefusson, a kivételek elkapása után az állapot és az invariáns is egyezzen, és a replace_all dokumentált szerződése tartalmazza a Bucket másolhatóságát, valamint az allocator-egyenlőség feltételét.

25.2.9 Összefoglaló

Az exception guarantee az invariánshoz kötött, műveletenként dokumentált hibaág-szerződés: a no-throw a hiba kizárását, az erős garancia a teljes visszagörgetést, az alap garancia a konzisztens, de nem specifikált állapotot ígéri. Az erős garancia receptje a dobható előkészítés és a nem dobó commit szétválasztása (copy-and-swap, illetve State-csere), ahol a kritikus pont a swap no-throw volta és a típus-, illetve allocatorfüggő konténergaranciák ismerete. A kivételobjektum létrehozása és másolása maga is hibapont, ezért a kivételtípus legyen kicsi és nem dobóan másolható; a destruktor soha ne jelezzen hibát, a cleanupot explicit művelet végezze. A garanciát hibainjektálással és teljes állapot-snapshot összehasonlítással kell igazolni – a fokozatos feladatok pont ezt a három réteget (döntés, tervezés, diagnosztika) gyakoroltatják.