Введение
Вопрос «как обрабатывать ошибки в C++» не имеет единственно верного ответа — в языке исторически сосуществуют три подхода: исключения (exceptions), коды возврата (error codes) и появившийся в C++23 std::expected. У каждого подхода свои достоинства, накладные расходы и область применения, и зрелый C++ разработчик выбирает инструмент осознанно, а не по привычке.
Исключения
Исключения отделяют «счастливый путь» выполнения от обработки ошибок, автоматически прокидывают ошибку через несколько уровней вызовов и хорошо сочетаются с RAII (деструкторы вызываются при раскрутке стека).
#include <iostream>
#include <stdexcept>
double divide(double a, double b)
{
if (b == 0.0) {
throw std::invalid_argument("Деление на ноль");
}
return a / b;
}
void exceptionDemo()
{
try {
std::cout << divide(10, 2) << std::endl;
std::cout << divide(5, 0) << std::endl; // бросит исключение
} catch (const std::invalid_argument& e) {
std::cerr << "Ошибка: " << e.what() << std::endl;
}
}
Минусы: издержки на throw/catch (хотя «счастливый путь» в современных компиляторах почти бесплатен — затраты возникают только при реальном выбросе), невозможность использовать в коде, скомпилированном с -fno-exceptions (встраиваемые системы, часть игровых движков), риск пропустить обработку какого-то типа исключения.
Коды возврата
Классический подход в стиле C: функция возвращает код ошибки, а результат передаётся через выходной параметр либо отдельное поле структуры.
#include <iostream>
enum class ErrorCode { Ok, DivisionByZero };
ErrorCode divideSafe(double a, double b, double& result)
{
if (b == 0.0) {
return ErrorCode::DivisionByZero;
}
result = a / b;
return ErrorCode::Ok;
}
void errorCodeDemo()
{
double result = 0.0;
ErrorCode code = divideSafe(5, 0, result);
if (code != ErrorCode::Ok) {
std::cerr << "Ошибка деления, код: " << static_cast<int>(code) << std::endl;
return;
}
std::cout << result << std::endl;
}
Плюсы: предсказуемая стоимость, нет накладных расходов на исключения, подходит для систем без исключений. Минусы: ошибку легко проигнорировать (никто не заставляет проверять возвращаемый код), сигнатуры функций «загрязняются» выходными параметрами, ошибку сложно прокинуть через несколько уровней вызовов без ручной передачи кода на каждом уровне.
std::expected (C++23)
std::expected<T, E> — это попытка взять лучшее от обоих миров: явный тип ошибки в сигнатуре функции (как код возврата), но с удобным «обёрточным» API, похожим на std::optional, и без необходимости передавать ошибку через выходные параметры.
#include <expected>
#include <iostream>
#include <string>
std::expected<double, std::string> divideExpected(double a, double b)
{
if (b == 0.0) {
return std::unexpected("Деление на ноль");
}
return a / b;
}
void expectedDemo()
{
auto result = divideExpected(10, 2);
if (result.has_value()) {
std::cout << "Результат: " << *result << std::endl;
} else {
std::cerr << "Ошибка: " << result.error() << std::endl;
}
auto failed = divideExpected(5, 0);
if (!failed) {
std::cerr << "Ошибка: " << failed.error() << std::endl;
}
}
int main()
{
exceptionDemo();
errorCodeDemo();
expectedDemo();
return 0;
}
Разбор примера
std::expected<T, E> хранит либо значение типа T (успех), либо ошибку типа E — аналогично тому, как Result работает в Rust. Метод has_value() (или просто проверка через operator bool) сообщает, был ли успех; *result/result.value() извлекает значение, result.error() — описание ошибки. Главное отличие от исключений: ошибка — часть типа возвращаемого значения, видна в сигнатуре функции и компилятор не даст случайно забыть о ней при работе с самим объектом expected (хотя забыть проверить has_value() перед обращением к значению всё же можно — это приведёт к неопределённому поведению, аналогично std::optional).
Как выбирать подход на практике
- Исключения хороши для действительно исключительных ситуаций: повреждённые данные, нарушенные инварианты, ошибки, которые приложение в большинстве случаев не ожидает и не обязано обрабатывать в каждой функции по цепочке вызовов. Хорошо сочетаются с конструкторами (у конструктора нет возвращаемого значения, поэтому сообщить об ошибке инициализации можно только исключением или флагом-валидности).
- Коды возврата разумны в горячих путях с предсказуемыми, частыми «ошибками» (например, парсер, который регулярно встречает невалидный ввод как часть нормальной работы), а также в кодовых базах без поддержки исключений.
std::expected— современная альтернатива для функций, где ошибка является ожидаемым, частым результатом (валидация ввода, парсинг, сетевые операции), но вы хотите сохранить выразительность типов и не плодить выходные параметры. Если ваш компилятор/стандартная библиотека пока не поддерживают C++23, аналоги есть в Boost (boost::outcome,boost::leaf) и в собственных небольших реализацияхExpected<T, E>.
Подводные камни
- Не смешивайте без явной политики все три подхода в одном модуле — это затрудняет понимание контракта функций.
- Исключения нельзя бросать из деструкторов в процессе раскрутки стека по другому исключению — это вызовет
std::terminate. - Коды ошибок легко игнорировать — атрибут
[[nodiscard]]на возвращаемом значении заставит компилятор предупреждать, если результат не проверен. std::expected, как иstd::optional, не защищает от обращения к значению без проверки — это всё ещё ответственность разработчика.
================================================================================