A hibamodell megválasztása ritkán nyelvi kérdés: legtöbbször az dönti
el, hogy a hívó képes-e érdemben reagálni a kudarcra. Egy
szolgáltatásban egyszerre vannak olyan pontok, ahol a „nincs ilyen
kulcs” teljesen normális eset, és olyanok, ahol a hiba okát naplózni és
a hívó felé stabil kóddá kell alakítani. Ez az alfejezet az
std::optional monadikus műveleteire építve mutatja meg,
hogyan lehet a hiányt és a hibát tudatosan szétválasztani, a rétegek
határán információvesztés nélkül konvertálni, végül egy döntési táblával
lezárni a négy hibamodell közötti választást. Az
std::expected monadikus láncait és az
std::optional alapjait korábbi részek tárgyalták (std::expected mélyebben: monadikus hibaláncok
(41.1), optional, variant és visit (31.1),
error_code, system_error és expected (25.4));
itt a hangsúly a kompozíción és a határátlépéseken van.
Az std::optional<T> vagy tartalmaz egy
T típusú objektumot, vagy nem tartalmaz semmit
(disengaged, üres állapot). Fontos, hogy az üres állapothoz
a típus nem rendel okot: az optional nem
mondja meg, miért nincs érték. Előfeltétel: T
objektumtípus, nem referencia és nem void, továbbá
megsemmisíthető, mert az optional a benne tárolt objektum
élettartamát kezeli.
Az std::expected<T, E> ezzel szemben vagy egy
T értéket, vagy egy E hibát tartalmaz, ahol az
E maga is érték, tehát hordozhat okot: hibakódot,
kategóriát, kontextust. Előfeltétel: T nem
unexpected specializáció, E nem referencia és
nem void, és mindkettő megsemmisíthető.
A szemantikai különbség egyetlen kérdésre vezethető vissza: a
hívónak szüksége van-e az okra. Ha a hiány önmagában is teljes
válasz (cache miss, nem kötelező konfigurációs kulcs, opcionális mező),
akkor az optional a helyes típus, mert nem kényszerít hamis
indoklásra. Ha a hiány valamilyen kudarc következménye, és a kudarcot
naplózni, újrapróbálni vagy a hívó felé leképezni kell, akkor az
optional információt veszít. A gyakorlati hiba az szokott
lenni, hogy egy réteg azért ad vissza optional-t, mert
„egyszerűbb”, és ezzel a felette lévő réteg kénytelen kitalálni egy
hibakódot — vagy ami rosszabb, csendben elnyelni a különbséget a „kulcs
hiányzik” és a „kulcs megvan, de értelmezhetetlen” eset között.
A C++23 három láncoló tagfüggvényt ad az optional-hoz;
elérhetőségüket a __cpp_lib_optional >= 202110L
feature-test makró jelzi (a konkrét értéket és a támogatást a
laborprofilban kell ellenőrizni, ez a rész nem állítja, hogy bármely
fordító támogatja).
and_then(f): ha az
optional üres, az eredmény üres
optional<U> és f nem hívódik
meg; ha tartalmaz értéket, az eredmény f(value),
amelynek magának is optional<U> specializációnak kell
lennie. Ez a „ha van érték, akkor folytasd egy újabb esetleg-sikertelen
lépéssel” művelet, és a lánc az első üres ponton megáll
(short-circuit).transform(f): ha üres, üres; ha
tartalmaz, az eredmény optional<U>, ahol
U az f(value) visszatérési típusa. Ha
f maga is optional-t ad vissza, az eredmény
optional<optional<U>> lesz — ez a leggyakoribb
félreértés, mert a lánc nem simul ki. Referenciát visszaadó callable
esetén a kifejezés nem fordul le, mert az optional nem
tárolhat referenciát.or_else(f): csak üres állapotban hívja
meg az argumentum nélküli f-et, és az eredmény típusa
kötelezően optional<T>, tehát a típus nem változhat.
Ez lazán kiértékelt fallback ugyanarra a típusra. Értéket előállító
fallbackhez nem ez való: arra a value_or szolgál, amely
viszont mohó, tehát az argumentuma akkor is
kiértékelődik, ha az optional tartalmaz értéket.Az értékkategória számít: lvalue optional esetén a
callable T&-et, rvalue esetén
T&&-t kap, ami eldönti, hogy a lánc másol-e. A
string_view-t visszaadó láncoknál ez különösen fontos, mert
a nézet nem tartja életben a mögötte lévő puffert.
Az alábbi példa önállóan fordítható. A raw szándékosan
optional-t ad vissza (a hiányzó kulcs nem hiba), a
require_port viszont már expected-et, mert ott
a hívó megkülönbözteti a két kudarcot.
// cfg.cpp — ISO C++23
#include <charconv>
#include <cstdint>
#include <map>
#include <optional>
#include <string>
#include <string_view>
#include <version>
#if !defined(__cpp_lib_optional) || __cpp_lib_optional < 202110L
#error "Az optional monadikus muveletei nem elerhetok ebben a korlabban."
#endif
using Config = std::map<std::string, std::string, std::less<>>;
// A Config tulajdonol; a visszaadott view addig ervenyes, amig a Config el
// es nem modositjak (beszuras torolheti a map elemeit).
std::optional<std::string_view> raw(const Config& c, std::string_view key) {
auto it = c.find(key);
if (it == c.end()) return std::nullopt;
return std::string_view{it->second};
}
std::optional<int> to_int(std::string_view s) {
int v{};
const char* b = s.data();
const char* e = b + s.size();
auto [p, ec] = std::from_chars(b, e, v);
if (ec != std::errc{} || p != e) return std::nullopt;
return v;
}
std::optional<std::uint16_t> as_port(int v) {
if (v < 1 || v > 65535) return std::nullopt;
return static_cast<std::uint16_t>(v);
}
// Lanc: az elso ures lepes megallitja a tovabbi hivasokat.
std::optional<std::uint16_t> listen_port(const Config& c) {
return raw(c, "port").and_then(to_int).and_then(as_port);
}
// Lazy fallback ugyanarra a tipusra; a lambdaba csak ures esetben lep be.
std::optional<std::uint16_t> listen_port_or_default(const Config& c) {
return listen_port(c).or_else([&c] {
return raw(c, "default_port").and_then(to_int).and_then(as_port);
});
}
// HIBA-pelda (nem fordul ugy, ahogy a szerzoje hiszi):
// auto x = raw(c, "port").transform(to_int); // optional<optional<int>>Fordítás és a szükséges feature-ellenőrzés:
# A laborban rogzitett GCC/Clang kiadassal; a -std=c++23 explicit.
g++ -std=c++23 -Wall -Wextra -Wpedantic -O2 -o cfg cfg.cppHa a monadikus műveletek nem elérhetők, a fallback kézi láncolás:
if (auto a = raw(...)) { if (auto b = to_int(*a)) { ... } }.
Ez ugyanazt a viselkedést adja, csak hosszabban.
A határátlépés szabálya: minden konverzió nevezze meg, mit tart meg és mit dob el szándékosan. Amit eldobunk, azt a határon naplózni kell, különben a diagnosztika a legérdekesebb esetben lesz vak.
optional → expected: ez az a pont, ahol a
réteg dönt az okról, tehát itt kell szétválasztani a „nincs” és a „van,
de rossz” esetet.
#include <expected>
enum class DomainError { missing_key, bad_value };
enum class PublicCode : int { ok = 0, invalid_input = 2, unavailable = 14, internal = 70 };
std::expected<std::uint16_t, DomainError> require_port(const Config& c) {
auto r = raw(c, "port");
if (!r) return std::unexpected(DomainError::missing_key);
auto v = to_int(*r).and_then(as_port);
if (!v) return std::unexpected(DomainError::bad_value);
return *v;
}expected → optional: csak akkor, ha a hiba
máshol már gazdára talált (naplózva vagy egy másik csatornán
visszaadva). Itt az ok szándékosan vész el; ezt
kommentben vagy a függvény nevében jelezni kell.
kivétel → expected: a noexcept
határon (C API callback, szál-belépési pont, FFI) kötelező. A
catch (...) és a
catch (const std::exception&) között a különbség az,
hogy az utóbbi üzenetet ad, de az üzenet nem stabil kód — a naplóba
kerül, a visszatérési értékbe nem.
expected → kivétel: a value()
hívása std::bad_expected_access<E>-t dob, ha nincs
érték; ez kizárólag a legkülső rétegben (pl. main)
elfogadható, ahol már van kivételpolitika. Figyelem: az
error() előfeltétele, hogy has_value() hamis
legyen — ellenkező esetben a viselkedés nem definiált, tehát
error()-t csak ágban szabad hívni. Ugyanígy az
optional::value() üres állapotban
std::bad_optional_access-t dob, a *o viszont
nem definiált viselkedés.
error_code: a std::error_code
kategóriafüggő, ezért ec == std::errc::invalid_argument
alakban kell összehasonlítani, nem ec.value() == EINVAL
formában; a POSIX errno rendszerkategórián keresztül
érkezik, és a kettő közötti leképezés nem automatikus.
A ranges algoritmusoknak nincs hibacsatornájuk: ha a
transform egy expected-et ad vissza, a
tartomány elemei expected-ek lesznek, és a pipeline maga
nem áll meg. Két legitim stratégia van, egy pedig anti-minta.
std::ranges::find_if a
!has_value() predikátummal megadja az első hibát, majd a
hívó visszatér vele. Itt a hiba oka megmarad, és a többi elem
kiértékelése elmarad.views::filter a
has_value()-ra, majd a hibák elhagyása. Ez csendben
részleges eredményt ad, ami a legdrágább hibaosztály: a futás „sikeres”,
az adat hiányos.Egy figyelmeztetés a nézetekkel: a
views::transform(parse_one) nézet nem tárolja el az
eredményeket, így minden bejárás újraszámol; mellékhatásos callable-lel
ez meglepő viselkedést ad. Gyűjtéshez ezért egyszeri ciklus a
biztonságos választás.
A naplózás és a nyilvános válasz két külön csatorna. A belső részlet
(kulcsnév, parse-hiba helye, errno) a naplóba kerül, a hívó
stabil, dokumentált kódot kap. A leképezés legyen
teljes: minden belső hibaérképre legyen ág, és a
maradék essen az internal kategóriába, ne forduljon elő,
hogy egy ismeretlen hiba „sikerként” szivárog tovább. Az
újrapróbálhatóságot (retryable) is a leképezés döntse el,
ne a hívó találgassa az üzenetszövegből.
struct Failure {
PublicCode code;
bool retryable;
std::string detail; // csak naploba; a publikus valaszba nem kerul
};
Failure to_public(DomainError e, std::string_view key) {
switch (e) {
case DomainError::missing_key:
return {PublicCode::invalid_input, false, "hianyzo kulcs: " + std::string{key}};
case DomainError::bad_value:
return {PublicCode::invalid_input, false, "ertelmezhetetlen ertek: " + std::string{key}};
}
return {PublicCode::internal, false, "ismeretlen DomainError"};
}A missing_key itt azért lesz invalid_input,
mert a konfigurációs rétegben egy kötelező kulcs hiánya a kérés hibája —
ez rétegdöntés, nem nyelvi tény. A naplózás pontosan egyszer, a határon
történjen; a lánc belsejében naplózott és továbbadott hiba duplikált
bejegyzéseket és torz metrikákat ad.
| Modell | Mit fejez ki | Mikor válaszd | Határviselkedés | Információvesztés |
|---|---|---|---|---|
optional<T> |
lehet, hogy nincs érték; ok nélkül | opcionális mező, cache miss, „nem található” | határon expected-dé kell alakítani, ha ok kell |
az ok teljes egészében elvész |
expected<T,E> |
érték vagy tipizált hiba | domain- és szolgáltatáslogika, tesztelhető hibautak | E-t publikus kódra képezni; value() csak a
legkülső rétegben |
csak ha szándékosan szűkítünk |
| kivétel | kivételes, ritka kudarc, unwindinggal | konstruktor, allocation, mély hívási lánc | noexcept határon expected-dé
alakítani |
az üzenet nem stabil kód |
std::error_code |
OS/könyvtár hibakód kategóriával | C API, POSIX, FFI, visszafelé kompatibilis interfész | saját kódra leképezni; kategóriát őrizni | kategória nélkül igen, azzal kevés |
A tábla olvasata: a négy modell nem egymás versenytársa egyetlen
rétegen belül, hanem rétegenkénti választás. A tipikus helyes
architektúra: error_code/errno a legalul,
expected a domain rétegben, optional a valóban
opcionális lekérdezéseknél, kivétel pedig csak ott, ahol a kudarc
tényleg kivételes és van felső szintű kezelő.
transform olyan callable-lel, amely
optional-t ad:
optional<optional<U>> lesz, a lánc nem simul
ki. Javítás: and_then.or_else-t használnak érték előállítására vagy
típusváltásra; ez nem fordul le, mert a visszatérési típusnak
optional<T>-nek kell lennie. Javítás:
transform + value_or, illetve kézi ág.value_or-t lazának hiszik; az argumentum mohón
kiértékelődik. Javítás: or_else nullary lambdával.optional-on *o (nem definiált
viselkedés) vagy expected::error() értékkel rendelkező
objektumon (előfeltétel-sértés). Javítás: has_value() ág,
illetve value() ott, ahol a kivétel elfogadható.string_view-t tartalmazó optional-t él
tovább, mint a mögötte lévő Config: a lánc helyes, a nézet
dangling.Az optional a hiányt fejezi ki, nem a kudarcot; a kettő
szétválasztása API-tervezési döntés, amelyet a hívó reagálóképessége
vezérel. Az and_then láncol, a transform
leképez, az or_else lazán pótol — a rossz pár választása
vagy nem fordul le, vagy néma típusbeágyazást okoz. A réteghatáron
minden konverzióhoz tartozik egy mondat arról, mi vész el, és ami
elvész, azt naplózni kell. A ranges pipeline-ban a hibát vagy az első
előfordulásnál állítjuk meg, vagy tudatosan gyűjtjük; a filterezés
csendben részleges eredményt ad. A szolgáltatásszintű leképezés teljes,
stabil és egy helyen naplóz.
optional<std::string>-et ad vissza arra, hogy a kulcs
hiányzik, az érték viszont érvénytelen. Sorolj fel két helyet, ahol ez
információvesztést okoz, és fogalmazd meg, milyen típusra
cserélnéd.std::optional<int>-et adó láncot, amely egy
Config-ból kiolvassa a timeout_ms kulcsot,
egész számmá alakítja, majd 1 és 60000 közé szorítja úgy, hogy a
tartományon kívüli érték üres eredményt adjon. Készíts
expected<int, DomainError> változatot is, ahol a
„hiányzik” és a „rossz” megkülönböztethető.auto v = raw(c, "port").transform(to_int); if (v) use(*v);Failure-t visszaadó
leképezést egy expected<T, std::error_code>-et adó
Alsó függvényhez, ahol a std::errc::timed_out
újrapróbálható, minden más nem az, és a belső részlet csak a naplóba
kerül.1. Először ott, ahol a hívó a felhasználónak
üzenetet kell hogy adjon: „hiányzó kulcs” és „érvénytelen érték”
ugyanarra az ágra fut, így a hibaüzenet félrevezető. Másodszor ott, ahol
a hiba alapján újrapróbálna (pl. a fájl még nem íródott ki): az üres
optional nem hordozza, hogy érdemes-e újrapróbálni. Csere:
std::expected<std::string, ConfigError>, ahol
ConfigError legalább missing_key és
invalid_value értéket vesz fel. Ellenőrzés: a
switch a hibára teljes, és nincs default,
amely elnyelné az új értékeket.
2. Az optional lánc:
std::optional<int> timeout_ms(const Config& c) {
return raw(c, "timeout_ms").and_then(to_int).and_then([](int v) -> std::optional<int> {
if (v < 1 || v > 60000) return std::nullopt;
return v;
});
}Az expected változatban a raw hiányát és a
to_int/tartomány-hibát külön ágra kell bontani, mert a lánc
csak az egyik okot tudná visszaadni:
std::expected<int, DomainError> timeout_ms_checked(const Config& c) {
auto r = raw(c, "timeout_ms");
if (!r) return std::unexpected(DomainError::missing_key);
auto v = to_int(*r);
if (!v || *v < 1 || *v > 60000) return std::unexpected(DomainError::bad_value);
return *v;
}Ellenőrzés: három bemenet (hiányzó kulcs, "abc",
"70000") rendre missing_key,
bad_value, bad_value; a "443"
értékre mindkét változat 443-at ad. A határértékek
(1, 60000) elfogadottak, a 0 és
60001 nem.
3. A hiba az, hogy a to_int
std::optional<int>-et ad vissza, a
transform ezt becsomagolja, így a v típusa
std::optional<std::optional<int>>. A tünet: a
if (v) igaz, ha a külső optional tartalmaz —
azaz ha a raw értéket adott —, miközben a belső üres lehet;
a *v egy üres optional<int>-et ad, amit
a use már nem tud értékként kezelni, vagy némán
0-t/nullopt-ot továbbít. Javítás:
and_then(to_int), ekkor a lánc az első üres ponton megáll,
és v típusa std::optional<int>.
Ellenőrzés: a decltype próba
(static_assert(std::is_same_v<decltype(v), std::optional<int>>))
és a "abc" bemenetre kapott üres eredmény.
4. A leképezés:
Failure to_public(const std::error_code& ec) {
if (ec == std::errc::timed_out)
return {PublicCode::unavailable, true, "also reteg: idotullepes"};
if (ec == std::errc::invalid_argument)
return {PublicCode::invalid_input, false, "also reteg: ervenytelen argumentum"};
return {PublicCode::internal, false, "also reteg: " + ec.message()};
}Az indoklás: a std::errc összehasonlítás
kategóriaérzékeny, ezért ec == std::errc::... alak kell; a
detail a belső kategóriaüzenetet is tartalmazhatja, mert
nem kerül a publikus válaszba; az ismeretlen kód internal
lesz, nem „ok”. Ellenőrzés: a retryable csak a
timed_out ágon igaz; a naplózás a határon egyszer történik,
és a publikus struktúrában nincs std::string mező, amely a
belső okot kiszivárogtatná.