Una vez más sobre por qué es malo lanzar excepciones en destructores
Muchos expertos en C ++ (por ejemplo, Herb Sutter ) nos enseñan que lanzar excepciones en los destructores es malo, porque puedes ingresar al destructor durante la promoción de la pila cuando ya se lanza una excepción, y si en ese momento se lanza otra excepción, se llamará std como resultado :: terminar () . El estándar de lenguaje C ++ 17 (en adelante me refiero a la versión disponible gratuitamente del borrador N4713 ) sobre este tema nos dice lo siguiente:
18.5.1 La función std :: terminate () [excepto.terminate]
1 En algunas situaciones, se debe abandonar el manejo de excepciones para técnicas de manejo de errores menos sutiles. [Nota:
Estas situaciones son:
...
(1.4) cuando la destrucción de un objeto durante el desbobinado de la pila (18.2) termina lanzando una excepción, o
...
- nota final]
Veamos un ejemplo simple:
#include <iostream> class PrintInDestructor { public: ~PrintInDestructor() noexcept { std::cerr << "~PrintInDestructor() invoked\n"; } }; 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; }
Resultado:
~ThrowInDestructor() invoked throw_int_func() invoked ~PrintInDestructor() invoked terminate called after throwing an instance of 'int' Aborted
Tenga en cuenta que todavía se llama al destructor PrintInDestructor , es decir después de lanzar la segunda excepción, la promoción de la pila no se interrumpe. La Norma (el mismo párrafo 18.5.1) sobre este tema dice lo siguiente:
2 ... En la situación en la que no se encuentra un controlador coincidente,
está definido por la implementación si la pila se desenrolla o no antes de llamar a std :: terminate (). En
la situación en la que la búsqueda de un controlador (18.3) encuentra el bloque más externo de una función con un
especificación de excepción de no lanzamiento (18.4), está definida por la implementación si la pila está desenrollada,
desenrollado parcialmente, o no desenrollado antes de que se llame a std :: terminate () ...
Probé este ejemplo en varias versiones de GCC (8.2, 7.3) y Clang (6.0, 5.0), en todas partes continúa la promoción de la pila. Si se encuentra con un compilador donde la implementación definida es diferente, escríbala en los comentarios.
También debe tenerse en cuenta que std :: terminate () se llama cuando la pila se desenrolla solo cuando se arroja una excepción del destructor. Si hay un bloque try / catch dentro del destructor que captura la excepción y no lanza más, esto no interrumpe la promoción de la pila de la excepción externa.
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; }
muestra
ThrowCatchInDestructor instance created throw_int_func() invoked Catched int in ~ThrowCatchInDestructor(): 1 ~PrintInDestructor() invoked Catched const char* exception: BANG!
¿Cómo evitar situaciones desagradables? En teoría, todo es simple: nunca arroje excepciones en el destructor. Sin embargo, en la práctica, no es tan simple darse cuenta de este requisito tan simple y elegante.
Si no puedes, pero realmente quieres ...
Notaré de inmediato que no estoy tratando de justificar el lanzamiento de excepciones desde el destructor, y siguiendo a Sutter, Meyers y otros gurús de C ++, les insto a que nunca intenten hacer esto (al menos en el nuevo código). Sin embargo, en la práctica real, un programador puede encontrar código heredado, que no es tan fácil de llevar a altos estándares. Además, las técnicas que a menudo se describen a continuación pueden ser útiles en el proceso de depuración.
Por ejemplo, estamos desarrollando una biblioteca con una clase contenedora que encapsula el trabajo con un determinado recurso. De acuerdo con los principios de RAII, tomamos el recurso en el constructor y debemos liberarlo en el destructor. Pero, ¿qué pasa si falla un intento de liberar un recurso? Opciones para resolver este problema:
- Ignora el error. Malo, porque estamos ocultando un problema que podría afectar otras partes del sistema.
- Escribe en el registro. Mejor que simplemente ignorarlo, pero sigue siendo malo, porque nuestra biblioteca no sabe nada acerca de las políticas de registro adoptadas en el sistema que lo utiliza. El registro estándar se puede redirigir a / dev / null, como resultado de lo cual, nuevamente, no veremos un error.
- Tome la liberación del recurso en una función separada que devuelva un valor o arroje una excepción, y obligue al usuario de la clase a llamarlo por su cuenta. Es malo, porque el usuario puede olvidarse de hacer esto y recibiremos una fuga de recursos.
- Lanza una excepción. Bueno en casos ordinarios, como el usuario de la clase puede detectar la excepción y obtener información sobre el error de la manera estándar. Mal durante la promoción de la pila, como conduce a std :: terminate () .
¿Cómo entender si actualmente estamos en el proceso de promover la pila por excepción o no? En C ++, hay una función especial std :: uncaught_exception () para esto . Con su ayuda, podemos lanzar de manera segura una excepción en una situación normal, o hacer algo menos correcto, pero sin generar una excepción durante la promoción de la pila.
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; }
Resultado:
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!
Tenga en cuenta que la función std :: uncaught_exception () está en desuso desde C ++ Standard 17, por lo tanto, para compilar el ejemplo, se debe suprimir el vorning correspondiente (consulte el repositorio con ejemplos del artículo ).
El problema con esta función es que verifica si estamos en el proceso de hacer girar la pila por excepción. Pero es imposible entender si se llama al destructor actual durante el proceso de promoción de la pila. Como resultado, si hay una promoción de pila, pero el destructor de algún objeto se llama normalmente, std :: uncaught_exception () aún devolverá verdadero .
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; }
Resultado:
ThrowInDestructor stack unwinding ~MayThrowInDestructor() stack unwinding, not throwing ~PrintInDestructor() invoked Catched const char* exception: BANG!
En el nuevo estándar C ++ 17, la función std :: uncaught_exceptions () se introdujo para reemplazar std :: uncaught_exception () (tenga en cuenta el plural), que en lugar de un valor booleano devuelve el número de excepciones actualmente activas (aquí hay una justificación detallada).
Así es como se resuelve el problema descrito anteriormente con 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; }
Resultado:
ThrowInDestructor stack unwinding ~MayThrowInDestructor() normal case, throwing throw_int_func() invoked Catched int in ~ThrowCatchInDestructor(): 1 ~PrintInDestructor() invoked Catched const char* exception: BANG!
Cuando realmente quiero lanzar algunas excepciones a la vez
std :: uncaught_exceptions () evita llamar a std :: terminate () , pero no ayuda a manejar varias excepciones correctamente. Idealmente, me gustaría tener un mecanismo que me permitiera guardar todas las excepciones lanzadas y luego procesarlas en un solo lugar.
Quiero recordar una vez más que el mecanismo propuesto por mí a continuación solo sirve para demostrar el concepto y no se recomienda su uso en el código industrial real.
La esencia de la idea es capturar excepciones y guardarlas en un contenedor, y luego obtenerlas y procesarlas una por una. Para guardar objetos de excepción, C ++ tiene un tipo especial std :: exception_ptr . La estructura de tipo en el Estándar no se revela, pero se dice que es esencialmente shared_ptr por objeto de excepción.
¿Cómo, entonces, procesar estas excepciones? Hay una función std :: rethrow_exception () para esto , que toma un puntero std :: exception_ptr y lanza la excepción correspondiente. Solo necesitamos atraparlo con la sección de captura correspondiente y procesarlo, después de lo cual podemos pasar al siguiente objeto de excepción.
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; }
Resultado:
~ThrowInDestructor() invoked throw_int_func() invoked ~PrintInDestructor() invoked Catched const char* exception: BANG! Catched int exception: 1
En el ejemplo anterior, la pila se usa para guardar objetos de excepción, sin embargo, el manejo de excepciones se realizará de acuerdo con el principio FIFO (es decir, lógicamente esta es la cola; la excepción lanzada por el primero se procesará primero).
Conclusiones
Lanzar excepciones en los destructores de objetos es realmente una mala idea, y en cualquier código nuevo, recomiendo encarecidamente no hacerlo declarando destructores noexcept . Sin embargo, con el soporte y la depuración del código heredado, puede ser necesario manejar correctamente las excepciones generadas por los destructores, incluso durante la promoción de la pila, y C ++ moderno nos proporciona mecanismos para esto. Espero que las ideas presentadas en el artículo te ayuden en este difícil camino.
Referencias
Repositorio con ejemplos del artículo.