Исключения удобны, пока ошибки не становятся обычной частью логики. В 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.
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‑кодом, который ожидает исключения.
Сила 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.
Реальные 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 не для контрактных нарушений (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 (ожидается осенью 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. Записаться
Больше открытых уроков июля смотрите в дайджесте.
| # | Наименование новости | Тональность | Информативность | Дата публикации |
|---|---|---|---|---|
| 1 | Когда может пригодиться экзотика в ООП: миксины/трейты/аспекты | 0 | 5 | 08-07-2026 |
| 2 | Ты не найдёшь эту ошибку. Потому что её нет в твоём коде. Как Self-describing API спасает от чужих рефакторингов | 5 | 8 | 07-07-2026 |
| 3 | Нужен ли здесь useEffect? «хороший» React-вариант и мол-вариант здесь один и тот же код | 0 | 5 | 07-07-2026 |
| 4 | Нейросеть-автопилот вместо 400 Playwright-тестов | 0 | 7 | 07-07-2026 |
| 5 | Свой VPN на Rust: как я спорил с сетью, TLS и самим собой | 7 | 8 | 27-06-2026 |
| 6 | Пускать нельзя блокировать: приносят ли ИИ-чат боты клиентов на Ваш сайт? Как ИИ ассистенты посещают сайт… | 0 | 7 | 07-07-2026 |
| 7 | 6 pro-фишек FluxCD. Выжимаем все соки из GitOps | 2 | 6 | 06-07-2026 |
| 8 | Один запрос прес лист, вот пример чека работы и отчетов ... | 5 | 7 | 29-06-2026 |
| 9 | kafkactl — другой взгляд на работу с Kafka | 0 | 5 | 30-06-2026 |