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.
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
bit kell, az optional<T>-nek legalább
állapotot kell megkülönböztetnie: a
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.
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
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.
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).
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.
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ó.
optional hibaként. Ha a hiány oka a
hívónak számít, expected<T,E> kell. Javítás: a
szerződés írásbeli rögzítése és a két típus szétválasztása.*opt ellenőrzés nélkül. Üres
optional dereferálása nem definiált viselkedés. Javítás:
if (opt) vagy value_or.get<T> holds_alternative
helyett. A get_if nem dob, ezért feltételes ágban
ez a robusztusabb.valueless ágra.emplace utáni régi referencia. Az
emplace/reset érvényteleníti a korábban
szerzett pointert és referenciát; ezek a T objektum
élettartamához kötöttek. Javítás: az új értékhez újra hozzáférni, nem
eltárolt pointeren át.sizeof-ra építő optimalizálás. A
variant/optional mérete nem garantált;
méréssel és dokumentált profillal kezeld.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.
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.
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.