В С++ есть такая замечательная конструкция catch(...). С её помощью можно перехватить любое брошенное исключение, даже не зная его тип. Очень удобно. Но у такого подхода есть и серьёзный недостаток. Сегодня расскажу какой.
Недостаток блока catch(...)
Рассмотрим небольшой пример, в котором, как представляется, конструкция catch(...)очень даже кстати.
void foo() noexcept
{
try
{
//do something
}
catch(const std::exception &e)
{
std::cerr << "Error " << e.what() << std::endl;
}
catch(...)
{
std::cerr << "Unknown error" << std::endl;
}
}
Оставим за скобками тот факт, что операции с std::cerr тоже могут бросить исключение. Для простоты считаем, что код в приведённых обработчиках catch никогда не бросает исключений.
Также считаем, что весь вывод в std::cerr автоматически перенаправляется в некий лог файл. Добиться этого можно разными способами. В этом посте я бы хотел сосредоточиться на другом.
Вроде бы всё отлично. Если в нашем коде будет брошено исключение, унаследованное от std::exception, то в лог будет выведена информация об этом исключении. Если же будет брошено какое-то другое исключение, то в лог будет выведена строка "Unknown error". И в этом основная проблема.
Такое сообщение ничего не говорит о произошедшей ошибке. Вот увидели вы его в логе, и что? Как понять в чем истинная причина ошибки? Откуда оно прилетело? Где и кем оно было брошено? Не забываем, что при поиске подходящего обработчика происходит раскрутка стека вызовов функций. Из-за этого место запуска исключения может быть очень далеко от того места, где оно было перехвачено.
Хорошо, какие у нас есть альтернативы?
Получаем подробности об ошибке
Начать следует с вопроса: а откуда вообще может взяться исключение, не являющееся потомком std::exception? Все классы исключений стандартной библиотеки являются его потомками. Но мы ведь используем не только стандартную библиотеку. Существует огромное количество сторонних библиотек. Автор одной из них вполне мог реализовать свою иерархию классов исключений. То, что эта иерархия не связана с std::exception очень спорное, но вполне возможное решение. В этом случае пример выше можно переписать так:
void foo() noexcept
{
try
{
//do something
}
catch(const std::exception &e)
{
std::cerr << "Error " << e.what() << std::endl;
}
catch(const custom_library::ExceptionBase &e)
{
std::cerr << "Error " << e.GetError() << std::endl;
}
}
Здесь в случае возникновения исключения в библиотеке custom_library в лог будет выведена подробная информация о нём. Обладая этой информацией мы уже сможем предпринять какие-либо шаги по устранению ошибки. В случае же малоинформативного "Unknown error" (как в первом примере) мы ничего сделать не можем. Так как это сообщение вообще ничего не говорит о точном месте и причинах ошибки.
Теперь предположим, что автор библиотеки объелся белены и создал классы исключений так, что они не образуют какую-либо иерархию. То есть у нас нет класса ExceptionBase. Тут резонно спросить, а нам точно нужна эта библиотека? Может ну её?
Хорошо, допустим нам кровь из носу нужна именно эта библиотека со всеми её приколами. Как быть? В этом случае я бы посоветовал подумать над реализацией специального враппера. Примерно такого вида:
template<typename F, typename... Args>
auto call_custom_library_function(F f, Args&&... args)
-> decltype(auto)
{
try
{
return std::invoke(f, std::forward<Args>(args)...);
}
catch(const custom_library::Exception1 &e)
{
std::string msg = "custom_library::Exception1, ";
msg += e.getError();
throw std::runtime_error{msg};
}
catch(const custom_library::Exception2 &e)
{
std::string msg = "custom_library::Exception2, ";
msg += e.getError();
throw std::runtime_error{msg};
}
}
Его идея в том, что все исключения из библиотеки custom_library, возникающие при вызове функций этой библиотеки, принудительно приводятся к иерархии от std::exception. Конечно реализовать его можно иначе. Тут большой простор для разных вариантов. Но главное в том, что в логе мы увидим конкретное описание ошибки, а не туманное "Unknown error".
Исключение, не являющееся потомком std::exception может появляться и в нашем коде. Если мы сами так напишем. Да, язык позволяет это делать. Но я вас очень прошу, не надо так делать. Не усложняйте себе жизнь на ровном месте.
Главный посыл этого раздела в том, что в любом более или менее нормально спроектированном коде вы никогда не должны оказаться в блоке catch(...). Здесь я имею ввиду блок, осуществляющий именно фиксацию ошибки, её логирование. В этом блоке вам нечего фиксировать, у вас нет никакой информации об ошибке. Поэтому и делать в этом блоке вам нечего.
Да, существуют ситуации, в которых блок catch(...) нужен. О них я расскажу ниже.
std::terminate
Вообще главная причина добавления блока catch(...) это страх перед std::terminate. Если в первом примере мы не перехватим брошенное исключение, будет вызван std::terminate, который просто прибьёт нашу программу. Да, вы можете изменить его поведение, но вернуть программу к её нормальному функционированию вы все равно не сможете.
На самом деле мы имеем выбор между двумя плохими вариантами.
Допустим мы оставили блок catch(...). При хорошем раскладе мы в него никогда не попадём. Но вот однажды мы в него всё-таки попали. Что мы имеем? В логе мы видим "Unknown error". То есть в программе произошла какая-то непредвиденная ситуация, но мы не знаем какая именно. Вдруг она начала форматировать жесткий диск? Или портить данные в базе данных? Я конечно утрирую, но все же.
Причем здесь еще вопрос когда мы обнаружим эту запись в логе. Системы непрерывного мониторинга логов и автоматического уведомления об ошибках используются далеко не везде.
Другой вариант. Без этого блока. В этом случае приложение аварийно завершится. Да, это может привести к порче данных. Но, во-первых, при правильном подходе вероятность этого можно уменьшить. А, во-вторых, это все равно безопаснее чем оставлять программу работающей в неизвестно каком ошибочном состоянии.
Дополнительно к этому проведенный мной небольшой эксперимент на godbolt показал, что компиляторы gcc и clang при крахе приложения выводят тип исключения, которое привело к вызову std::terminate. Ниже приводится пример такого вывода.
terminate called after throwing an instance of 'std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >'
Да, они не показывают где именно это произошло, но тип исключения (std::string в примере) — это уже что-то. За это уже можно попробовать зацепиться.
Плюс в зависимости от того, как происходит запуск вашего приложения на целевой машине, у вас может быть создана его «корка» (core dump). Из неё (при удачном стечении обстоятельств) можно вытащить полный стек вызовов и другую информацию о состоянии программы в момент аварийного завершения работы.
Компилятор от компании Microsoft такой информативностью нас к сожалению не балует. Вот его скупой вывод из того же эксперимента.
Program returned: 3221226505
С учетом вышеизложенного первоначальный пример можно улучшить.
void foo() noexcept
{
try
{
//do something
}
catch(const std::exception &e)
{
std::cerr << "Error " << e.what() << std::endl;
}
catch(...)
{
std::cerr << "Unknown fatal error in foo function"
<< std::endl;
throw;
}
}
Если ваш компилятор поддерживает C++23, то можно еще лучше
void foo() noexcept
{
try
{
//do something
}
catch(const std::exception &e)
{
std::cerr << "Error " << e.what() << std::endl;
}
catch(...)
{
std::cerr << std::stacktrace::current() << std::endl;
throw;
}
}
В этом случае дополнительно к типу исключения вы будете видеть место где оно было перехвачено, или даже стек вызовов до этого места.
Где блок catch(…) нужен
Мне в голову приходит всего две ситуации, в которых блок catch(...) действительно нужен. Первая — это проброс исключения на верх.
void foo()
{
try
{
//do something
}
catch(...)
{
//do something
throw;
}
}
В этом случае нам не важен тип исключения. Его полноценная обработка происходит где-то в другом месте.
Вторая ситуация, это «гашение» любых исключений. В этом случае крах приложения от std::terminate значительно хуже последствий возможных ошибок, от неперехваченных исключений.
MyClass::~MyClass() noexcept
{
try
{
//do something
}
catch(...)
{
//do nothing
}
}
Пример такой ситуации — не удалось закрыть сокет. Да, это неприятно. Да, это утечка ресурсов. Но это еще не повод аварийно останавливать всё приложение. Тем более что для обнаружения таких проблем существуют другие инструменты.
Заключение
Проблема блока catch(...) в том, что при неправильном использовании он скрывает информацию об ошибке и оставляет приложение работать в неизвестном состоянии. Иногда это допустимо, а иногда — нет.
В этом посте я осветил саму проблему и поделился своими идеями по поводу того как её можно купировать.
Я ни в коем случае не призываю вас отказаться от блока catch(...). Нет. Но при его использовании вы должны учитывать все связанные с ним риски.
