Привет, Хабр!
C++23 принёс в стандартную библиотеку std::expected — тип, который для языков семейства Rust и Haskell давно норма, а для C++ долгое время оставался экзотикой через сторонние библиотеки (tl::expected, boost::outcome).
К середине 2026 года поддержка std::expected есть в GCC 12+, Clang 16+, MSVC 19.33+, а компании постепенно начинают использовать его как замену исключениям в новых модулях.
В статье рассмотрим, как мигрировать с привычного try/catch на std::expected, какие выигрыши даёт переход и где не стоит переходить из принципа.
Зачем вообще нужен ещё один способ обработки ошибок
В C++ исторически три способа сообщить об ошибке. Возврат специального значения (-1, nullptr, false), что работает в C, но плохо ложится на современные API. Исключения через throw/catch, которые есть в языке с C++98. Возврат std::optional<T>, который говорит «значения нет», но не объясняет, почему.
У каждого подхода свои проблемы. Возврат специального значения требует помнить, какое именно значение означает ошибку для каждой функции, и легко проигнорировать проверку. Исключения дороги по производительности на throw‑path, требуют exception‑safe‑кода во всей цепочке вызовов, плохо взаимодействуют с многопоточностью и часто запрещены в game dev, embedded, real‑time системах. std::optional теряет причину ошибки.
std::expected<T, E> — это контейнер, который содержит либо значение типа T (успех), либо значение типа E (ошибка). На уровне реализации это std::variant<T, E> с удобным API и семантикой «либо успех, либо ошибка, третьего не дано». Тип ошибки выбирает программист: enum, struct с деталями, std::string с описанием.
#include <expected> #include <string> #include <iostream> enum class ParseError { InvalidFormat, OutOfRange, EmptyInput }; std::expected<int, ParseError> parse_positive_int(const std::string& input) { if (input.empty()) { return std::unexpected(ParseError::EmptyInput); } try { int value = std::stoi(input); if (value < 0) { return std::unexpected(ParseError::OutOfRange); } return value; } catch (...) { return std::unexpected(ParseError::InvalidFormat); } } int main() { auto result = parse_positive_int("42"); if (result) { std::cout << "Parsed: " << *result << '\n'; } else { std::cout << "Error code: " << static_cast<int>(result.error()) << '\n'; } }
Несколько вещей сразу видны из этого примера. Функция явно объявляет, что может вернуть ошибку и какого типа. Возврат успеха выглядит как обычный return значения. Возврат ошибки оборачивается в std::unexpected. На вызывающей стороне используется булевое преобразование if (result) для проверки, оператор * для доступа к значению, .error() для доступа к ошибке. Никаких исключений, никаких out‑параметров, никакого скрытого control flow.
Базовый API
std::expected<T, E>поддерживает несколько способов работы со значением.operator bool()и методhas_value()возвращают true, если в expected лежит значение, и false если ошибка. Используются для базовой проверки.operator*()и методvalue()возвращают значение. Разница:value()бросает исключениеstd::bad_expected_access<E>если в expected лежит ошибка,operator*()это undefined behavior на ошибке (быстрее, но опаснее). На практике используетсяoperator*()после проверки илиvalue_or()для безопасного fallback.error()возвращает ошибку. Если в expected лежит значение, это undefined behavior, поэтому вызывается только после проверки.value_or(default_value)возвращает значение, если оно есть, иначе fallback. Удобно для случаев, когда ошибку обработать просто и без логирования.
auto result = parse_positive_int("abc"); // Безопасный доступ с fallback int value = result.value_or(0); // Доступ с проверкой if (result.has_value()) { std::cout << *result << '\n'; } else { log_error(result.error()); } // Доступ через value() с обработкой исключения try { int value = result.value(); process(value); } catch (const std::bad_expected_access<ParseError>& e) { handle_parse_error(e.error()); }
Последний вариант показывает, что
std::expectedне отменяет исключения полностью: их можно использовать на границе между expected‑кодом и legacy‑кодом, который ожидает исключения.
Цепочки через monadic operations
Сила
std::expectedраскрывается через monadic operations: методыand_then,or_else,transform,transform_error. Они позволяют строить цепочки преобразований без явных проверок на каждом шаге.and_then(f)вызывает функциюfна значении, если оно есть.fдолжна возвращать новыйstd::expected<U, E>с тем же типом ошибки. Если в исходном expected была ошибка, она пробрасывается без вызоваf.transform(f)похож наand_then, ноfвозвращает обычное значениеU, а неstd::expected<U, E>. Результат заворачивается вstd::expected<U, E>автоматически.or_else(f)вызывается на ошибке, позволяет восстановиться от неё или преобразовать в другую ошибку.fпринимает ошибку и возвращаетstd::expected<T, G>.transform_error(f)преобразует тип ошибки. Принимает ошибкуE, возвращает ошибку другого типаG.
Пример: запрос пользователя из базы, проверка прав доступа, формирование DTO.
struct User { int id; std::string name; int role_id; }; enum class AppError { UserNotFound, AccessDenied, DatabaseError }; std::expected<User, AppError> load_user(int id); std::expected<bool, AppError> check_admin(const User& user); std::expected<UserDto, AppError> to_dto(const User& user); // Цепочка через and_then std::expected<UserDto, AppError> get_admin_dto(int id) { return load_user(id) .and_then([](const User& user) -> std::expected<User, AppError> { return check_admin(user) .and_then([&user](bool is_admin) -> std::expected<User, AppError> { if (!is_admin) return std::unexpected(AppError::AccessDenied); return user; }); }) .and_then(to_dto); }
Сравним с эквивалентом на исключениях:
UserDto get_admin_dto_exceptions(int id) { User user = load_user_throws(id); // throws UserNotFoundError if (!check_admin_throws(user)) { // throws DatabaseError throw AccessDeniedError{}; } return to_dto(user); }
Версия с исключениями выглядит компактнее, и в простом случае она такая и есть. Но у неё есть скрытая стоимость. Каждый из вызовов может бросить разные типы исключений, и для надёжной обработки требуется ловить их все. Контроль того, какие исключения возможны, лежит в документации, а не в типе. Компилятор не подскажет, если добавится новый тип исключения. С std::expected всё это видно в сигнатуре: вернуть можно только AppError, что компилятор проверяет статически.
Возврат разных типов ошибок
В реальном коде разные слои абстракции имеют свои типы ошибок. Слой репозитория знает про — DatabaseError, слой сервиса про — BusinessLogicError, слой контроллера про ApiError. Цепочка std::expected через transform_error позволяет аккуратно конвертировать между ними.
enum class DbError { ConnectionFailed, QueryFailed, RecordNotFound }; enum class ServiceError { UserNotFound, InvalidState, DependencyFailed }; enum class ApiError { NotFound, BadRequest, InternalError }; std::expected<User, DbError> repo_find_user(int id); std::expected<User, ServiceError> service_get_user(int id) { return repo_find_user(id) .transform_error([](DbError db_err) -> ServiceError { switch (db_err) { case DbError::RecordNotFound: return ServiceError::UserNotFound; case DbError::ConnectionFailed: return ServiceError::DependencyFailed; case DbError::QueryFailed: return ServiceError::DependencyFailed; } return ServiceError::DependencyFailed; }); } std::expected<UserResponse, ApiError> handler_get_user(int id) { return service_get_user(id) .transform([](const User& user) { return UserResponse{user}; }) .transform_error([](ServiceError svc_err) -> ApiError { switch (svc_err) { case ServiceError::UserNotFound: return ApiError::NotFound; case ServiceError::InvalidState: return ApiError::BadRequest; case ServiceError::DependencyFailed: return ApiError::InternalError; } return ApiError::InternalError; }); }
Каждый слой выражает только свои ошибки, не знает про детали соседних. Маппинг между типами ошибок происходит в одном месте на границе слоёв. Если добавляется новый код ошибки в репозитории, компилятор подсветит, что в transform_error нужно его обработать (правда, требует -Wswitch для enum без default).
Богатые типы ошибок с деталями
enum class хорош для общих категорий, но часто нужны детали: какое именно поле не прошло валидацию, какие данные не найдены, какой статус‑код вернул внешний API. Тип ошибки в std::expected ничем не ограничен, можно использовать структуру с произвольными полями.
struct ValidationError { std::string field; std::string message; std::optional<std::string> received_value; }; struct NotFoundError { std::string entity_type; std::string identifier; }; struct ApiError { int http_status; std::string code; std::string message; std::vector<ValidationError> validation_errors; }; std::expected<int, ApiError> parse_age(const std::string& input) { if (input.empty()) { return std::unexpected(ApiError{ .http_status = 400, .code = "EMPTY_FIELD", .message = "Age cannot be empty", .validation_errors = { {"age", "Required field is missing", std::nullopt} } }); } try { int value = std::stoi(input); if (value < 0 || value > 150) { return std::unexpected(ApiError{ .http_status = 400, .code = "OUT_OF_RANGE", .message = "Age must be between 0 and 150", .validation_errors = { {"age", "Value out of range", input} } }); } return value; } catch (...) { return std::unexpected(ApiError{ .http_status = 400, .code = "INVALID_FORMAT", .message = "Age must be a valid integer", .validation_errors = { {"age", "Could not parse as integer", input} } }); } }
Структурированные ошибки удобно сериализовать в JSON‑ответы API, логировать с полным контекстом, использовать для метрик и алертов. На уровне типа сразу видно, что ошибка содержит достаточно информации для отладки.
Альтернатива через std::variant для разных типов ошибок:
using DataError = std::variant<NotFoundError, ValidationError, NetworkError>; std::expected<User, DataError> get_user(int id); void handle_result(const std::expected<User, DataError>& result) { if (result) { process(*result); return; } std::visit([](const auto& err) { using T = std::decay_t<decltype(err)>; if constexpr (std::is_same_v<T, NotFoundError>) { log_not_found(err); } else if constexpr (std::is_same_v<T, ValidationError>) { log_validation_error(err); } else if constexpr (std::is_same_v<T, NetworkError>) { log_network_error(err); } }, result.error()); }
Variant даёт гибкость иметь несколько структурно разных ошибок и обрабатывать каждую через std::visit. Это близко к тому, как работают algebraic data types в Rust и Haskell.
Совместимость с legacy‑кодом
Реальные C++‑проекты редко переписываются с нуля под std::expected. Чаще нужно вводить тип постепенно, при этом сохраняя интеграцию с кодом, который использует исключения или возврат специальных значений.
Адаптация существующей функции с исключениями в expected‑стиль через try‑catch обёртку:
// Существующая функция с исключениями User load_user_legacy(int id) { // throws UserNotFoundException auto connection = pool.acquire(); auto rows = connection.query("SELECT * FROM users WHERE id = ?", id); if (rows.empty()) { throw UserNotFoundException{id}; } return User::from_row(rows[0]); } // Обёртка в expected-стиле std::expected<User, AppError> load_user(int id) { try { return load_user_legacy(id); } catch (const UserNotFoundException&) { return std::unexpected(AppError::UserNotFound); } catch (const DatabaseException& e) { log_database_error(e); return std::unexpected(AppError::DatabaseError); } catch (const std::exception& e) { log_unexpected_error(e); return std::unexpected(AppError::UnexpectedError); } }
Обратное направление: expected‑функция вызывается из кода, который ожидает исключения.
User load_user_or_throw(int id) { auto result = load_user(id); if (!result) { switch (result.error()) { case AppError::UserNotFound: throw UserNotFoundException{id}; case AppError::DatabaseError: throw DatabaseException{}; default: throw UnexpectedError{}; } } return std::move(*result); }
Постепенный переход обычно выглядит так. Сначала новые модули пишутся в expected‑стиле с обёртками над legacy. Постепенно legacy‑функции переписываются и обёртки исчезают. На полную миграцию большого проекта уходят месяцы, и обычно остаются legacy‑углы, где исключения сохраняются по объективным причинам (стандартная библиотека сама их использует).
Где std::expected не подходит
Не каждая ошибка подходит под std::expected. Несколько категорий, где правильнее оставить исключения или другие механизмы.
Программные баги.
std::expectedне для контрактных нарушений (null‑указатель там, где он не должен быть, индекс за пределами массива). Это случаи дляassert,std::abort, или специальных исключений типаstd::logic_error. Цельstd::expected— обработка ожидаемых ошибочных ситуаций, а не баги в коде.Constructors. Конструктор не может вернуть
std::expectedнапрямую, потому что у него нет return value. Альтернатива: factory‑функция, которая возвращаетstd::expected<T, E>, плюс приватный конструктор.
class Resource { Resource(int id, std::string data) : id_(id), data_(std::move(data)) {} public: static std::expected<Resource, ResourceError> create(int id) { if (id < 0) return std::unexpected(ResourceError::InvalidId); auto data = load_data(id); if (!data) return std::unexpected(ResourceError::LoadFailed); return Resource{id, std::move(*data)}; } };
Это паттерн named constructor из C++98, который теперь приобрёл новый смысл.
Деструкторы. Деструкторы не могут возвращать значение и не должны бросать исключения. Если в деструкторе нужно сделать что‑то, что может провалиться (закрыть файл, освободить ресурс с возможной ошибкой), результат логируется или игнорируется, но не возвращается.
Out‑of‑memory.
std::bad_allocбросается из new и не может быть выражен черезstd::expected, потому что выделение памяти происходит до того, как у нас есть expected, в который что‑то поместить. На системах без OOM‑сценариев (большая часть desktop и serverside) это редкая проблема; на embedded используются специализированные аллокаторы.Внешний API с асинхронной природой.
std::expectedэто синхронный механизм. Для асинхронных операций используютсяstd::futureили корутины сco_await. С C++23 есть концепцияexpectedв корутинах через адаптер, но это отдельная большая тема.Производительность горячего пути с очень высокой частотой успешных вызовов.
std::expectedвсегда платит заstd::variant‑обёртку: проверка дискриминанта на каждый доступ, чуть больше места в памяти, чуть медленнее move/copy. Для функций, которые вызываются миллион раз в секунду и почти всегда успешны, исключения дешевле (бесплатны на happy path).
Чего ждать в C++26
C++26 (ожидается осенью 2026) расширяет работу с
std::expectedнесколькими дополнениями.Pattern matching через
inspect(если попадёт в стандарт) сделает работу сstd::variant‑ошибками радикально удобнее. Сейчас нуженstd::visitс generic‑lambda иif constexpr, в C++26 это может быть как match в Rust.Adapter
std::expected_from_optionalдля конверсииstd::optional<T>вstd::expected<T, E>с заданной ошибкой по умолчанию. Удобно для интеграции с API, возвращающим optional.Координация с корутинами через P‑предложение для
co_awaitна expected. Это позволит писать асинхронный код с обработкой ошибок в expected‑стиле без явных проверок.
Все эти возможности постепенно сужают разрыв между C++ и языками с встроенным error handling типа Rust. Если миграция на
std::expectedначинается сейчас, к выходу C++26 проект окажется готов к новым возможностям без дополнительных переписываний.
Что внедрять сегодня
Минимальный план миграции на std::expected для среднего проекта без революции:
Обновить toolchain до GCC 13+, Clang 17+ или MSVC 19.34+ для стабильной поддержки
std::expectedи monadic operations.Выбрать один новый модуль, который пишется с нуля, и сделать его полностью на
std::expected. Не трогать существующий код.На границе нового модуля с legacy‑кодом написать тонкий слой адаптеров (try‑catch обёртки в одну сторону, throw‑функции в другую). Изолировать legacy‑код от нового.
Определить иерархию типов ошибок: общие enum, специфические структуры с деталями, при необходимости variant для нескольких структурно разных ошибок. Зафиксировать в codebase guidelines.
Договориться с командой: новый код пишется на
std::expected, старый трогаем только при модификациях. Через год‑два большая часть проекта будет на новом стиле без специальных усилий по миграции.Запустить линтер и анализатор (clang‑tidy 17+ имеет проверки на правильное использование
std::expected) на полную сборку. Поправить найденные проблемы.
После такой работы код становится явнее: возможные ошибки выражены в сигнатуре функции, цепочки операций читаются как pipeline, преобразование между слоями ошибок происходит в одном месте. Производительность на error path вырастает (нет stack unwinding), на happy path сравнима или незначительно ниже (стоимость dispatch через variant).
Ошибки в сигнатурах, явные типы, RAII и аккуратные цепочки вызовов — всё это про один и тот же навык: писать C++ так, чтобы намерение кода было видно без расшифровки.
16 июля в 20:00 на занятии «Выразительный C++: кодируем намерения» разберём, как переводить комментарии и неявные договорённости в типы, сигнатуры и структуру программы. На примерах из реальных кодовых баз потренируемся делать код понятнее: от std::optional и enum class до RAII, алгоритмов STL, ranges и views. Записаться
Больше открытых уроков июля смотрите в дайджесте.
