Обработка ошибок в C++

Привет! В этой статье будут рассмотрены основные стратегии обработки ошибок.

Сишный стиль или возврат кодов ошибок

Самый простой и интуитивно понятный метод. Каждая функция, которая может завершить исполнение с ошибкой, возвращает код ошибки. Вызывающий код может либо отреагировать на ошибку, либо проигнорировать и продолжить выполнение.

enum class Error {
    Ok,
    BadInput,
    InternalError
};
Error some_func();

auto result = some_func();

switch (result) {
    case Error::Ok: succeed(); break;
    case Error::BadInput: handler_bad_input(); break;
    case Error::InternalError: handler_internal_error(); break;
}

Иногда код ошибки и результат возвращается одной переменной, но у ошибочного состояния есть какое-то определенное значение. Классический пример - std::atoi.

int atoi(const char* str);

// atoi возвращает 0, если произошла ошибка. Да, если в строке был записан 0, то atoi так же вернет 0. Это, наверно, самый популярный пример плохого дизайна в STL : )
auto str = "not a number";
auto result = std::atoi(str);
if(result == 0 && string_doesnt_contain_null(str)) {
    process_error();
} else {
    process_result(result);
}

Так же существует вариант с уведомлением об ошибке через errno. Errno это глобальная переменная, которая заполняется функцией в случае ошибки. Из-за того что она глобальная, если вы вызовете две функции подряд и обе они произведут ошибку, в errno будет записана только последняя из них. Так же в старых стандартах (до 11) она не была потокобезопасной. Хотя проблемы есть и сейчас - например, при использовании корутин, которые могут продолжать работу в другом потоке.

// пример взят с https://en.cppreference.com/w/cpp/error/errno 
const double not_a_number = std::log(-1.0);
// проверка errno после вызова функции, которая сообщает об ошибки через эту глобальную переменную
if (errno == EDOM)
{
    std::cout << "log(-1) failed: " << std::strerror(errno) << '\n';
}

плюсы

  • Выполнение не прерывается внезапно - выход из функции происходит только тогда, когда это явно прописано в теле функции. Никаких неожиданных раскручиваний стека!
  • (спорно) В случае, если нам не интересно как завершилась вызываемая функция, легко игнорировать ошибку - просто не проверяем ее возврат.
  • Локальная обработка ошибок относительно простая и не требует слишком много кода - один if statement.

минусы

  • Легко забыть обработать ошибку, потому что это никак не контролируется языком.
  • Нету опции глобальной обработки ошибки - если ошибка возможна только в глубине стека вызовов, все равно придется вставлять код для обработки ошибки на каждом уровне.
  • Сложно прокидывать контекст ошибки (то есть более детальное описание) в начало стека вызовов.
  • Более громоздкий и менее читаемый код.

Исключения

Исключения работают по следующему принципу: что-то пошло не так - прерываем выполнение выкинув функцию, а обработка произойдет уже на том уровне, где это надо. Для того, чтобы такой подход работал, все ресурсы должны быть завернуты в RAII обертки (подробнее про RAII и работу с ресурсами тут). Это надо для того, чтобы раскручивание стека при активном исключении не порождало неконсистентного состояния программы.

Рассмотрим базовый пример с выделением памяти:

template< class T, class... Args >
shared_ptr<T> make_shared( Args&&... args );

try {
    // make_shared вызывает new, а new может выкинуть исключение, если не удается выделить нужное количество памяти
    auto ptr = std::make_shared<SomeVeryBigObject>();

    do_something_useful(ptr);
} catch (const std::bad_alloc& e) {
    // если из make_shared вылетело исключение, то do_something_useful выполняться не будет, а выполнение продолжиться с тела catch
    std::cout << "Allocation failed: " << e.what() << std::endl;
}

Пример, показывающий простоту проброса ошибки из глубины стека вызовов:

void recursive(int depth) {
    if(depth == 100)
        throw 0;

    recursive(depth + 1);
}

try {
    // хотя ошибка произойдет на глубине вызовов 100, мы можем добавить код обработки только на самом верхнем уровне
    recursive(0);
} catch(...) {
    // handle
}

Пример про простоту передачи контекста:

void some_throwing_func(int arg) {
    switch (arg) {
        case 0:
            throw std::runtime_error("Bad things X happened");
        case 1:
            throw SomeOtherExceptionType("Bad things Y happened");
        default:
            ok(); break;
    }
}

try {
    some_throwing_func(some_int);
} catch(const std::exception& e) {
    // не важно, что и насколько глубоко в стеке вызовов произошло. Так как по конвенции все типы исключений наследуют от std::exception, мы сможем получить строку с описанием ошибочного состояния.
    std::cout << e.what() << std::endl;
}

По плюсам и минусам является практически антиподом сишному стилю.

плюсы

  • Ошибки нельзя игнорировать - либо имеется обработчик, либо программа завершает выполнение.
  • Можно сделать глобальный обработчик ошибок (например, на ран лупа, если речь про рабочий поток), который в одном месте будет обрабатывать все.
  • Контекст ошибки легко передавать наверх.
  • Код более читаем относительно сишного стиля, потому что не нужно добавлять if после каждого вызова практически любой функции.

минусы

  • Локальная обработка ошибок требует много кода (try+catch clauses).
  • Исключения не являются частью интерфейса функции, поэтому не всегда понятно какой тип исключения и в каких ситуациях будет выкинут.
  • Если ваша функция может выкинуть исключение, то любая функция ее вызывающая тоже будет кидать исключения.
  • Могут быть проблемы с прокидыванием исключений через границы динамической библиотеки.

Производительность

Как обычно, с производительностью все не однозначно. Исключения в C++ часто заявлены как "zero cost". Это частично правда, но на практике не совсем. Что стоит учесть:

  1. Если ваша функция может выкинуть исключение, но этого не делает, то есть идет по "нормальному" сценарию выполнения, то в рантайме действительно оверхеда нет.
  2. Если же исключение выкидывается, то его обработка относительно дорогая.
  3. Крайне важно следующее - просто сам факт компиляции программы с включенными исключениями может замедлить программу на <= ~5%. Это не всегда происходит, но так может быть, поэтому надо замерять.

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

Так что использовать?

То, какой стиль выбрать, зависит от вашей кодовой базы и какой компонент вы пишите.

Реальность такова, что с наибольшей вероятностью в вашей кодовой базе итак есть исключения, ведь они кидаются в том числе из STL. Поэтому с наибольшей вероятностью вам придется в любом случае эти исключения обрабатывать и соответствующим образом писать код программы. В таком случае мне кажется логичным использовать исключения для всего. Самое главное - следуйте RAII, и самая сложная часть обработки ошибок - сохранения инварианта программы - будет работать по умолчанию.

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

Еще контент

Подписывайтесь на мой телеграм канал, я выкладываю самое интересное из мира C++ и авторские статьи по типу этой: t.me/krestovii_podhod