Привет, Хабр!

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

  1. Сила std::expected раскрывается через monadic operations: методы and_then, or_else, transform, transform_error. Они позволяют строить цепочки преобразований без явных проверок на каждом шаге.

  2. and_then(f) вызывает функцию f на значении, если оно есть. f должна возвращать новый std::expected<U, E> с тем же типом ошибки. Если в исходном expected была ошибка, она пробрасывается без вызова f.

  3. transform(f) похож на and_then, но f возвращает обычное значение U, а не std::expected<U, E>. Результат заворачивается в std::expected<U, E> автоматически.

  4. or_else(f) вызывается на ошибке, позволяет восстановиться от неё или преобразовать в другую ошибку. f принимает ошибку и возвращает std::expected<T, G>.

  5. 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

  1. C++26 (ожидается осенью 2026) расширяет работу с std::expected несколькими дополнениями.

  2. Pattern matching через inspect (если попадёт в стандарт) сделает работу с std::variant‑ошибками радикально удобнее. Сейчас нужен std::visit с generic‑lambda и if constexpr, в C++26 это может быть как match в Rust.

  3. Adapter std::expected_from_optional для конверсии std::optional<T> в std::expected<T, E> с заданной ошибкой по умолчанию. Удобно для интеграции с API, возвращающим optional.

  4. Координация с корутинами через P‑предложение для co_await на expected. Это позволит писать асинхронный код с обработкой ошибок в expected‑стиле без явных проверок.

Все эти возможности постепенно сужают разрыв между C++ и языками с встроенным error handling типа Rust. Если миграция на std::expected начинается сейчас, к выходу C++26 проект окажется готов к новым возможностям без дополнительных переписываний.

Что внедрять сегодня

Минимальный план миграции на std::expected для среднего проекта без революции:

  1. Обновить toolchain до GCC 13+, Clang 17+ или MSVC 19.34+ для стабильной поддержки std::expected и monadic operations.

  2. Выбрать один новый модуль, который пишется с нуля, и сделать его полностью на std::expected. Не трогать существующий код.

  3. На границе нового модуля с legacy‑кодом написать тонкий слой адаптеров (try‑catch обёртки в одну сторону, throw‑функции в другую). Изолировать legacy‑код от нового.

  4. Определить иерархию типов ошибок: общие enum, специфические структуры с деталями, при необходимости variant для нескольких структурно разных ошибок. Зафиксировать в codebase guidelines.

  5. Договориться с командой: новый код пишется на std::expected, старый трогаем только при модификациях. Через год‑два большая часть проекта будет на новом стиле без специальных усилий по миграции.

  6. Запустить линтер и анализатор (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. Записаться

Больше открытых уроков июля смотрите в дайджесте.

Комментарии (12)


  1. Dhwtj
    07.07.2026 17:20

    Нет оператора ? , поэтому вложенные цепочки быстро превращаются в лапшу из лямбд.

    Но вообще хорошо язык пошёл развиваться


    1. eoanermine
      07.07.2026 17:20

      Отсутствие ? можно компенсировать наличием корутин в языке. Один из примеров того, как это можно реализовать: https://gist.github.com/jemand2001/adb0cd09460bdcc44ca50e346eb7599c


  1. MegaHard
    07.07.2026 17:20

    Если оценивать по трём параметрам: размер кода, понятность и скорость работы, то особых улучшений не видно. Если ошибка является нормальным результатом работы функции, то каждый раз заполнять для неё целую структуру выглядит накладно. В этом смысле сишники давно изобрели errno, который можно и вообще не проверять, если уверен в результате. Цепочку лямбд неудобно отлаживать, и выглядит такой код не очевидно, и места они занимают много.


    1. domix32
      07.07.2026 17:20

      Цепочку лямбд неудобно отлаживать, и выглядит такой код не очевидно, и места они занимают много.

      Ну это несколько странная телега. То что вы можете писать лямбды не требует от вас это делать - пишите обычные функции. Ну и отлаживать код с исключениями не сказать чтобы сильно проще, особенно в многопотоке.

      В этом смысле сишники давно изобрели errno

      оно на маленьком коде может и хорошо, но иметь некоторое глобальное состояние да ещё и в многопотоке, довольно заметный минус для оптимизатора - появляются неоптимизируемые индирекции. Ну и как в си гадать от кого из стека прилетело -1 вместо того чтобы явно видеть DbError::AccessDenied имеет свои следствия для безопасности финального кода.


  1. eao197
    07.07.2026 17:20

    Исключения дороги по производительности на throw‑path, требуют exception‑safe‑кода во всей цепочке вызовов, плохо взаимодействуют с многопоточностью

    Не устану повторять, что в С++ принципы обеспечения exception safety прекрасно работают и для обычных return-ов. Поэтому если разработчик озадачился вопросами exception safety, то нет разницы, сообщается ли об ошибке через exception или через early return.


  1. hiewpoint
    07.07.2026 17:20

    Спасибо за честную оценку ситуации, когда ошибка - исключительное явление, а не регулярное событие: ¨Для функций, которые вызываются миллион раз в секунду и почти всегда успешны, исключения дешевле (бесплатны на happy path).¨ Это надо учесть всем, кто задумывается о переходе на std::expected.


  1. ImagineTables
    07.07.2026 17:20

    А будет режим, когда код не скомпилируется, пока не учтёшь возможные ошибки? Я так понимаю, в Rust это главная фишка. А то, вот это:

    value() бросает исключение std::bad_expected_access если в expected лежит ошибка

    Всю идею обесценивает. Такого быть просто не должно, компилятор должен за этим следить. Или, хотя бы, #pragma с форсом такого поведения добавить.


    1. maaGames
      07.07.2026 17:20

      По идее, если всем этим функциям добавлять аттрибут [[nodiscard]] , то проигнорировать ошибку будет сложнее. С другой стороны, там же value() берётся, а на ошибку проверить могут и забыть...


    1. redfox0
      07.07.2026 17:20

      В Rust .unwrap() на ошибке тоже паникует. Думаю, статические анализаторы смогут отловить отсутствие проверки перед вызовом. @Andrey2008?


  1. redfox0
    07.07.2026 17:20

    Я извиняюсь, но разве в первом примере не надо добавить noexcept?

    std::expected<int, ParseError> parse_positive_int(const std::string& input) noexcept {
        //...
    


    1. domix32
      07.07.2026 17:20

      noexcept по-большому счёту несёт справочных характер для компилятора. Если бы там гнались за перформансом, то по-хорошему стоило бы указать, но статья не про это и по большому счёту принципиальной разницы это не добавляет.


  1. sceptizator
    07.07.2026 17:20

    Хорошая попытка, но нет.

    Почти идеальная реализация ошибок могла бы выглядеть примерно так, через structured binding (или destructuring, кому как нравится):

    do { 
        auto [result, error] = TheService::callService(params);
        switch (error) { 
            case TheService::ErrorCodes::TIMEOUT_REACHED: 
                System::fatal("TheService Not Available"); 
            case TheService::ErrorCodes::RETRY: 
                System::sleep(TheService::DEFAULT_EXT_CALL_TIMEOUT); 
                break; 
            case TheService::ErrorCodes::OK: 
                return result;
        }    
    } while (true);        

    Код условный, реальный чуть посложнее будет. Но ключевой момент тут показан: TheService::ErrorCodes это enum c конечным числом значений. И если функция TheService::callService начинает еще какой-то четвертый тип ошибок возвращать (добавили еще одно значение в enum), то компилятор нам сразу высветит все switch места, где нет case обработки этого нового типа ошибки, и их нужно срочно пойти дописать.

    А лямбдо-монады - это лишь синтаксический сахар, да есть есть весьма широкая категория любителей так спагетти-кодировать, и наверное нужно уважать их культурные отличия. Но проблему расширения контрактов, в данном случае возможных кодов ошибок, и проверку их соблюдения компилятором (покрытия всех возможных кодов) все эти чудесные трудно читабельные формы callback-ов не решают от слова никак, нужно идти и вычитывать код каждый раз.