обработка ошибок и compile-timeerror handling and compile-time
Значение 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>.
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.
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
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
Ни исключения, ни существующие до C++23 механизмы не покрывали весь спектр реальных сценариев ошибок:Neither exceptions nor the mechanisms available before C++23 covered the full range of real-world error scenarios:
В 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.
Функция, возвращающая 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.
Функция, кидающая исключение, не обязана декларировать это в сигнатуре (спецификации исключений вроде 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.
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); });
Функция чтения конфига, где каждая причина отказа (файл не найден, невалидный 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};
}
Подключение → аутентификация → запрос данных — каждый шаг может провалиться независимо, но код читается как последовательность, а не пирамида проверок.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"); });
}
Попытка прочитать значение из кэша, а при отсутствии/ошибке — вычислить заново и сохранить в кэш, без явного 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;
});
}
Внутренний код использует 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
Как и у 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 хорошо подходит для ожидаемых, "штатных" отказов (файл не найден, невалидный ввод) — ошибок, которые вызывающий код разумно должен обработать на месте. Для по-настоящему исключительных ситуаций (нехватка памяти, повреждение инварианта программы) исключения (или аварийное завершение) остаются уместнее — заворачивать вообще всё в 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.
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>.