Вход на сайт

Просмотр новости

Найдите то, что Вас интересует

std::expected в C++23: гайд по миграции с исключений на функциональный error handling

Дата публикации: 07-07-2026 17:10:01

Исключения удобны, пока ошибки не становятся обычной частью логики. В C++23 для таких случаев есть std::expected: он явно показывает в сигнатуре, что функция вернёт либо результат, либо ошибку. Разберём, как перейти к этому подходу без переписывания проекта с нуля. Читать далее

Основное содержимое страницы с новостью.

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

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. Записаться

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

Схожие новости

#Наименование новостиТональностьИнформативностьДата публикации
1Когда может пригодиться экзотика в ООП: миксины/трейты/аспекты0508-07-2026
2Ты не найдёшь эту ошибку. Потому что её нет в твоём коде. Как Self-describing API спасает от чужих рефакторингов5807-07-2026
3Нужен ли здесь useEffect? «хороший» React-вариант и мол-вариант здесь один и тот же код0507-07-2026
4Нейросеть-автопилот вместо 400 Playwright-тестов0707-07-2026
5Свой VPN на Rust: как я спорил с сетью, TLS и самим собой7827-06-2026
6Пускать нельзя блокировать: приносят ли ИИ-чат боты клиентов на Ваш сайт? Как ИИ ассистенты посещают сайт…0707-07-2026
76 pro-фишек FluxCD. Выжимаем все соки из GitOps2606-07-2026
8Один запрос прес лист, вот пример чека работы и отчетов ...5729-06-2026
9kafkactl — другой взгляд на работу с Kafka0530-06-2026

Классификация: Мнения. Схожих патентов: 0. Схожих новостей: 9. Тональность: 0. Информативность: 7. Источник: habr.com.