Хабрахабр

[Из песочницы] Разрушительные исключения

Ещё раз о том, почему плохо бросать исключения в деструкторах

Стандарт языка C++17 (здесь и далее я ссылаюсь на свободно доступную версию драфта N4713) на эту тему сообщает нам следующее: Многие знатоки C++ (например, Герб Саттер) учат нас, что бросать исключения в деструкторах плохо, потому что в деструктор можно попасть во время раскрутки стека при уже выброшенном исключении, и если в этот момент будет выброшено ещё одно исключение, в результате будет вызван std::terminate().

5. 18. 1 The std::terminate() function [except.terminate]

[ Note: 1 In some situations exception handling must be abandoned for less subtle error handling techniques.

These situations are:

4) when the destruction of an object during stack unwinding (18. (1. 2) terminates by throwing an exception, or

— end note ]

Проверим на простом примере:

#include <iostream> class PrintInDestructor }; void throw_int_func() { std::cerr << "throw_int_func() invoked\n"; throw 1;
} class ThrowInDestructor { public: ~ThrowInDestructor() noexcept(false) { std::cerr << "~ThrowInDestructor() invoked\n"; throw_int_func(); } private: PrintInDestructor member_; }; int main(int, char**) { try { ThrowInDestructor bad; throw "BANG!"; } catch (int i) { std::cerr << "Catched int exception: " << i << "\n"; } catch (const char* c) { std::cerr << "Catched const char* exception: " << c << "\n"; } catch (...) { std::cerr << "Catched unknown exception\n"; } return 0; }

Результат:

~ThrowInDestructor() invoked
throw_int_func() invoked
~PrintInDestructor() invoked
terminate called after throwing an instance of 'int'
Aborted

после выбрасывания второго исключения раскрутка стека не прерывается. Обратите внимание, что деструктор PrintInDestructor всё же вызывается, т.е. 5. В Стандарте (тот же самый пункт 18. 1) на эту тему сказано следующее:

In
the situation where the search for a handler (18. 2… In the situation where no matching handler is found,
it is implementation-defined whether or not the stack is unwound before std::terminate() is called. 4), it is implementation-defined whether the stack is unwound,
unwound partially, or not unwound at all before std::terminate() is called ... 3) encounters the outermost block of a function with a
non-throwing exception specification (18.

2, 7. Я проверял этот пример на нескольких версиях GCC (8. 0, 5. 3) и Clang (6. Если вы встретите компилятор, где implementation-defined по-другому, пожалуйста, напишите об этом в комментариях. 0), везде раскрутка стека продолжается.

Если внутри деструктора находится try/catch блок, который ловит исключение и не пробрасывается дальше, это не приводит к прерыванию раскрутки стека внешнего исключения. Следует заметить также, что std::terminate() при раскрутке стека вызывается только тогда, когда исключение выбрасывается наружу из деструктора.

class ThrowCatchInDestructor { public: ~ThrowCatchInDestructor() noexcept(false) { try { throw_int_func(); } catch (int i) { std::cerr << "Catched int in ~ThrowCatchInDestructor(): " << i << "\n"; } } private: PrintInDestructor member_; }; int main(int, char**) { try { ThrowCatchInDestructor good; std::cerr << "ThrowCatchInDestructor instance created\n"; throw "BANG!"; } catch (int i) { std::cerr << "Catched int exception: " << i << "\n"; } catch (const char* s) { std::cerr << "Catched const char* exception: " << s << "\n"; } catch (...) { std::cerr << "Catched unknown exception\n"; } return 0; }

выводит

ThrowCatchInDestructor instance created
throw_int_func() invoked
Catched int in ~ThrowCatchInDestructor(): 1
~PrintInDestructor() invoked
Catched const char* exception: BANG!

В теории всё просто: никогда не бросать исключения в деструкторе. Как же избегать неприятных ситуаций? Однако, на практике красиво и изящно реализовать это простое требование бывает не так просто.

Если нельзя, но очень хочется...

Тем не менее, программист в реальной практике вполне может столкнуться с legacy-кодом, который не так просто привести к высоким стандартам. Сразу отмечу, что я не пытаюсь оправдать выбрасывание исключений из деструктора, и вслед за Саттером, Мейерсом и другими гуру C++ призываю вас постараться никогда этого не делать (по крайней мере, в новом коде). Кроме того, зачастую описанные ниже методики могут пригодиться в процессе отладки.

В соответствии с принципами RAII мы захватываем ресурс в конструкторе и должны освободить его в деструкторе. Например, мы разрабатываем библиотеку с классом-обёрткой, инкапсулирующей работу с неким ресурсом. Варианты решения этой проблемы: Но что если попытка освободить ресурс заканчивается ошибкой?

  • Проигнорировать ошибку. Плохо, потому что мы скрываем проблему, которая может повлиять на другие части системы.
  • Написать в лог. Лучше, чем просто проигнорировать, но всё равно плохо, т.к. наша библиотека ничего не знает о политиках логирования, принятых в системе, которая её использует. Стандартный лог может быть перенаправлен в /dev/null, в результате чего, опять же, ошибку мы не увидим.
  • Вынести освобождение ресурса в отдельную функцию, которая возвращает значение или бросает исключение, и заставлять пользователя класса вызывать её самостоятельно. Плохо, потому что пользователь вообще может забыть это сделать, и мы получим утечку ресурса.
  • Выбросить исключение. Хорошо в обычных случаях, т.к. пользователь класса может поймать исключение и стандартным образом получить информацию о возникшей ошибке. Плохо во время раскрутки стека, т.к. приводит к std::terminate().

В C++ для этого есть специальная функция std::uncaught_exception(). Как же понять, находимся ли мы в данный момент в процессе раскрутке стека по исключению или нет? С её помощью мы можем безопасно кидать исключение в обычной ситуации, либо делать что-либо менее правильное, но не приводящее к выбросу исключения во время раскрутки стека.

class ThrowInDestructor { public: ~ThrowInDestructor() noexcept(false) { if (std::uncaught_exception()) { std::cerr << "~ThrowInDestructor() stack unwinding, not throwing\n"; } else { std::cerr << "~ThrowInDestructor() normal case, throwing\n"; throw_int_func(); } } private: PrintInDestructor member_; }; int main(int, char**) { try { ThrowInDestructor normal; std::cerr << "ThrowInDestructor normal destruction\n"; } catch (int i) { std::cerr << "Catched int exception: " << i << "\n"; } try { ThrowInDestructor stack_unwind; std::cerr << "ThrowInDestructor stack unwinding\n"; throw "BANG!"; } catch (int i) { std::cerr << "Catched int exception: " << i << "\n"; } catch (const char* s) { std::cerr << "Catched const char* exception: " << s << "\n"; } catch (...) { std::cerr << "Catched unknown exception\n"; } return 0; }

Результат:

ThrowInDestructor normal destruction
~ThrowInDestructor() normal case, throwing
throw_int_func() invoked
~PrintInDestructor() invoked
Catched int exception: 1
ThrowInDestructor stack unwinding
~ThrowInDestructor() stack unwinding, not throwing
~PrintInDestructor() invoked
Catched const char* exception: BANG!

репозитарий с примерами из статьи). Обратите внимание, что функция std::uncaught_exception() является deprecated начиная со Стандарта C++17, поэтому чтобы скомпилировать пример, соответствующий ворнинг приходится подавлять (с.м.

Но вот понять, вызван ли текущий деструктор в процессе раскрутки стека, с помощью этой функции невозможно. Проблема с этой функцией в том, что она проверяет находимся ли мы в процессе раскрутки стека по исключению. В результате, если происходит раскрутка стека, но деструктор какого-то объекта вызывается нормальным образом, std::uncaught_exception() всё равно вернёт true.

class MayThrowInDestructor { public: ~MayThrowInDestructor() noexcept(false) { if (std::uncaught_exception()) { std::cerr << "~MayThrowInDestructor() stack unwinding, not throwing\n"; } else { std::cerr << "~MayThrowInDestructor() normal case, throwing\n"; throw_int_func(); } } }; class ThrowCatchInDestructor { public: ~ThrowCatchInDestructor() noexcept(false) { try { MayThrowInDestructor may_throw; } catch (int i) { std::cerr << "Catched int in ~ThrowCatchInDestructor(): " << i << "\n"; } } private: PrintInDestructor member_; }; int main(int, char**) { try { ThrowCatchInDestructor stack_unwind; std::cerr << "ThrowInDestructor stack unwinding\n"; throw "BANG!"; } catch (int i) { std::cerr << "Catched int exception: " << i << "\n"; } catch (const char* s) { std::cerr << "Catched const char* exception: " << s << "\n"; } catch (...) { std::cerr << "Catched unknown exception\n"; } return 0; }

Результат:

ThrowInDestructor stack unwinding
~MayThrowInDestructor() stack unwinding, not throwing
~PrintInDestructor() invoked
Catched const char* exception: BANG!

В новом Стандарте C++17 на замену std::uncaught_exception() была представлена функция std::uncaught_exceptions() (обратите внимание на множественное число), которая вместо булевого значения возвращает количество активных в данный момент исключений (вот подробное обоснование).

Вот как описанная выше проблема решается при помощи std::uncaught_exceptions():

class MayThrowInDestructor { public: MayThrowInDestructor() : exceptions_(std::uncaught_exceptions()) {} ~MayThrowInDestructor() noexcept(false) { if (std::uncaught_exceptions() > exceptions_) { std::cerr << "~MayThrowInDestructor() stack unwinding, not throwing\n"; } else { std::cerr << "~MayThrowInDestructor() normal case, throwing\n"; throw_int_func(); } } private: int exceptions_; }; class ThrowCatchInDestructor { public: ~ThrowCatchInDestructor() noexcept(false) { try { MayThrowInDestructor may_throw; } catch (int i) { std::cerr << "Catched int in ~ThrowCatchInDestructor(): " << i << "\n"; } } private: PrintInDestructor member_; }; int main(int, char**) { try { ThrowCatchInDestructor stack_unwind; std::cerr << "ThrowInDestructor stack unwinding\n"; throw "BANG!"; } catch (int i) { std::cerr << "Catched int exception: " << i << "\n"; } catch (const char* s) { std::cerr << "Catched const char* exception: " << s << "\n"; } catch (...) { std::cerr << "Catched unknown exception\n"; } return 0; }

Результат:

ThrowInDestructor stack unwinding
~MayThrowInDestructor() normal case, throwing
throw_int_func() invoked
Catched int in ~ThrowCatchInDestructor(): 1
~PrintInDestructor() invoked
Catched const char* exception: BANG!

Когда очень-очень хочется выбросить сразу несколько исключений

В идеале хотелось бы иметь механизм, который позволял бы сохранять все выброшенные исключения, а затем обработать их в одном месте. std::uncaught_exceptions() позволяет избежать вызова std::terminate(), но не помогает корректно обрабатывать множественные исключения.

Хочу ещё раз напомнить, что предложенный мной ниже механизм служит только для демонстрации концепции и не рекомендован к использованию в реальном промышленном коде.

Для того, чтобы сохранять объекты исключений, в языке C++ есть специальный тип std::exception_ptr. Суть идеи состоит в том, чтобы ловить исключения и сохранять их в контейнер, а затем по одному доставать и обрабатывать. Структура типа в Стандарте не раскрывается, но говорится, что это по сути своей shared_ptr на объект исключения.

Для этого есть функция std::rethrow_exception(), которая принимает указатель std::exception_ptr и выбрасывает соответствующее исключение. Как же потом обработать эти исключения? Нам нужно только поймать его соответствующей catch-секцией и обработать, после чего можно переходить к следующему объекту исключения.

using exceptions_queue = std::stack<std::exception_ptr>; // Get exceptions queue for current thread
exceptions_queue& get_queue() { thread_local exceptions_queue queue_; return queue_;
} // Invoke functor and save exception in queue
void safe_invoke(std::function<void()> f) noexcept { try { f(); } catch (...) { get_queue().push(std::current_exception()); }
} class ThrowInDestructor { public: ~ThrowInDestructor() noexcept { std::cerr << "~ThrowInDestructor() invoked\n"; safe_invoke([]() { throw_int_func(); }); } private: PrintInDestructor member_; }; int main(int, char**) { safe_invoke([]() { ThrowInDestructor bad; throw "BANG!"; }); auto& q = get_queue(); while (!q.empty()) { try { std::exception_ptr ex = q.top(); q.pop(); if (ex != nullptr) { std::rethrow_exception(ex); } } catch (int i) { std::cerr << "Catched int exception: " << i << "\n"; } catch (const char* s) { std::cerr << "Catched const char* exception: " << s << "\n"; } catch (...) { std::cerr << "Catched unknown exception\n"; } } return 0; }

Результат:

~ThrowInDestructor() invoked
throw_int_func() invoked
~PrintInDestructor() invoked
Catched const char* exception: BANG!
Catched int exception: 1

логически это очередь — выброшенное первым исключение будет первым же и обработано). В примере выше для сохранения объектов исключений используется стек, однако обработка исключений будет производиться по принципу FIFO (т.е.

Выводы

Однако, при поддержке и отладке legacy-кода может возникнуть потребность корректно обрабатывать исключения, выбрасываемые из деструкторов, в том числе и при раскрутке стека, и современный C++ предоставляет нам механизмы для этого. Выбрасывание исключений в деструкторах объектов действительно является плохой идеей, и в любом новом коде я настоятельно рекомендую этого не делать, объявляя деструкторы noexcept. Надеюсь, представленные в статье идеи помогут вам на этом нелёгком пути.

Ссылки

Репозитарий с примерами из статьи

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть