обработка ошибок и compile-timeerror handling and compile-time

std::expected

Значение T или ошибка E как часть возвращаемого типа функции — явная, типобезопасная альтернатива исключениям, вдохновлённая Result<T, E> из Rust.A value T or an error E as part of a function's return type — an explicit, type-safe alternative to exceptions, inspired by Rust's Result<T, E>.

часто используетсяcommonly used частый вопрос: чем отличается от исключений и от optionalcommon question: how it differs from exceptions and from optional

Что этоWhat it is

std::expected<T, E> — тип, хранящий либо успешное значение типа T, либо ошибку типа E, с явной проверкой, что именно там лежит, через has_value(). В отличие от std::optional<T>, который просто говорит "значения нет", expected несёт содержательную информацию о том, почему значения нет — код ошибки, сообщение, произвольный тип.A std::expected<T, E> holds either a successful value of type T or an error of type E, with explicit checking of which one is there via has_value(). Unlike std::optional<T>, which simply says "there's no value," expected carries meaningful information about why there's no value — an error code, a message, an arbitrary type.

до C++23 — исключение или errno-подобный кодbefore C++23 — an exception or an errno-like code
double parse_price(std::string_view s) {
    if (s.empty()) throw std::invalid_argument("empty");
    return std::stod(std::string(s));
}
// вызывающий код обязан знать про throw — не видно в сигнатуре// the caller must know about the throw — invisible in the signature
C++23 — ошибка видна в типеC++23 — the error is visible in the type
std::expected<double, std::string> parse_price(std::string_view s) {
    if (s.empty()) return std::unexpected("empty");
    return std::stod(std::string(s));
}
// сигнатура сама говорит: может не получиться, вот тип ошибки// the signature itself says: this can fail, here's the error type

Зачем ввелиWhy it was introduced

Ни исключения, ни существующие до C++23 механизмы не покрывали весь спектр реальных сценариев ошибок:Neither exceptions nor the mechanisms available before C++23 covered the full range of real-world error scenarios:

01

Исключения не бесплатны и не везде разрешеныExceptions aren't free, and aren't allowed everywhere

В embedded-разработке, в части игровых движков, в коде с жёсткими требованиями к latency (в том числе в low-latency финансовом коде) исключения часто отключены компиляторным флагом (-fno-exceptions) или запрещены стандартом проекта — код ошибок или optional были единственной альтернативой, но ни то, ни другое не несло структурированной информации об ошибке так же удобно, как исключение.In embedded development, parts of game engines, and code with hard latency requirements (including low-latency financial code), exceptions are often disabled via a compiler flag (-fno-exceptions) or forbidden by project standards — error codes or optional were the only alternative, but neither carried structured error information as conveniently as an exception.

02

std::optional не говорит, почему значения нетstd::optional doesn't say why there's no value

Функция, возвращающая std::optional<T>, сообщает только факт отсутствия результата — если причин отсутствия несколько (файл не найден, файл повреждён, недостаточно прав), optional сам по себе не различает их, и разработчик вынужден городить отдельный out-параметр с кодом ошибки рядом.A function returning std::optional<T> only communicates the fact that there's no result — if there are several reasons for the absence (file not found, file corrupted, insufficient permissions), optional by itself doesn't distinguish them, forcing the developer to bolt on a separate out-parameter carrying an error code.

03

Ошибка, видимая в сигнатуре, — документация и компилятор проверяют её вместеAn error visible in the signature — the documentation and the compiler check it together

Функция, кидающая исключение, не обязана декларировать это в сигнатуре (спецификации исключений вроде throw(...) устарели и удалены из языка) — единственный способ узнать, что функция может бросить исключение, это прочитать документацию или реализацию. expected<T, E> в возвращаемом типе — это часть контракта, которую компилятор обязывает обработать явно.A function that throws an exception isn't required to declare that in its signature (exception specifications like throw(...) are deprecated and removed from the language) — the only way to learn a function can throw is to read the documentation or the implementation. An expected<T, E> in the return type is part of the contract that the compiler forces callers to handle explicitly.

Как это работает под капотомHow it works under the hood

std::expected<T, E> устроен похоже на std::variant<T, E> — хранит один из двух вариантов в общей области памяти (обычно union-подобно, без дополнительной аллокации кучи для не слишком больших T/E) плюс флаг, какой из вариантов активен. Конструируется из T напрямую (успех) или из std::unexpected(e) — обёртки, явно маркирующей "это ошибка, не значение".A std::expected<T, E> is built similarly to std::variant<T, E> — it stores one of the two alternatives in a shared memory region (typically union-like, with no extra heap allocation for reasonably-sized T/E) plus a flag for which alternative is active. It's constructed from a T directly (success) or from std::unexpected(e) — a wrapper explicitly marking "this is an error, not a value".

std::expected<int, std::string> parse_int(std::string_view s) {
    int value;
    auto [ptr, ec] = std::from_chars(s.data(), s.data() + s.size(), value);
    if (ec != std::errc{}) {
        return std::unexpected(std::format("bad number: {}", s));  // ошибка// error
    }
    return value;  // успех — неявное построение из T// success — implicit construction from T
}

auto result = parse_int("42");
if (result.has_value()) { use(*result); }        // operator* — доступ к значению// operator* — access the value
else                    { log(result.error()); }   // .error() — доступ к ошибке// .error() — access the error

Ключевая особенность API — монадические методы для композиции цепочек операций без ручного разбора каждого промежуточного результата: .and_then(f) (продолжить, только если успех, f сама возвращает expected), .transform(f) (применить f к значению, если успех, обернуть результат обратно в expected), .or_else(f) (обработать ошибку, вернуться к успеху или пробросить другую ошибку), .transform_error(f) (преобразовать тип/содержимое ошибки).A key part of the API is a set of monadic methods for composing chains of operations without manually unpacking every intermediate result: .and_then(f) (continue only on success, f itself returns an expected), .transform(f) (apply f to the value on success, wrap the result back into an expected), .or_else(f) (handle the error, recover to success or propagate a different error), .transform_error(f) (convert the error's type/content).

// цепочка из трёх операций, каждая может вернуть ошибку —// a chain of three operations, each of which may fail —
// без единой ручной проверки has_value() между шагами:// with not a single manual has_value() check between steps:
auto result = parse_int(input)
    .and_then(validate_range)      // int -> expected<int, string>// int -> expected<int, string>
    .transform([](int x) { return x * 2; })  // int -> int, обёртка в expected автоматическая// int -> int, wrapping into expected is automatic
    .or_else([](const std::string& e) { return fallback_value(e); });

Примеры из реального кодаReal-world examples

типичные сценарии использованияtypical usage scenarios
01

Парсинг конфигурации с содержательными сообщениями об ошибкеParsing configuration with meaningful error messages

Функция чтения конфига, где каждая причина отказа (файл не найден, невалидный JSON, отсутствует обязательное поле) должна быть различима вызывающим кодом.A config-reading function where every failure reason (file not found, invalid JSON, missing required field) must be distinguishable by the caller.

enum class ConfigError { NotFound, InvalidJson, MissingField };

std::expected<Config, ConfigError> load_config(const std::filesystem::path& p) {
    if (!std::filesystem::exists(p)) return std::unexpected(ConfigError::NotFound);
    auto json = parse_json_file(p);
    if (!json) return std::unexpected(ConfigError::InvalidJson);
    if (!json->contains("port")) return std::unexpected(ConfigError::MissingField);
    return Config{*json};
}
02

Цепочка сетевых операций через and_thenA chain of network operations via and_then

Подключение → аутентификация → запрос данных — каждый шаг может провалиться независимо, но код читается как последовательность, а не пирамида проверок.Connect → authenticate → fetch data — each step can independently fail, but the code reads as a sequence, not a pyramid of checks.

std::expected<Response, NetError> fetch_user_data(std::string_view host) {
    return connect(host)
        .and_then([](Connection c) { return authenticate(c); })
        .and_then([](Session s) { return request(s, "/api/user"); });
}
03

Восстановление после ошибки через or_elseRecovering from an error via or_else

Попытка прочитать значение из кэша, а при отсутствии/ошибке — вычислить заново и сохранить в кэш, без явного if/else вокруг всей логики.Trying to read a value from a cache, and on absence/error, recomputing it and saving it back, with no explicit if/else wrapped around the whole logic.

std::expected<int, CacheError> get_cached_or_compute(std::string_view key) {
    return read_cache(key).or_else([&](CacheError) -> std::expected<int, CacheError> {
        int value = expensive_computation(key);
        write_cache(key, value);
        return value;
    });
}
04

Явная конвертация expected в исключение на границе APIExplicitly converting expected into an exception at an API boundary

Внутренний код использует expected для чистой обработки ошибок, но публичный API проекта уже построен на исключениях — value() кидает исключение автоматически, если значения нет.Internal code uses expected for clean error handling, but the project's public API is already exception-based — value() throws automatically if there's no value.

Config load_config_or_throw(const std::filesystem::path& p) {
    return load_config(p).value();  // кидает std::bad_expected_access<ConfigError>// throws std::bad_expected_access<ConfigError>
}                                        // если результат — ошибка// if the result is an error

ГраблиGotchas

operator* и .value() ведут себя по-разному при ошибкеoperator* and .value() behave differently on error

Как и у optional, разыменование через *result при отсутствии значения — undefined behavior, а .value() кидает исключение std::bad_expected_access<E> (содержащее саму ошибку E внутри). Использовать operator* без предварительной проверки has_value()/явного if (result) — тот же класс багов, что у необдуманного *optional_value.Just like with optional, dereferencing via *result when there's no value is undefined behavior, while .value() throws a std::bad_expected_access<E> exception (carrying the actual error E inside it). Using operator* without first checking has_value()/an explicit if (result) is the same class of bug as a careless *optional_value.

expected<T, E> — не замена исключениям для исключительных, непредвиденных ошибокexpected<T, E> isn't a replacement for exceptions on truly exceptional, unforeseen errors

expected хорошо подходит для ожидаемых, "штатных" отказов (файл не найден, невалидный ввод) — ошибок, которые вызывающий код разумно должен обработать на месте. Для по-настоящему исключительных ситуаций (нехватка памяти, повреждение инварианта программы) исключения (или аварийное завершение) остаются уместнее — заворачивать вообще всё в expected ради единообразия усложняет код без выгоды.expected is a good fit for expected, "routine" failures (file not found, invalid input) — errors the calling code should reasonably handle right there. For truly exceptional situations (out of memory, a broken program invariant), exceptions (or aborting) remain more appropriate — wrapping literally everything in expected for the sake of uniformity complicates the code with no real benefit.

E = void — легальный, но специальный случай тоже существует в обратную сторонуE = void isn't legal, but there's a special case in the opposite direction too

std::expected<void, E> — легальная и частая специализация для операций, которые либо успешны без возвращаемого значения, либо проваливаются с ошибкой (аналог Result<(), E> в Rust) — не нужно придумывать фиктивный тип успеха вроде bool. Обратная специализация, expected<T, void>, не имеет смысла и не предусмотрена API — если тип ошибки не нужен, это просто optional<T>.std::expected<void, E> is a legal and common specialization for operations that either succeed with no return value or fail with an error (the analogue of Rust's Result<(), E>) — no need to invent a fake success type like bool. The reverse specialization, expected<T, void>, makes no sense and isn't provided by the API — if you don't need an error type, that's just optional<T>.