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.
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 , sikeres kimenetek halmaza , hibajelzések halmaza . Két implementáció, (kivételes) és (explicit eredmény), funkcionálisan ekvivalens, ha minden bemenetre:
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_labA 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.
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 labFigyeljü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.
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 labA 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
#endifEz 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.
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 labBizonyí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.
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.
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.
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.noexcept mag, a wrapperek csak
fordítanak.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.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.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.-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.extern "C"
wrappert a make függvényhez, amely minden kivételt elkap,
és a handle értékét egy int* kimeneten adja
vissza.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.
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.