41.1 std::expected mélyebben: monadikus hibaláncok

Egy szolgáltatás hibamodellje akkor auditálható, ha a hibaáramlás egyetlen kifejezésláncban olvasható, és minden ponton pontosan tudjuk, milyen típusú hiba léphet ki belőle. A korábbi error_code, system_error és expected (25.4) részben az error_code, system_error és expected alapeseteit rögzítettük; itt azt építjük tovább, ami egy termelési API-t megkülönböztet egy tankóldától: a rétegzett, monadikus hibaláncot, a hibaidőtípus rétegenkénti megválasztását és a kivételhatár szerződését. A Hibakezelési és exception safety labor (25.5) laborra és a Hibakód, errno és hibaadatok élettartama (11.3) errno-élettartam-szabályára támaszkodunk.

Források. A szabályok forrása a forrásjegyzék [C++23] tétele ([expected]); a std::expected maga a P0323, a monadikus függvények a P2505 javaslatból származnak. A fordító- és könyvtártámogatást feature-test makróval és fordítási próbával döntjük el. A lab-gcc profil libstdc++-a __cpp_lib_expected-et 202211L értékkel definiálja, vagyis a monadikus felületet is nyújtja.

41.1.1 Az expected alapszerződése és állapotai

A std::expected<T, E> egy tagszerű tárolású összegtípus: vagy egy T értéket, vagy egy E hibát tartalmaz, soha nem mindkettőt, és az értéket nem a szabad tárból allokálja. A std::unexpected<E> csomagoló azért kell, mert expected<std::error_code, std::error_code> esetén a puszta return ec; kétértelmű lenne: nem tudni, értéket vagy hibát adunk-e vissza. A std::unexpected{ec} egyértelműen a hibaágat jelöli.

Előfeltételek: T nem referencia- és nem függvénytípus, nem unexpected-specializáció, és lehet void (expected<void, E> a „siker, de nincs érték” esetre). E objektumtípus, nem referencia, nem függvénytípus, nem unexpected-specializáció, és nem void. A pontos szabványos megfogalmazás helyét a munkatervezetben kell visszakeresni; itt a szerződés tartalma a lényeg.

Két megfigyelhető állapot van: az objektum vagy értéket, vagy hibát tárol, és soha nincs egyik nélkül sem (never-empty). A std::variant-tal ellentétben az expected-nek nincs „valueless by exception” állapota. Az értékadó műveletek ezt úgy biztosítják, hogy csak akkor érhetők el, ha a típusok megfelelnek a követelményeiknek. Az állapotot váltó értékadás (például hibából értékbe) megköveteli, hogy a T vagy az E legalább egyike nem dobó módon mozgatható legyen; ennek hiányában az értékadó operátor nem vesz részt a túlterhelés-feloldásban, és a kód nem fordul le. Kivétel esetén a művelet a régi állapotot őrzi meg (erős garancia), nem egy harmadikat.

A hozzáférők szerződése külön-külön:

Hozzáférő Előfeltétel Ha az előfeltétel nem teljesül
has_value(), operator bool nincs —
operator*, operator-> has_value() igaz nem definiált viselkedés
value() nincs hibaállapotban std::bad_expected_access<E> kivételt dob (az error() másolatával)
error() has_value() hamis nem definiált viselkedés
value_or(v) nincs hibaállapotban a v-t adja
// expected_states.cpp — C++23: két állapot, value() és a dereferálás
#include <cstdio>
#include <expected>
#include <string>

int main() {
    std::expected<int, std::string> ok = 42;
    std::expected<int, std::string> bad = std::unexpected(std::string("parse"));

    std::printf("ok: has_value=%d *ok=%d value=%d\n", ok.has_value(), *ok, ok.value());
    std::printf("bad: has_value=%d error=%s\n", bad.has_value(), bad.error().c_str());

    try {
        (void)bad.value();                     // hibaállapotban: kivétel, nem UB
    } catch (const std::bad_expected_access<std::string>& e) {
        std::printf("value() dobott: bad_expected_access, error=%s\n", e.error().c_str());
    }

    bad = 7;                                   // értékadás: hibából értékállapotba
    std::printf("bad = 7: has_value=%d\n", bad.has_value());
}

Buildjegyzőkönyv (lab-gcc profil, -std=c++23). A kimenet: ok: has_value=1 *ok=42 value=42, bad: has_value=0 error=parse, value() dobott: bad_expected_access, error=parse, bad = 7: has_value=1.

41.1.2 A monadikus műveletek pontos típusai

Négy művelet alkotja a C++23-as monadikus felületet: and_then, or_else, transform és transform_error. Mind a négyet a P2505 adta a P0323-ban szabványosított std::expected-hez. A két rész feature-test értéke eltér: __cpp_lib_expected >= 202202L csak az alap típust jelzi, a monadikus felülethez >= 202211L kell. Egy korábbi könyvtárkiadás tehát nyújthatja az expected-et monadikus függvények nélkül; ilyenkor a lánc helyére kézzel írt if (r) ... else ... elágazás lép.

Legyen F hívható. Ekkor, &-minősítőn (std::invoke(f, value) szemantikával):

expected<void, E> esetén az and_then és a transform hívhatója nulla paramétert kap. Ez a leggyakoribb fordítási hibaforrás, amikor egy expected<T,E>-re írt lambdát expected<void,E>-re használunk.

A rövidzárlat táblázata: értékágon and_then/transform fut, or_else/transform_error nem; hibaágon fordítva. Értékágon a hiba típusa sosem változik meg and_then/transform alatt, ezért egy láncon belül E végig azonos kell legyen — a hibakonverzió helye kizárólag a transform_error (és az or_else hibaidőtípus-váltása).

41.1.3 Típuslevezetés egy láncon

Legyen a : std::expected<int, E>, f : int → std::expected<double, E>, g : double → std::string, h : E → std::error_code, k : std::error_code → DomainErr.

Kifejezés Eredmény típusa Megjegyzés
a expected<int, E> kiindulás
a.and_then(f) expected<double, E> hiba típusa változatlan
.transform(g) expected<string, E> érték transzformálás
.transform_error(h) expected<string, error_code> itt vált hibaidőtípust a lánc
.transform_error(k) expected<string, DomainErr> rétegváltás a modulhatáron
a.transform(f) expected<expected<double,E>, E> hiba: transform nem lapít

A táblázat tanulsága: a láncot úgy kell felépíteni, hogy a hibaidőtípus-váltás egyetlen, dokumentált ponton történjen, és a lánc belsejében and_then-t használjunk, amikor a hívott függvény maga is expected-et ad.

41.1.4 Kidolgozott példa: errno-tól a doménhibáig

A példa egy konfigurációbetöltő réteg: a fájlolvasás operációs hibát (error_code), a feldolgozás doménhibát (enum class) ad, a kettő találkozását a transform_error kezeli. Az errno-t minden esetben azonnal, a következő könyvtárhívás előtt csomagoljuk be — ez a Hibakód, errno és hibaadatok élettartama (11.3) szabálya.

// cfg.cpp -- ISO C++23, std::expected monadikus lanc, fallbackkel
#include <version>      // a feature-test makrok forrasa: MINDEN teszt elott

#if !defined(__cpp_lib_expected)
#  error "A laborprofil standard konyvtara nem nyujt std::expected-et (C++23)"
#endif
// 202211L: monadikus fuggvenyek (P2505); 202202L: csak az alap expected (P0323).
#if __cpp_lib_expected >= 202211L && !defined(CFG_FORCE_FALLBACK)
#  define CFG_MONADIC 1
#endif

#include <cerrno>
#include <charconv>
#include <cstdio>
#include <expected>
#include <string>
#include <string_view>
#include <system_error>

namespace cfg {

enum class err { missing, io, malformed, out_of_range };

std::error_code errno_code() noexcept { return {errno, std::system_category()}; }

std::expected<std::string, std::error_code> read_file(const char* path)
{
    std::FILE* f = std::fopen(path, "rb");
    if (!f) return std::unexpected(errno_code());

    std::string out;
    char buf[256];
    std::size_t n = 0;
    while ((n = std::fread(buf, 1, sizeof buf, f)) != 0) out.append(buf, n);

    if (std::ferror(f)) {
        const std::error_code ec = errno_code();   // meg erzekeny az errno
        std::fclose(f);
        return std::unexpected(ec);
    }
    std::fclose(f);
    return out;
}

std::expected<std::string_view, err>
field(std::string_view text, std::string_view key) noexcept
{
    std::size_t pos = text.find(key);
    if (pos == std::string_view::npos) return std::unexpected(err::malformed);
    pos += key.size();
    std::size_t end = text.find('\n', pos);
    if (end == std::string_view::npos) end = text.size();
    return text.substr(pos, end - pos);
}

std::expected<int, err> to_int(std::string_view s) noexcept
{
    int v{};
    auto [ptr, ec] = std::from_chars(s.data(), s.data() + s.size(), v);
    if (ec != std::errc{} || ptr != s.data() + s.size())
        return std::unexpected(err::malformed);
    return v;
}

struct config { int workers; };

std::expected<config, err> map_io(std::error_code ec) noexcept
{
    return std::unexpected(ec == std::errc::no_such_file_or_directory
                               ? err::missing : err::io);
}

std::expected<int, err> check_range(int v) noexcept
{
    if (v < 1 || v > 64) return std::unexpected(err::out_of_range);
    return v;
}

#if defined(CFG_MONADIC)
std::expected<config, err> load(const char* path)
{
    return read_file(path)
        .transform_error([](std::error_code ec) {
            return ec == std::errc::no_such_file_or_directory
                 ? err::missing : err::io;
        })
        .and_then([](std::string&& text) -> std::expected<config, err> {
            // a string_view a text-re mutat; a text a lambda vegeig el
            return field(text, "workers=")
                .and_then(&to_int)
                .and_then(&check_range)
                .transform([](int v) { return config{v}; });
        });
}
#else
// Fallback: ugyanaz a szerzodes kezi elagazasokkal, monadikus fuggvenyek nelkul.
std::expected<config, err> load(const char* path)
{
    auto text = read_file(path);
    if (!text) return map_io(text.error());
    auto f = field(*text, "workers=");
    if (!f) return std::unexpected(f.error());
    auto v = to_int(*f);
    if (!v) return std::unexpected(v.error());
    auto r = check_range(*v);
    if (!r) return std::unexpected(r.error());
    return config{*r};
}
#endif

} // namespace cfg

int main(int argc, char** argv)
{
    if (argc != 2) return 2;
    if (auto r = cfg::load(argv[1])) std::printf("workers=%d\n", r->workers);
    else std::printf("hiba: %d\n", static_cast<int>(r.error()));
    return 0;
}

Élettartam-térkép: a text std::string tulajdonosa a load lambdája, a field által visszaadott string_view kölcsönzött nézet ugyanerre; a nézet érvényessége a lambda végéig tart, és nem hagyja el a láncot, mert a végső config csak int-et tartalmaz. Érvénytelenítő művelet nem történik, mert a text-et a lánc nem módosítja. Ha a config tagja std::string_view lenne, a lánc végétől dangling nézetünk maradna — ez a leggyakoribb tervezési hiba ebben a mintában.

Feature-test és build: a program elején a <version> után a __cpp_lib_expected dönt. Ha nincs std::expected, világos #error áll le; ha van, de 202211L alatti, a load kézi elágazásos fallbackje fordul; különben a monadikus lánc. A fallback-ág a -DCFG_FORCE_FALLBACK kapcsolóval monadikus felületet tudó profilban is lefordítható és tesztelhető.

# rogzitett laborprofilban, peldaul GCC 13+ vagy Clang 17+ mellett ertelmezheto
g++ -std=c++23 -O2 -Wall -Wextra -Wpedantic -o cfg cfg.cpp
# vagy
clang++ -std=c++23 -O2 -Wall -Wextra -Wpedantic -o cfg cfg.cpp
# a fallback-ág tesztje:
g++ -std=c++23 -O2 -Wall -Wextra -Wpedantic -DCFG_FORCE_FALLBACK -o cfg cfg.cpp

Buildjegyzőkönyv (lab-gcc profil). A monadikus és a fallback-ág is figyelmeztetés nélkül fordult, és azonosan viselkedett: workers=8 fájlra workers=8; workers=99-re hiba: 3 (out_of_range); workers=abc-re hiba: 2 (malformed); hiányzó fájlra hiba: 0 (missing). -std=c++20 mellett a fordítás a saját #error üzenettel állt le, nem template-hibával. A Clang-sort nem ellenőriztük.

41.1.5 Hibatípus tervezése: enum, error_code vagy doménstruct

Három reális választás van. Az enum class olcsó, másolható, kimerítő switch-cél; akkor jó, ha a hiba fajtája elég, a kontextus nem. A std::error_code a rendszerhatáron kötelező: két szó, olcsón másolható, kategóriával együtt hordozza a hibát, de üzleti jelentést nem ad. A doménstruct (struct parse_error { err kind; std::size_t offset; }) akkor kell, ha a hiba helye vagy a kontextus a diagnosztikához nélkülözhetetlen; ára a méret és a másolási költség.

Ajánlott minta: a modul belsejében error_code, a publikus API határán transform_error-ral domén típusra váltás, és a láncon belül E soha nem változik. Az error_code és a domén típus közötti oda-vissza konvertálást ne építsd a lánc közepére: minden váltás újabb hely, ahol a hibainformáció elveszhet.

41.1.6 Kivételhatár és noexcept

A szerződés two-way: expected a számított, rutinszerű hibákra való (hiányzó fájl, formátumhiba, megtelt puffer), kivétel a szerződésszegésre és a nem helyreállítható állapotra. Egy noexcept függvény nem engedheti ki a kivételt, ezért minden meghiúsulási módját a visszatérési típusban kell kifejeznie — a noexcept és az expected tehát természetes páros.

Konstruktorhiba esetén a konstruktor nem adhat vissza expected-et. Klasszikus minta: privát konstruktor és publikus static std::expected<T, E> create(...) gyárfüggvény, amely a validációt elvégzi, és csak érvényes objektumot konstruál. Ha a konstruktor mégis dob, a hibát a hívó modulhatáron try/catch-csel alakítsd expected-dé, és ott rögzítsd, mely kivételeket fordítasz hibakóddá.

Figyelj rá, hogy a value() szándékosan átlépi a határt: bad_expected_access<E>-t dob. Ez hasznos a „lehetetlen” ág jelölésére, de tilos noexcept függvényben és tilos egy lánc közepén, mert a lánc értékre optimalizált. A monadikus műveletek maguk sem noexcept-ek, csak ha a hívott F az; egy noexcept lánchoz minden lépésnek noexcept-nek kell lennie.

41.1.7 Költség: méret, másolás, kódméret

Az expected<T,E> mérete legalább max(𝚜𝚒𝚣𝚎𝚘𝚏(T),𝚜𝚒𝚣𝚎𝚘𝚏(E))\max(\texttt{sizeof}(T), \texttt{sizeof}(E)), ehhez jön a diszkrimináns (jellemzően egy szó, a padding miatt néha ingyen), és az igazítás a két típus igazításmaximuma. A képlet mint alsó korlát értendő: a konkrét érték target ABI és fordítási profil függvénye, ezért mérés nélkül ne állíts számot. A laborban írj egy programot, amely kiírja a sizeof-okat, és hasonlítsd össze a std::variant<T,E> és a kézi struct { bool ok; T v; E e; } változattal.

Másolás: az expected másolása a benne lévő T vagy E másolását jelenti. A lánc végén ezért érdemes std::move(exp).and_then(...)-t használni, mert az rvalue-minősített hívás T&&-t ad át, és elkerüli a nagy T felesleges másolását. Kódméret: az expected jellemzően elágazásokká fordul, míg a kivételkezelés külön táblákat és unwinder-útvonalat igényel; a két minta kódméret-összehasonlítását szintén a laborra bízom (size, nm --size-sort, illetve a kiadott bináris összevetése), nem állítom mért eredményként.

41.1.8 Gyakori hibák és javításuk

  1. transform expected-et adó függvénnyel: expected<expected<T,E>,E> lesz belőle. Javítás: and_then.
  2. Eltérő E a láncban: az and_then nem konvertál, fordítási hiba. Javítás: a hibaidőtípus-váltás transform_error-ral, egyetlen ponton.
  3. error() hívása értékágon, illetve *r vagy r-> hibaágon: nem definiált viselkedés. Javítás: előbb has_value(), vagy a value(), amely hibaágon std::bad_expected_access-t dob, és nem UB.
  4. errno késői olvasása: a köztes fclose/append felülírhatja. Javítás: azonnali errno_code() csomagolás.
  5. value() egy noexcept határon: kivétel szökik ki, std::terminate. Javítás: has_value()-ágazás vagy a hívó expected-en marad.
  6. expected<void,E> hívhatója egy paramétert vár: fordítási hiba. Javítás: nulla paraméteres lambda.

41.1.9 Feladatok

1. feladat (típuslevezetés). Legyen e : std::expected<int, NetErr>, f : int → std::expected<Bytes, NetErr>, g : Bytes → std::size_t, h : NetErr → std::error_code. Írd fel a e.and_then(f).transform(g).transform_error(h) kifejezés pontos típusát, majd add meg, mi lenne a típus, ha a transform(g) helyett transform(f) állna, és indokold a különbséget!

2. feladat (kódolás). Írj write_all(int fd, std::span<const std::byte>) függvényt, amely std::expected<std::size_t, std::error_code>-t ad vissza, EINTR esetén újrapróbál, részleges írást helyesen kezel, és noexcept. Készíts hozzá egy láncot, amely a hibát transform_error-ral enum class io_err { interrupted, no_space, other } típusra képzi. Add meg a buildparancsot és az ellenőrzési feltételt!

3. feladat (diagnosztika). A következő függvényben legalább három hiba van. Nevezd meg őket, sorrendezd aszerint, hogy fordítási hibát vagy futásidejű hibát okoznak, és írd fel a javított változat láncát!

std::expected<config, cfg::err> load(const char* path) noexcept
{
    auto text = cfg::read_file(path);          // expected<string, error_code>
    return text
        .and_then([](const std::string& t) { return cfg::parse_workers(t); })
        .transform([](int v) { return config{v}; })
        .or_else([](cfg::err e) { return e; })
        .value();
}

41.1.10 Megoldások

1. megoldás. Lépésenként: e.and_then(f) típusa std::expected<Bytes, NetErr> (a hiba típusa változatlan, az érték Bytes). .transform(g) típusa std::expected<std::size_t, NetErr>. .transform_error(h) típusa std::expected<std::size_t, std::error_code>. A hibás változatban e.transform(f) típusa std::expected<std::expected<Bytes, NetErr>, NetErr>: a transform nem lapít, a belső expected értékként kerül a külsőbe, és a hibainformáció kétszeresen csomagolódik. Indoklás: a lapítás az and_then szerződése, ezért expected-et adó hívásra mindig and_then kell.

2. megoldás.

#include <cerrno>
#include <cstddef>
#include <expected>
#include <span>
#include <system_error>
#include <unistd.h>

enum class io_err { interrupted, no_space, other };

std::expected<std::size_t, std::error_code>
write_all(int fd, std::span<const std::byte> data) noexcept
{
    std::size_t done = 0;
    while (done < data.size()) {
        const ssize_t n = ::write(fd, data.data() + done, data.size() - done);
        if (n < 0) {
            if (errno == EINTR) continue;
            return std::unexpected(std::error_code{errno, std::system_category()});
        }
        done += static_cast<std::size_t>(n);
    }
    return done;
}

std::expected<std::size_t, io_err>
write_frame(int fd, std::span<const std::byte> frame) noexcept
{
    return write_all(fd, frame).transform_error([](std::error_code ec) {
        if (ec == std::errc::no_space_on_device) return io_err::no_space;
        if (ec == std::errc::interrupted)       return io_err::interrupted;
        return io_err::other;
    });
}

Indoklás: a write_all noexcept, mert minden hibát a visszatérési típusban fejez ki; az errno-t azonnal csomagolja, így a ciklus következő iterációja nem írhatja felül. A done nulla hosszú bemenetre 0-t ad, ami érvényes siker. Korlát: a ::write size_t argumentuma SSIZE_MAX felett implementációfüggő; ha ekkora pufferek előfordulhatnak, a hívást darabolni kell — ez a labor egyik negatív tesztje. Build: g++ -std=c++23 -O2 -Wall -Wextra -Wpedantic -c io.cpp. Ellenőrzési feltétel: zárt fd-re unexpected, EBADF kategóriával; EINTR-t szimuláló wrapper mellett a ciklus nem ad vissza hibát; nulla hosszú spanra 0 érték.

3. megoldás. Elsőként fordítási hibát okoz, hogy a read_file E = error_code, az and_then viszont cfg::err hibát adó függvényt kap; a lánc ezen a ponton nem konvertál. Ugyancsak fordítási hiba, hogy az or_else cfg::err-t ad vissza expected helyett. Futásidejű hiba a value(): a noexcept függvényben bad_expected_access<cfg::err>-t dobhat, ami std::terminate-hez vezet, és egyben elveszíti a hibaági információt. Negyedikként tervezési hiba, hogy text-et const std::string&-ként veszi át, miközben a lánc rvalue-ként továbbítja; ez nem hiba, de felesleges másolás nélkül is elkerülhető. Javított lánc: read_file(path).transform_error(...), majd and_then a feldolgozásra, transform a config-ra, végül return az expected-en, or_else nélkül.

41.1.11 Összefoglaló

A std::expected rétegzett használatának három pillére van: (1) a láncon belül E végig azonos, a váltás transform_error-ral, egyetlen deklarált ponton történik; (2) expected-et adó hívásra and_then, sima értéktranszformációra transform kell, mert csak az előbbi lapít; (3) a noexcept határ és az expected ugyanazt a szerződést fejezi ki, ezért a value() sosem kerülhet egy noexcept láncba. Az errno-t azonnal csomagold, a doménhibát a modulhatáron alakítsd ki, és a lánc végén mozgatással dolgozz a felesleges másolás ellen. A C++23-as felület elérhetőségét feature-test és fordítási próba döntse el, ne a tankönyvi feltevés.