Exceções devastadoras

Mais uma vez, por que é ruim lançar exceções em destruidores


Muitos especialistas em C ++ (por exemplo, Herb Sutter ) nos ensinam que lançar exceções em destruidores é ruim, porque você pode entrar no destruidor durante a promoção da pilha com uma exceção já lançada e, se outra exceção for lançada nesse momento, std será chamado :: terminate () . O padrão de linguagem C ++ 17 (a seguir, refiro-me à versão disponível gratuitamente do rascunho N4713 ) sobre este tópico nos diz o seguinte:


18.5.1 A função std :: terminate () [except.terminate]

1 Em algumas situações, o tratamento de exceções deve ser abandonado para técnicas de tratamento de erros menos sutis. [Nota:

Essas situações são:

...

(1.4) quando a destruição de um objeto durante o desenrolamento da pilha (18.2) terminar lançando uma exceção, ou

...

- nota final]

Vamos verificar um exemplo simples:


#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 

Observe que o destruidor PrintInDestructor ainda é chamado, ou seja, depois de lançar a segunda exceção, a promoção da pilha não é interrompida. A Norma (o mesmo parágrafo 18.5.1) sobre este assunto diz o seguinte:


2 ... Na situação em que nenhum manipulador correspondente é encontrado,
é definido pela implementação se a pilha é ou não desenrolada antes que std :: terminate () seja chamado. Em
a situação em que a procura de um manipulador (18.3) encontra o bloco mais externo de uma função com um
especificação de exceção não lançadora (18.4), é definido pela implementação se a pilha é desenrolada,
desenrolou parcialmente ou não desenrolou antes que std :: terminate () seja chamado ...

Testei este exemplo em várias versões do GCC (8.2, 7.3) e Clang (6.0, 5.0), em todos os lugares em que a promoção da pilha continua. Se você se deparar com um compilador onde a definição de implementação é diferente, escreva sobre isso nos comentários.


Também deve ser observado que std :: terminate () é chamado quando a pilha é desenrolada somente quando uma exceção é lançada para fora do destruidor. Se houver um bloco try / catch dentro do destruidor que captura a exceção e não lança mais, isso não interrompe a promoção da pilha da exceção 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; } 

exibe


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

Como evitar situações desagradáveis? Em teoria, tudo é simples: nunca jogue exceções no destruidor. No entanto, na prática, não é tão simples realizar com perfeição esse requisito simples.


Se você não pode, mas realmente quer ...


Notarei imediatamente que não estou tentando justificar o lançamento de exceções ao destruidor e, seguindo Sutter, Meyers e outros gurus de C ++, peço que tente nunca fazer isso (pelo menos no novo código). No entanto, na prática real, um programador pode muito bem encontrar código legado, o que não é tão fácil de levar a altos padrões. Além disso, as técnicas frequentemente descritas abaixo podem ser úteis durante o processo de depuração.

Por exemplo, estamos desenvolvendo uma biblioteca com uma classe de wrapper que encapsula o trabalho com um determinado recurso. De acordo com os princípios da RAII, pegamos o recurso no construtor e devemos liberá-lo no destruidor. Mas e se uma tentativa de liberar um recurso falhar? Opções para resolver este problema:


  • Ignore o erro. Ruim, porque estamos escondendo um problema que pode afetar outras partes do sistema.
  • Escreva no log. Melhor do que simplesmente ignorá-lo, mas ainda assim ruim, porque nossa biblioteca não sabe nada sobre as políticas de registro adotadas no sistema que a utiliza. O log padrão pode ser redirecionado para / dev / null, como resultado, novamente, não veremos um erro.
  • Leve a liberação do recurso para uma função separada que retorne um valor ou gere uma exceção e force o usuário da classe a chamá-lo por conta própria. É ruim, porque o usuário pode se esquecer de fazer isso e receberemos um vazamento de recursos.
  • Lance uma exceção. Bom em casos comuns, como o usuário da classe pode capturar a exceção e obter informações sobre o erro da maneira padrão. Ruim durante a promoção da pilha, como leva a std :: terminate () .

Como entender se estamos atualmente no processo de promover a pilha por exceção ou não? No C ++, existe uma função especial std :: uncaught_exception () para isso . Com sua ajuda, podemos lançar uma exceção com segurança em uma situação normal ou fazer algo menos correto, mas não levando a uma exceção durante a promoção da pilha.


 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! 

Observe que a função std :: uncaught_exception () está obsoleta desde o C ++ Standard 17; portanto, para compilar o exemplo, a vorning correspondente deve ser suprimida (consulte o repositório com exemplos do artigo ).


O problema com essa função é que ela verifica se estamos girando a pilha por exceção. Mas é impossível entender se o destruidor atual é chamado durante o processo de promoção da pilha. Como resultado, se houver uma promoção de pilha, mas o destruidor de algum objeto for chamado normalmente, std :: uncaught_exception () ainda retornará 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; } 

Resultado:


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

No novo C ++ 17 Standard, a função std :: uncaught_exceptions () foi introduzida para substituir std :: uncaught_exception () (observe o plural), que em vez de um valor booleano retorna o número de exceções atualmente ativas (aqui está uma justificativa detalhada).


É assim que o problema descrito acima é resolvido com 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! 

Quando eu realmente quero lançar algumas exceções de uma só vez


std :: uncaught_exceptions () evita chamar std :: terminate () , mas não ajuda a lidar com várias exceções corretamente. Idealmente, eu gostaria de ter um mecanismo que me permita salvar todas as exceções lançadas e depois processá-las em um só lugar.


Quero lembrar mais uma vez que o mecanismo proposto por mim abaixo serve apenas para demonstrar o conceito e não é recomendado para uso em código industrial real.

A essência da idéia é capturar exceções e salvá-las em um contêiner e, em seguida, obter e processá-las uma por vez. Para salvar objetos de exceção, o C ++ possui um tipo especial std :: exception_ptr . A estrutura de tipos na Norma não é divulgada, mas é dito que é essencialmente shared_ptr por objeto de exceção.


Como então processar essas exceções? Existe uma função std :: rethrow_exception () para isso , que pega um ponteiro std :: exception_ptr e lança a exceção correspondente. Nós só precisamos pegá-lo com a seção catch correspondente e processá-lo, após o qual podemos passar para o próximo objeto de exceção.


 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 

No exemplo acima, a pilha é usada para salvar objetos de exceção, no entanto, o tratamento de exceções será realizado de acordo com o princípio FIFO (ou seja, logicamente essa é a fila - a exceção lançada primeiro será a primeira a ser processada).


Conclusões


Lançar exceções em destruidores de objetos é realmente uma péssima idéia, e em qualquer novo código eu recomendo fortemente que não faça isso declarando que não destruidores. No entanto, com o suporte e a depuração do código legado, pode ser necessário lidar corretamente com as exceções geradas pelos destruidores, inclusive durante a promoção da pilha, e o C ++ moderno nos fornece mecanismos para isso. Espero que as idéias apresentadas no artigo o ajudem nesse caminho difícil.


Referências


Repositório com exemplos do artigo

Source: https://habr.com/ru/post/pt433944/


All Articles