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.
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:
std::error_code, std::optional, állapotflag).
Feltétel: a művelet minden tranzitívan hívott művelete – allokáció,
másolás, összehasonlítás, destruktorok – is no-throw, vagy a hiba nem
kerül át a határon.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.
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:
swap vagy egy
mutató/unique_ptr cseréje.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.
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.
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.
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.
clear()-rel kezd. A törlés után minden hiba csak
részleges állapotot hagy; a helyes sorrend a commit utáni
takarítás.swap no-throw voltát feltételezzük.
Nem egyenlő allocatoroknál a konténer-swap viselkedése
eltérhet; a saját típusunkban a tag-swap-et
noexcept-ként deklaráljuk és tartsuk is be.noexcept-ot a no-throw
garanciával. A jelölés önmagában nem tesz egy műveletet nem
dobóvá; ha mégis dob, a program leáll.terminate kockázatát növeli a
hibaágon.std::terminate; a cleanup hibáját explicit művelettel kell
kitenni.(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!
(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.
(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!
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.
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.
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.
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.