Заметки по языку с и с++

Обработка ошибок в C++: исключения vs коды возврата vs std::expected

Введение

Вопрос «как обрабатывать ошибки в 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, не защищает от обращения к значению без проверки — это всё ещё ответственность разработчика.

================================================================================