41.2 optional monadikus műveletei és hibamodellek kompozíciója

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.

41.2.1 Hiányzó érték és hiba szemantikai különbsége

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.

41.2.2 Az optional monadikus műveletei

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).

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.cpp

Ha 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.

41.2.3 Konverzió optional, expected és kivétel között a rétegek határán

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.

41.2.4 Hibák gyűjtése ranges pipeline-ban

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.

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.

41.2.5 Szolgáltatásszintű hibakód-leképezés naplózáshoz

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.

41.2.6 Döntési tábla a négy hibamodellre

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ő.

41.2.7 Gyakori hibák

41.2.8 Összefoglaló

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.

41.2.9 Feladatok

  1. Magyarázó. Egy konfigurációs olvasó 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.
  2. Kódolási. Írj 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ő.
  3. Diagnosztikai. Az alábbi lánc nem azt teszi, amit a szerzője állít. Nevezd meg a hibát, írd le a tünetet, és javítsd: auto v = raw(c, "port").transform(to_int); if (v) use(*v);
  4. Tervezési. Adj 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.

41.2.10 Megoldások

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á.