31.1 optional, variant és visit

A hibakezelésnél (std::expected, std::error_code) megszokott minta, hogy a visszatérési érték hordozza a hibát is. Sok domain-állapot azonban nem hiba: egy konfigurációs kulcs lehet, hogy nincs beállítva; egy eseménynek lehet, hogy nincs payloadja; egy mérőszám lehet, hogy még nem érkezett meg. Ilyenkor a hibakód-payload erőltetése hazugság: olyan ágat kényszerítünk a hívóra, amelyre a domainben nincs szükség, és az if (rc != 0) ellenőrzések közé elrejtjük a valódi kérdést, hogy „van-e egyáltalán érték”. A standard utility típusok ezt a kérdést emelik a típusba: std::optional a jelenlétet, std::variant a zárt alternatívahalmazt modellezi, a std::visit pedig a feldolgozást köti az alternatívákhoz. A fejezet a döntési szempontokat, a lifetime-szerződést és a teljes lefedés kikényszerítését tárgyalja, összevetve a C tagged union megoldásával (Union, tagged union, enum és bit-field (9.3)).

A tárgyalt típusok C++17 óta a szabvány részei, a könyv profilja szerint C++20-at használunk. A fejezet forrásjegyzet nélkül készült: a konkrét szabványparagrafusokat, library-implementációs részleteket és a méretekre vonatkozó állításokat a saját laborprofilodban (compiler- és libstdc++/libc++ verzió) ellenőrizd, mielőtt egy viselkedésre építesz.

31.1.1 Optional: a jelenlét mint állapot

A std::optional<T> (header: <optional>) olyan értéktípus, amely vagy tartalmaz egy T típusú objektumot, vagy nem tartalmaz semmit. Előfeltételek: T objektumtípus, nem referencia és nem tömb (std::optional<T&> nem szabványos), és a használat pontján teljes típus. A tároló az optional objektumon belül helyezkedik el, tehát a műveletek nem allokálnak.

A fontos fogalom az engaged/disengaged állapot: engaged esetben az optional belsejében él egy T objektum, amelynek megvan a maga élettartama; disengaged esetben nem létezik T objektum, csak a tároló. Ez nem ugyanaz, mint „a T értéke nulla” vagy „a pointer null”: itt ténylegesen nincs objektum, ezért destruktort sem hívunk rá. Az élettartam-ablakot három művelet mozgatja: a konstrukció/értékadás engage-el, az emplace(args...) a régi értéket elpusztítja és egy újat konstruál a helyén, a reset() pedig elpusztítja az értéket és disengaged állapotba visz. Ha az emplace során a T konstruktora kivételt dob, az optional garantáltan üresen marad — ez az a pont, ahol a lifetime-szerződés megfogható.

Olvasáskor három út van: has_value()/operator bool ellenőrzés után operator* (nem ellenőriz, üresen nem definiált viselkedés), a value() (üresen std::bad_variant_access helyett std::bad_optional_access-t dob, kivétel nélküli buildben jellemzően leállítja a programot), illetve a value_or(default), amely üresen a megadott alapértéket adja vissza. Az operator* csak ott indokolt, ahol a jelenlétet közvetlenül előtte már bizonyítottuk.

Levezetés-vázlat a méretre. Ha T értékkészletének reprezentálásához kk bit kell, az optional<T>-nek legalább 2k+12^k+1 állapotot kell megkülönböztetnie: a 2k2^k lehetséges T-értéket és a „nincs érték” állapotot. Ezért ha a T reprezentációjában nincs kihasználatlan bit, a sizeof(optional<T>) > sizeof(T) szükségszerűen teljesül. A gyakorlatban a könyvtárak külön jelzőt használnak, így sizeof(std::optional<char>) jellemzően 2 — ez azonban implementációfüggő, mérés kérdése, nem hordozható állítás.

Az alábbi példa egy névfeloldást mutat, ahol a „nincs ilyen bejegyzés” nem hiba, hanem a domain bezárható esete. Fordítás: g++ -std=c++20 -Wall -Wextra -pedantic -O2 -o optional_demo optional_demo.cpp.

// optional_demo.cpp -- ISO C++20
#include <iostream>
#include <optional>
#include <string>

struct Endpoint {
    std::string host;
    unsigned short port;
};

// 0 vagy 1 érték; a "miért nincs" nem része a szerződésnek.
std::optional<Endpoint> lookup_endpoint(const std::string& name) {
    if (name == "api") return Endpoint{"api.internal", 8443};
    if (name == "db")  return Endpoint{"db.internal", 5432};
    return std::nullopt;
}

int main() {
    if (auto ep = lookup_endpoint("api")) {       // kontextuális bool-konverzió
        std::cout << ep->host << ':' << ep->port << '\n';
    }

    std::optional<Endpoint> slot;                 // disengaged: nincs Endpoint
    slot.emplace(Endpoint{"cache", 6379});        // itt kezdődik az élettartam
    std::cout << slot->host << '\n';              // bizonyított jelenlét
    slot.reset();                                 // itt véget ér
    std::cout << slot.has_value() << '\n';        // 0

    Endpoint fallback{"none", 0};
    std::cout << slot.value_or(fallback).port << '\n';  // 0
}

A gyakori félreértés, hogy az optional hibacsatorna. Ha a hívónak tudnia kell, miért nincs érték (érvénytelen bemenet, hiányzó fájl, időtúllépés), akkor nem optional<T>, hanem expected<T, E> vagy error_code a helyes választás (error_code, system_error és expected (25.4)). Ugyanígy antipattern a std::optional<std::optional<T>> és az optional függvényparaméterként „opcionális argumentum” gyanánt: utóbbira általában túlterhelés vagy dokumentált pointer-szerződés a jobb megoldás, mert a hívás helyén nem derül ki, hogy a nullopt mit jelent.

31.1.2 Variant: az aktív alternatíva

A std::variant<Ts...> (header: <variant>) zárt alternatívahalmazt modellez: az objektum minden pillanatban pontosan egy Ts közül tartalmaz értéket (vagy a valueless_by_exception állapotban van, lásd később). Az alternatívák objektumtípusok; referencia nem lehet köztük. A típuslista lezárt: nem bővíthető futásidőben, ezért ez a fordítási idejű megfelelője annak, amit C-ben kézi enum taggel és union-nal írunk le.

Az aktív alternatívát az index() árulja el (std::size_t), a hozzáférést a std::get<I>(v), a std::get<T>(v) (rossz esetben std::bad_variant_access), illetve a nem dobó std::get_if<I>(&v) / std::get_if<T>(&v) adja, amely üres pointert ad vissza, ha nem az adott alternatíva aktív. A std::holds_alternative<T>(v) ugyanezt bool-ként kérdezi. Ha egy típus többször szerepel a listában, a get<T> nem használható (nem egyértelmű), csak az indexes forma.

Két tervezési fogás érdemel figyelmet. Egyrészt a std::variant alapkonstruktora az első alternatívát konstruálja, amihez annak alapkonstruálhatónak kell lennie — ha ez nem kívánatos, a std::monostate-ot tesszük első helyre, és megkapjuk az explicit „nincs payload” állapotot. Másrészt a variant nem allokál: a mérete jellemzően maxi𝚜𝚒𝚣𝚎𝚘𝚏(Ti)\max_i \texttt{sizeof}(T_i) plusz az index tárolása és padding; a pontos értéket mérni kell, mert ABI- és implementációfüggő.

// variant_demo.cpp -- ISO C++20
#include <cstdint>
#include <iostream>
#include <string>
#include <variant>

struct Metric { double value; };
struct Log    { std::string text; };
struct Event  { std::uint64_t id; };

using Payload = std::variant<std::monostate, Metric, Log, Event>;

int main() {
    Payload p;                                  // monostate: nincs payload
    p = Log{"startup"};                         // aktív alternatíva váltás
    std::cout << p.index() << '\n';             // 2

    if (auto* l = std::get_if<Log>(&p)) {       // nem dob, nullptr lehet
        std::cout << l->text << '\n';
    }
    try {
        std::cout << std::get<Metric>(p).value; // rossz alternatíva -> kivétel
    } catch (const std::bad_variant_access&) {
        std::cout << "nem metric\n";
    }
}

Fordítás: g++ -std=c++20 -Wall -Wextra -pedantic -O2 -o variant_demo variant_demo.cpp.

31.1.3 std::visit és a teljes lefedés

A std::visit(visitor, v) a visitor call operátorát hívja meg az aktív alternatívára. A lényeg nem a kényelem, hanem a szerződés: a visitornak minden alternatívára hívhatónak kell lennie, különben a program nem fordul le. Ez a teljes lefedés (exhaustive handling) fordító általi kikényszerítése: ha a típuslistához új alternatíva kerül, a régi visitor nem fordul tovább, és a hiba a definíció helyén, nem futásidőben a ritka ágon jelentkezik.

A gyakorlatban kétféle visitort írunk. Az egyik egy explicit overload-halmaz (gyakran a overloaded minta, amely variadic template-re és pack-kiterjesztésre épül, Variadic template, pack és fold expression (27.4)):

template <class... Fs>
struct overloaded : Fs... { using Fs::operator()...; };
template <class... Fs>
overloaded(Fs...) -> overloaded<Fs...>;   // CTAD (C++17)

std::string describe(const Payload& p) {
    return std::visit(overloaded{
        [](std::monostate)      -> std::string { return "empty"; },
        [](const Metric& m)     -> std::string { return "metric"; },
        [](const Log& l)        -> std::string { return "log:" + l.text; },
        [](const Event& e)      -> std::string { return "event#" + std::to_string(e.id); },
    }, p);
}

A másik a generikus lambda if constexpr lánccal, ami akkor jó, ha az alternatívák közös interfészt mutatnak, vagy ha típusonként eltérő tagot kell elérni. A std::visit visszatérési típusa C++17-ben minden alternatívára azonos kell legyen; C++20-tól létezik std::visit<R> explicit visszatérési típussal. Több variant átadásakor a visitor a kombinációk szorzatán hívódik, tehát a lefedettségi követelmény is a szorzatra vonatkozik.

Itt van egy csapda, ami megkerüli a teljes lefedést: a [](auto const&) { return std::string{"?"}; } catch-all lambda a típuslista minden alternatívájára hívható, így a visitor lefordul, és egy később felvett új alternatíva csendben a semmitmondó ágra kerül. A catch-all csak akkor legitim, ha a típuslistában szereplő alternatívákra valóban van értelmes alapértelmezett viselkedés. A valueless állapotot viszont semmilyen visitor-ág nem kezeli: valueless variantra a std::visit meg sem hívja a visitort, hanem std::bad_variant_access-t dob. Ezt tehát a visit előtt (valueless_by_exception()) vagy a kivétel elkapásával kell kezelni (lásd a következő szakaszt).

31.1.4 valueless_by_exception: a variant egyetlen hibás állapota

A variant normál esetben mindig tartalmaz valamit. Kivételt jelent, ha típusváltó értékadás vagy emplace közben az új alternatíva konstrukciója kivételt dob: a szabvány megengedi, hogy a variant valueless_by_exception() állapotba kerüljön, amelyet index() == std::variant_npos jelez, és amelyre a std::visit std::bad_variant_access-t dob. Fontos feltétel, hogy ez a helyzet a már létező variant objektumon, értékadás/emplace során állhat elő — a variant puszta másolása/mozgatása közben dobott kivétel nem hoz létre valueless objektumot, hiszen az új objektum létre sem jön.

Két gyakorlati következmény. Egyrészt kivételeket nem használó buildben (-fno-exceptions) ez az állapot nem áll elő, másrészt a standard megengedi, de nem írja elő, hogy egy implementációval ez ténylegesen elérhető-e, ezért a valueless_by_exception()-t nem lehet kihagyni, ha az alternatívák konstruktora dobhat és az értékadás típusváltó. Ilyen esetben a visit előtt ellenőrizz:

if (p.valueless_by_exception()) { /* kényszerített "nincs értelmezhető állapot" ág */ }

Ha a noexcept konstruktorú alternatívákra szorítkozol, ez az ág statikusan kizárható, és a kód egyszerűbb lesz.

// valueless_visit.cpp — C++20: a catch-all ág soha nem kapja meg a valueless állapotot
#include <cstdio>
#include <stdexcept>
#include <string>
#include <variant>

struct Thrower {
    std::string name;                       // nem triviálisan másolható típus
    Thrower() = default;
    explicit Thrower(int) { throw std::runtime_error("ctor"); }
};

int main() {
    int catch_all_calls = 0;
    auto visitor = [&](const auto&) { ++catch_all_calls; return std::string{"?"}; };

    std::variant<std::monostate, std::string, Thrower> v{std::string{"x"}};
    try { v.emplace<Thrower>(1); } catch (const std::runtime_error&) {}
    std::printf("valueless=%d\n", v.valueless_by_exception());

    try {
        std::visit(visitor, v);
    } catch (const std::bad_variant_access&) {
        std::puts("bad_variant_access");
    }
    std::printf("catch-all hivasok: %d\n", catch_all_calls);   // 0

    std::variant<std::monostate, std::string, Thrower> e;      // monostate
    std::visit([](const auto& x) {
        if constexpr (std::is_same_v<std::decay_t<decltype(x)>, std::monostate>)
            std::puts("monostate ag");
    }, e);
}

Buildjegyzőkönyv (lab-gcc profil). g++ -std=c++20 -Wall -Wextra -Wpedantic valueless_visit.cpp kimenete: valueless=1, bad_variant_access, catch-all hivasok: 0, monostate ag; a Clang 22 / libc++ ugyanezt adta. A Thrower std::string tagja nem dísz: a libstdc++ triviálisan másolható alternatívánál előbb egy ideiglenesbe épít, és ilyenkor a variant nem lesz valueless — ez a megengedett implementációs mozgástér.

31.1.5 C tagged union és a típusos C++ élettartam

C-ben ugyanezt kézi taggel írjuk le; a tag karbantartása a program felelőssége, és a nemtriviális tagok (például char*) élettartamát semmi nem kezeli.

/* tagged_union.c -- ISO C17 */
#include <stdio.h>

enum payload_kind { PAYLOAD_EMPTY, PAYLOAD_METRIC, PAYLOAD_EVENT };

struct payload {
    enum payload_kind kind;        /* az aktív tag: ezt a program őrzi */
    union {
        double metric;
        unsigned long long event_id;
    } u;
};

int main(void) {
    struct payload p = { .kind = PAYLOAD_METRIC, .u.metric = 0.5 };
    if (p.kind == PAYLOAD_METRIC)
        printf("%f\n", p.u.metric);
    return 0;
}

Fordítás: gcc -std=c17 -Wall -Wextra -pedantic -O2 -o tagged_union tagged_union.c.

A C megoldás gyenge pontja nem a szintaxis, hanem a szerződés: a kind és az u konzisztenciáját semmi nem ellenőrzi, a rossz tag olvasása az objektumreprezentáció újraértelmezését adja (a C szabvány szerint ez nem feltétlenül UB, de az érték implementáció által meghatározott, azaz logikailag hibás program), és nemtriviális típust csak pointeren vagy byte-pufferen keresztül lehet elhelyezni, kézi konstrukcióval és destruktorral.

Szempont C tagged union std::variant
Aktív alternatíva kézi enum tag, a program tartja karban könyvtár tárolja, index()
Tárolt érték élettartama nincs kezelve destruktor, értékadás, emplace kezeli
Nemtriviális tag csak pointeren/byte-pufferen át közvetlenül tárolható
Rossz tag olvasása reprezentáció-újraértelmezés, csendben rossz eredmény get → kivétel, get_if → nullptr
Méret max(sizeof tagok) + tag max(sizeof alternatívák) + index + padding
Allokáció nincs nincs
Típusbiztonság nincs fordítási idejű

A döntés tehát nem teljesítmény, hanem szerződés kérdése: a variant ugyanazt a tárolási mintát adja, de az aktív alternatívát és az élettartamot a típusrendszerbe emeli. Nyitott halmazra (bővíthető viselkedés, plugin) viszont továbbra is a virtuális interfész a helyes eszköz (Virtual, override, final és dispatch (24.2)); a variant a lezárt esetekre való.

31.1.6 Tipikus hibák és javításuk

31.1.7 Feladatok

1. feladat (modellválasztás). Egy eseménystream payloadja három alakú lehet: nincs payload, std::uint64_t számláló, std::string üzenet. Írd fel a típust, és indokold, miért nem std::optional<std::variant<...>>, illetve miért nem std::variant külön bool has_payload taggal.

2. feladat (kódolás). Készíts std::visit-alapú describe függvényt az 1. feladat típusára overloaded használatával, teljes lefedéssel. Vedd fel negyedik alternatívaként a struct Close{} típust, és magyarázd el, mit jelez a fordító, ha az új overloadot kifelejted.

3. feladat (diagnosztika). Adott a [](auto const&) { return std::string{"?"}; } visitor. Magyarázd meg, miért nem véd a teljes lefedés ellen, és add meg két módját annak, hogy a kihagyott alternatíva fordítási hibát okozzon. Mikor kell mégis valueless_by_exception()-t ellenőrizni a visit előtt?

4. feladat (C összehasonlítás). Írd át a fejezet C tagged unionját std::variant-ra, és sorold fel, mely kézi kötelezettségek tűnnek el.

31.1.8 Megoldások

1. megoldás. using Payload = std::variant<std::monostate, std::uint64_t, std::string>;. A monostate az első helyen biztosítja az alapkonstruálhatóságot és a „nincs payload” állapotot. Az optional<variant<...>> kétszeresen kódolná ugyanazt a hiányt (két forrásigazság: optional állapota és monostate), és a hívónak két szinten kellene ellenőriznie. A külön bool tag pedig éppen azt a kézi konzisztencia-kötelezettséget hozná vissza, amiért a variant-ot választjuk. Ellenőrzési feltétel: a típus alapkonstruktora fordul, Payload{} esetén index() == 0, és egyetlen forrásból (index()) eldönthető az állapot.

2. megoldás. A describe a fenti overloaded példa mintájára írható, négy explicit overloaddal. Ha a Close alternatívát felveszed a variant-ba, de a visitornál kihagyod a hozzá tartozó overloadot, a std::visit nem talál hívható operator()-t a Close argumentumra, ezért a kifejezés ill-formed: a hiba a describe definíciójánál, fordításkor jelentkezik, nem futásidőben. Ez a kívánt viselkedés: a típusbővítés kikényszeríti a feldolgozás bővítését. Ellenőrzési feltétel: az overload kihagyásával a build hibára fut, visszatételével pedig describe(Payload{Close{}}) == "close".

3. megoldás. A catch-all lambda minden argumentumtípusra hívható, ezért a std::visit számára a visitor teljesnek látszik; az új alternatíva csendben a "?" ágra esik, és a hiba csak akkor derül ki, ha valaki észreveszi a hibás kimenetet. Két statikus kikényszerítés: (a) explicit overload-halmaz a generikus lambda helyett; (b) a visitor törzsében static_assert a lefedett típusok halmazára, illetve típusonkénti if constexpr-lánc, amelynek végén static_assert(false)-szal jelzett „nem kezelt alternatíva” ág áll (a false feltételt template-paramétertől függővé kell tenni, hogy ne azonnal bukjon el). A valueless_by_exception() ellenőrzése akkor kell, ha az alternatívák konstruktora dobhat és az értékadás/emplace típusváltó, mert a std::visit valueless varianton std::bad_variant_access-t dob. Ellenőrzési feltétel: a catch-all eltávolítása után a kihagyott alternatíva fordításhiba; a valueless ág jelenléte mellett a -fno-exceptions build is fordul.

4. megoldás. using Payload = std::variant<std::monostate, double, unsigned long long>; (a C enum helyett monostate, a union helyett a típuslista). Eltűnik: a kind tag kézi karbantartása, a rossz tag olvasásának kezeletlensége (helyette get_if/bad_variant_access), valamint a nemtriviális tagok kézi konstrukciója és destruktorhívása. Megmarad: az a döntés, hogy a payload zárt halmaz, és hogy a feldolgozást minden alternatívára meg kell írni. Ellenőrzési feltétel: a variant destruktora akkor is helyesen fut, ha a std::string alternatíva aktív, kézi free nélkül.

31.1.9 Összefoglaló

A std::optional<T> a jelenlétet modellezi: legfeljebb egy T, helyben tárolva, emplace/reset által mozgatott élettartammal, hibaok nélkül. Ha a hiány oka a hívónak számít, nem ez a típus, hanem expected/error_code a válasz. A std::variant<Ts...> zárt alternatívahalmaz, mindig pontosan egy aktív taggal, index()/get/get_if hozzáféréssel, egyetlen kivételes állapottal (valueless_by_exception), amely típusváltó, dobó konstrukciójú értékadásnál fordulhat elő. A std::visit a feldolgozást az alternatívákhoz köti, és ezzel fordítási időben kikényszeríti a teljes lefedést — feltéve, hogy nem rejtjük el catch-all lambdával. A C tagged unionnal szemben nem teljesítményt nyerünk, hanem szerződést: az aktív tag és az élettartam a típusrendszer része lesz.