Обработка ошибок в 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". Это частично правда, но на практике не совсем. Что стоит учесть:
- Если ваша функция может выкинуть исключение, но этого не делает, то есть идет по "нормальному" сценарию выполнения, то в рантайме действительно оверхеда нет.
- Если же исключение выкидывается, то его обработка относительно дорогая.
- Крайне важно следующее - просто сам факт компиляции программы с включенными исключениями может замедлить программу на <= ~5%. Это не всегда происходит, но так может быть, поэтому надо замерять.
Коды ошибок, с другой стороны, чуть дороже при не-ошибочном сценарии выполнения функции. Если ваша функция часто завершается ошибкой, то такой вариант производительнее исключений.
Так что использовать?
То, какой стиль выбрать, зависит от вашей кодовой базы и какой компонент вы пишите.
Реальность такова, что с наибольшей вероятностью в вашей кодовой базе итак есть исключения, ведь они кидаются в том числе из STL. Поэтому с наибольшей вероятностью вам придется в любом случае эти исключения обрабатывать и соответствующим образом писать код программы. В таком случае мне кажется логичным использовать исключения для всего. Самое главное - следуйте RAII, и самая сложная часть обработки ошибок - сохранения инварианта программы - будет работать по умолчанию.
Есть один кейс, в котором по моему опыту исключения работают не идеально, а именно при разработке библиотеки, поставляемой внешним пользователям. Тут удобнее делать noexcept интерфейс с кодами ошибок. В рамках самой библиотеки, при этом, использовать исключения можно, но ловить их на уровне верхней функции, доступной пользователям. Логика следующая: при поставке библиотеки крайне важно, чтобы она вела себя "как ожидается". По скольку исключения не являются частью интерфейса функции, их выкидывание может быть неожиданностью даже при описании такого поведения в документации. Возврат кодов ошибок же делает интеграцию более простой и понятной для пользователей.
Еще контент
Подписывайтесь на мой телеграм канал, я выкладываю самое интересное из мира C++ и авторские статьи по типу этой: t.me/krestovii_podhod