25.4 error_code, system_error és expected

A hibakezelés nem attól jó, hogy van benne try/catch, hanem attól, hogy a hiba azonosítható, a kontextusa megmarad, és a hívó a szerződésnek megfelelően tud dönteni. A C++ban ehhez több eszköz áll rendelkezésre: a kivétel, a std::error_code, a std::system_error, az std::optional és a C++23-as std::expected. Ezek nem egymás helyettesítői, hanem különböző hibaátadási modellek. A választás alapja az, hogy a hiba várható-e, kell-e hozzá gép által olvasható hibaazonosság, kell-e emberi kontextus, és a függvény noexcept határon van-e. Az előző részek közül különösen a noexcept határok és hibapolicy, valamint a C-s errno és hibaadatok élettartama ismerete szükséges.

25.4.1 Kivétel vagy explicit eredmény?

Kivételt akkor érdemes használni, ha a hiba a helyi kontextusban nem kezelhető, ha egy konstruktor nem tud invariánst felállítani, vagy ha a hibát egy központi határon akarjuk egységesen kezelni. Az explicit eredmény akkor természetes, ha a hiba a művelet szerződésének része: egy fájl hiánya, egy parser hibás bemenete, egy hálózati művelet megszakítása vagy egy stream vége. Az explicit eredményt a hívónak ellenőriznie kell; ez nem „gyengébb” hiba, hanem láthatóbb szerződés.

A döntés feltételei: ha a hibát a hívó rendszeresen kezeli, és a hiba nem tesz érvénytelenné egy nagyobb objektumot, akkor explicit result; ha a hiba egy invariánst sért, vagy a függvény nem tud értelmes állapotban visszatérni, akkor kivétel; ha a függvény noexcept, akkor kivétel nem hagyhatja el, tehát eredményt kell visszaadni. A noexcept nem azt jelenti, hogy „nincs hiba”, hanem azt, hogy a hiba más csatornán megy.

25.4.2 error_code, error_condition és error_category

A std::error_code egy (érték, error_category*) pár. A kategória egy hibatartományt azonosít, a value pedig a konkrét hibát a tartományon belül. Az error_code olcsón másolható, nem tulajdonol dinamikus memóriát, és a kategóriának a program teljes futása alatt élnie kell; ezért saját kategóriát statikus objektumként kell definiálni. A nulla érték sikert jelent, függetlenül a kategóriától.

A std::error_condition szintén (érték, error_category*), de jelentése általános, hordozható feltétel, nem konkrét hiba. Például egy konkrét OS-hibakód összehasonlítható a „nincs jogosultság” feltétellel. Az error_code és error_condition összehasonlítása a kategóriák equivalent logikáján keresztül történik; két error_code közvetlen összehasonlítása viszont kategóriaazonosságot is megkövetel. Ezért nem elég csak a nyers értéket összevetni.

A std::error_category adja a name(), message(), default_error_condition() és equivalent() műveleteket. A message() emberi szöveget adhat, de az error_code maga nem tárol szövegkontextust. Ha a hívónak tudnia kell, hogy melyik fájl megnyitása hibázott, azt külön kell hordozni, például egy std::system_error objektumban vagy egy saját hibaobjektumban.

A tipikus OS-hibaút:

  1. A C API visszatér -1-gyel, és beállítja az errno-t.
  2. Az errno értékét azonnal el kell menteni, mert bármely következő könyvtári hívás felülírhatja.
  3. A mentett értékből std::error_code készül: std::error_code(saved, std::system_category()).
  4. Az összehasonlítás std::errc feltétellel történik, például ec == std::errc::permission_denied.
  5. Ha a hibát kivételként kell továbbadni, std::system_error(ec, "open config") készül.

A system_category() és a generic_category() szerepe eltér: a system_category() az operációs rendszer hibáit képviseli, a generic_category() a hordozható std::errc feltételeket. POSIX rendszeren a system_category() értéke jellemzően errno, de a pontos leképezés implementációfüggő; a target libc dokumentációja és egy compile/run probe ellenőrzi. A példák itt illusztratívak, nem futtatott mérések.

#include <errno.h>
#include <stddef.h>
#include <unistd.h>

enum io_status { IO_OK, IO_EOF, IO_ERROR };

enum io_status read_exact(int fd, void *buf, size_t len, size_t *done, int *err) {
    unsigned char *p = (unsigned char *)buf;
    size_t n = 0;
    while (n < len) {
        ssize_t r = read(fd, p + n, len - n);
        if (r < 0) {
            if (errno == EINTR) continue;
            *err = errno;
            *done = n;
            return IO_ERROR;
        }
        if (r == 0) { *done = n; return IO_EOF; }
        n += (size_t)r;
    }
    *done = n;
    return IO_OK;
}

Fordítási példa: gcc -std=c17 -Wall -Wextra -Wpedantic -c read_exact.c. Itt az EINTR újrapróbálkozás, az IO_EOF külön kimenet, az IO_ERROR pedig errno-t hordoz. A C API explicit eredményt ad, de a hiba kontextusát a hívónak kell kiegészítenie.

25.4.3 system_error és OS-hiba kontextus

A std::system_error olyan kivételtípus, amely egy std::error_code-ot és egy emberi kontextust hordoz. A std::runtime_error leszármazottja, code()-tal visszaadja a hibakódot, what()-ja pedig jellemzően tartalmazza a hibaüzenetet és a megadott kontextust; a what() pontos formátuma nem specifikált. Akkor használjuk, ha a hibát a függvény határán kivételként akarjuk továbbadni, például egy fájlmegnyitó wrapperben:

#include <cerrno>
#include <fcntl.h>
#include <system_error>
#include <unistd.h>

int open_or_throw(const char *path) {
    int fd = ::open(path, O_RDONLY);
    if (fd < 0) {
        int saved = errno;
        throw std::system_error(
            std::error_code(saved, std::system_category()),
            "open config");
    }
    return fd;
}

A std::system_error nem helyettesíti az error_code-ot: a hibaazonosságot a code() hordozza, a kontextust a what() és a konstruktorban átadott szöveg. A kivételt const std::system_error& referenciával érdemes elkapni. Ha a függvény noexcept, itt nem dobhatunk; ilyenkor expected-tel vagy error_code-dal kell visszatérni. A C++ kivétel C ABI-n való átengedése tudatosan kívül esik ezen a részen.

25.4.4 optional: hiány, nem hiba

A std::optional<T> vagy tartalmaz egy T értéket, vagy üres. Nem hordoz hibaokot, hibakódot és kontextust. Akkor használjuk, ha a hiány normális, várható állapot, és a hívónak nincs szüksége magyarázatra: cache-lookup, opcionális konfigurációs mező, „van-e ilyen elem” kérdés. Egy parser eredményére önmagában nem jó, mert nem tudjuk megkülönböztetni az üres bemenetet, a hibás karaktert és a túlcsordulást.

A value() üres optional esetén std::bad_optional_access kivételt dob, a value_or() alapértéket ad. Ha mégis optional<T>-t használunk, és mellette error_code-ot adunk vissza, két csatornán keletkezik a hiba; ez könnyen inkonzisztens lesz. Ilyenkor inkább expected<T,E> vagy saját result típus kell.

25.4.5 expected C++23 és C++20 Result-alternatíva

A std::expected<T,E> vagy egy T értéket, vagy egy E hibát tartalmaz. C++23-as eszköz; expected<void,E> is létezik. Az E jellemzően értéktípus: std::error_code, saját domain enum, vagy strukturált hibaobjektum. Referenciát hibaoldalon ne használjunk, mert az élettartam- és ownership-problémákat okoz. A has_value(), value(), error(), operator* és value_or() a fő műveletek. A value() hibás oldalon std::bad_expected_access<E>-t dob, az operator* és az error() előfeltétele a megfelelő oldal létezése; rossz oldal elérése nem definiált viselkedés.

A C++23-as expected monadic műveletei: az and_then a sikeres értéket tovább láncolja, a transform a sikeres értéket képezi, az or_else a hibaágon ad helyreállítási lehetőséget, a transform_error a hiba típusát alakítja. Ezek használatát feature-test macro és compile probe ellenőrzi; a választott compiler és standard library támogatása eltérhet, ezért a könyv C++20 alapján a saját Result fallbacket is bemutatja. A monadic műveletek hiányában a kézi if (has_value()) láncolás továbbra is helyes.

#include <charconv>
#include <cstdint>
#include <expected>
#include <string_view>

enum class parse_error { empty, invalid, out_of_range };

std::expected<std::uint64_t, parse_error> parse_u64(std::string_view s) {
    if (s.empty()) return std::unexpected(parse_error::empty);
    std::uint64_t v{};
    auto [ptr, ec] = std::from_chars(s.data(), s.data() + s.size(), v);
    if (ec == std::errc::invalid_argument) return std::unexpected(parse_error::invalid);
    if (ec == std::errc::result_out_of_range) return std::unexpected(parse_error::out_of_range);
    if (ptr != s.data() + s.size()) return std::unexpected(parse_error::invalid);
    return v;
}

Fordítási példa: g++ -std=c++23 -Wall -Wextra -Wpedantic -c parse_u64.cpp. A kód a std::from_chars hibáit domain hibává alakítja; így a hívó nem kap nyers errno-t, hanem a parser szerződésének megfelelő hibát. A C++20-as fallback lényege egy std::variant<T,E>-ra épülő Result, amely has_value(), value() és error() műveleteket ad; a részletes példa a feladatok megoldásában szerepel.

25.4.6 Domain error, cancellation, EOF és input error elkülönítése

A hibák összemosása gyakori tervezési hiba. Négy külön kategóriát érdemes megkülönböztetni:

Kategória Jelentés Tipikus reprezentáció Retry
Domain error érvénytelen bemenet, szerződésszegés enum class parse_error, strukturált domain hiba nem; a hívó javítsa
Cancellation külső kérésre leállított művelet kind::cancelled csak új kérésre
EOF normál stream-vége kind::eof, optional, külön státusz nem hiba, nem retry
Input/OS error átviteli vagy OS hiba std::error_code system_category()-vel esetfüggő; pl. EINTR újra

A domain error determinisztikus: ugyanaz a bemenet ugyanazt a hibát adja, és a hívó a bemenet javításával orvosolhatja. A cancellation nem feltétlenül hiba: a művelet szabályosan leállt, az erőforrásokat le kell zárni. Az EOF nem hiba, hanem a stream állapota; nem szabad system_error-rá alakítani. Az input error ezzel szemben külső, átmeneti vagy tartós hiba lehet; itt van értelme error_code-nak és retry-policy-nak.

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

struct io_error {
    enum class kind { eof, cancelled, system } k;
    std::error_code ec{};
};

std::expected<std::size_t, io_error> read_some(int fd, void *buf, std::size_t len) {
    for (;;) {
        ssize_t r = ::read(fd, buf, len);
        if (r < 0) {
            if (errno == EINTR) continue;
            return std::unexpected(io_error{io_error::kind::system,
                std::error_code(errno, std::system_category())});
        }
        if (r == 0) return std::unexpected(io_error{io_error::kind::eof, {}});
        return static_cast<std::size_t>(r);
    }
}

Fordítás: g++ -std=c++23 -Wall -Wextra -Wpedantic -c read_some.cpp. Az io_error itt kimenetkód: az expected hibacsatornája minden nem értékes kimenetet kezel, de a kind megőrzi, hogy EOF-ról, cancellationről vagy OS-hibáról van szó. Ha az EOF tényleg normál, és a hívó nem köteles minden ágat kezelni, lehet optional vagy külön read_status; a lényeg, hogy ne váljon system_error-rá.

25.4.7 Gyakori hibák

25.4.8 Összefoglaló

A kivétel a nem lokálisan kezelhető, invariánst sértő hibák eszköze; az explicit result a szerződés részét képező, várható hibáké. A std::error_code konkrét hibaazonosságot és kategóriát hordoz, az std::error_condition hordozható összehasonlítást tesz lehetővé, a std::system_error pedig kivételként csomagolja ezt kontextussal. Az std::optional csak hiányt jelez, nem hibaokot. A C++23-as std::expected explicit, típusos result; C++20-on saját Result fallback szükséges. A jó hibamodell külön tartja a domain hibát, a cancellationt, az EOF-ot és az OS/input hibát, és mindegyikhez megfelelő kezelési politikát rendel.

25.4.9 Feladatok

25.4.9.1 Első feladat: hibacsatorna választása

Válaszd ki a megfelelő visszatérési módot, és indokold: (a) lookup_cache(key), ahol a hiány normál; (b) open_socket(host, port), ahol az OS-hiba és az EINTR lényeges; (c) parse_port(string), ahol az üres, hibás és túlcsordult bemenet külön kezelendő.

25.4.9.2 Második feladat: C++20 Result és portparser

Írj C++20-as Result<T,E> típust std::variant-ra, majd parse_port(std::string_view) -> Result<std::uint16_t, parse_error> függvényt. Kezeld az üres bemenetet, a nem számjegyet, a túlcsordulást és a záró karaktert. Add meg a buildparancsot és a validációs feltételeket.

25.4.9.3 Harmadik feladat: diagnosztika

Az alábbi kódrészletekben keresd meg a hibakezelési hibákat, és javítsd őket:

int fd = ::open(path, O_RDONLY);
std::string msg = "open " + std::string(path);
if (fd < 0) throw std::system_error(errno, std::generic_category(), msg);
std::expected<int, std::string_view> bad() {
    std::string s = "invalid";
    return std::unexpected(s);
}

25.4.10 Megoldások

25.4.10.1 Az első feladat megoldása

  1. std::optional<Value>: a hiány várható, nincs szükség hibaokra; a hívó has_value()-vel dönt. (b) std::expected<Socket, std::error_code> vagy a határon std::system_error: az OS-hiba azonosítható kell legyen, és a retry-policyhez EINTR/EAGAIN információk kellenek. (c) std::expected<std::uint16_t, parse_error>: a domainhiba típusos, a hívó a bemenetet javíthatja; az optional itt elveszítené a hibaokot. Validáció: minden válasznál nevezd meg, hogy a hiba várható-e, kell-e gép által olvasható kód, és a függvény noexcept-e.

25.4.10.2 A második feladat megoldása

#include <charconv>
#include <cstdint>
#include <string_view>
#include <utility>
#include <variant>

enum class parse_error { empty, invalid, out_of_range };

template<class T, class E>
class Result {
    std::variant<T, E> v_;
public:
    Result(T v) : v_(std::in_place_index<0>, std::move(v)) {}
    Result(E e) : v_(std::in_place_index<1>, std::move(e)) {}
    bool has_value() const noexcept { return v_.index() == 0; }
    explicit operator bool() const noexcept { return has_value(); }
    T& value() & { return std::get<0>(v_); }
    const T& value() const & { return std::get<0>(v_); }
    E& error() & { return std::get<1>(v_); }
    const E& error() const & { return std::get<1>(v_); }
};

Result<std::uint16_t, parse_error> parse_port(std::string_view s) {
    if (s.empty()) return parse_error::empty;
    unsigned v{};
    auto [ptr, ec] = std::from_chars(s.data(), s.data() + s.size(), v);
    if (ec == std::errc::invalid_argument) return parse_error::invalid;
    if (ec == std::errc::result_out_of_range) return parse_error::out_of_range;
    if (ptr != s.data() + s.size()) return parse_error::invalid;
    if (v > 65535u) return parse_error::out_of_range;
    return static_cast<std::uint16_t>(v);
}

Build: g++ -std=c++20 -Wall -Wextra -Wpedantic -c result.cpp. Validáció: parse_port("") hibája empty; parse_port("80") értéke 80; parse_port("65536") hibája out_of_range; parse_port("8x") hibája invalid; parse_port("-1") hibája invalid. A Result nem nyújt monadic műveleteket; C++20-on a láncolást kézi if-fel kell megírni, vagy a projekt saját and_then/transform segédeket adhat hozzá.

25.4.10.3 A harmadik feladat megoldása

Az első snippetben az errno-t a std::string konstrukció után olvassuk, miközben az allokáció felülírhatja; a generic_category() sem az OS-hiba tartománya. Helyes:

int fd = ::open(path, O_RDONLY);
if (fd < 0) {
    int saved = errno;
    throw std::system_error(
        std::error_code(saved, std::system_category()),
        std::string("open ") + path);
}

A második snippetben a std::string_view egy lokális std::string-re mutat, amely a függvény visszatérésekor megszűnik; a hibaobjektum dangling nézetet hordozna. Javítás: az E legyen std::string, vagy statikus literálra mutató const char*, vagy domain enum. Validáció: a hibát a keletkezés helyén olvassuk ki, a kategória az OS-hibához illeszkedik, és a hibaobjektum élettartama nem rövidebb a hívó által elvártnál.