Les exceptions
Les exceptions permettent de dévier du flot normal d'exécution, en remontant les appels de fonction jusqu'au premier bloc try / catch
, ou jusqu'au main (auquel cas le programme crash). Elles permettent donc de séparer le chemin d'exécution classique de celui du traitement des erreurs.
Stack unwinding
Lorsqu'une exception remonte, elle détruit tous les objets stockés sur la pile qu'elle croise : il s'agit du phénomène de stack unwinding. Cela permet de déclencher des actions via les destructeurs automatiquement, pour libérer de la mémoire ou fermer un fichier par exemple.
Cependant, si un destructeur lance à ce moment une exception, on se retrouve avec deux exceptions, ce qui fait crash le programme (std::terminate()
est appelé). Les destructeurs ne doivent donc jamais lancer d'exceptions.
std::set_terminate()
permet de passer une fonction qui sera appelée par std::terminate()
. Par défaut, std::abort()
est appelé.
De plus, certains ressources peuvent être perdues lorsqu'une exception est déclenchée :
Dans la plupart des autres langages, on remédierait à ce problème en plaçant dans un bloc try catch
les fonctions pouvant lancer une exception, afin de bien fermer puis détruire la session dans tous les cas :
En c++, on préfèrera gérer le cleanup automatiquement avec le destructeur d'une variable locale si on ne souhaite pas gérer à cet endroit l'erreur (et la laisser remonter) :
La notion de pile ne fait pas partie du langage. Dépasser la capacité de la pile (comme avec une récursion infinie) va donc faire crash le programme, et non lancer une exception.
Les garanties possibles
Les fonctions peuvent avoir 4 niveaux de garanties vis-à-vis des exceptions :
- No safety ne garantit rien, de façon similaire à la première version de la fonction send_resource
ci-dessus : des fuites de mémoire peuvent arriver, et des objets peuvent être dans des états invalides.
- Weak safety garantit qu'en cas d'exception, les objets sont dans un état valide, et aucune ressource n'est perdue. C'est la garantie minimale à viser lorsqu'on écrit du code. Les autres versions de send_resource
assurent cette garantie.
- Strong safety garantit qu'en cas d'exception, aucun effet visible ne s'est produit : c'est comme si on avait jamais appelé la fonction. Cela a un aspect transactionnel : soit l'opération a réussie, soit elle n'a pas eu d'effet. send_resource
ne possède pas cette garantie (pour les versions 2 et 3) : même si les fonctions open
et send
procurent elles-même cette strong safety. En effet, si send
lance une exception, open
s'est bien déroulé et a pu avoir des effets visibles.
- Nothrow garantit que la fonction ne lancera jamais d'exceptions. On marque alors la fonction noexcept, ce qui va appeler std::terminate()
si une exception est lancée dans la fonction. De plus, de légères optimisations sont parfois permises si le compilateur sait qu'une fonction ne peut pas échouer.
Par exemple, les collections de la librairie standard offrent souvent la strong safety, qui comporte quelques contraintes. Par exemple, std::vector<T>::push_back(T&&)
garantit cette strong safety : cela signifie que même si tout le vecteur doit être recréé, l'opération soit échoue complètement soit réussi complètement.
Pour que cela fonctionne, il faut alors que le constructeur par move de T
ait la garantie nothrow. Si il n'est pas marqué noexcept, std::vector<T>
va alors choisir de copier les valeurs. Move l'objet si il est noexcept, sinon le copier est un pattern courant pour garantir la strong safety, qui est facilité grâce à std::move_if_noexcept<T>(t& value).
Hiérarchie des exceptions et types d'erreurs
N'importe quelle classe peut être throw, et la clause catch(...)
les intercepte toutes. Cependant, toues les exceptions lancées par la librairie standard héritent de std::exception, qui possède une unique fonction virtuelle, char const* what() const noexcept
. Cela permet de catch std::exception
pour intercepter toutes les erreurs de la librairie standard et d'afficher leurs erreurs.
Il existe de nombreuses façons de gérer les erreurs dans un programme, et les exceptions ne sont pas toujours la meilleure solution :
Lorsque les préconditions d'une fonction ne sont pas respectées, il s'agit d'une erreur de logique, un "bug". Ce n'est généralement pas une erreur qui est récupérable (pour continuer l'exécution du programme après l'avoir catch), donc on souhaite stopper le programme rapidement, avec un appel à std::terminate()
par exemple. Cette fonction arrêtera le programme sans lancer d'exceptions, les objets ne seront donc pas détruits : on peut alors préférer lancer une exception.
Les exceptions héritant de std::logic_error
correspondent à ces erreurs logiques. Par exemple, accéder à un std::vector<T>
avec la méthode at(size_type pos)
en passant un mauvais index va lancer l'exception std::out_of_range
héritant de std::logic_error
.
La macro assert(x)
va appeler std::abort()
(ce que fait std::terminate()
par défaut) si l'expression passée n'est pas évaluée à true
. Elle est désactivable en définissant la macro NDEBUG
.
Lorsque la précondition d'une fonction est remplie mais qu'elle échoue quand même, on est dans le cas d'utilisation le plus propice aux exceptions. Par exemple, si une allocation demande trop de mémoire par rapport à celle disponible (comme avec new int[10'000'000'000]
), l'exception std::bad_alloc
sera lancée.
Beaucoup de fonctions peuvent lancer une exception juste parce qu'elles font des allocations dynamiques. Si on ne souhaite pas gérer le cas où le programme ne peut plus allouer de mémoire, on peut ignorer cette erreur (en marquant les fonctions noexcept et/ou avec une clause catch(std::bad_alloc const& e)
qui relance l'exception).
Fonctions noexcept
On peut marquer une fonction conditionnellement noexcept, pratique dans le code template :
Le mot-clé noexcept peut également vérifier si une expression ou une fonction est noexcept :
On peut donc utiliser noexcept(noexcept(expression))
pour marquer une fonction noexcept :
On a déjà vu que les destructeurs ne doivent pas lancer d'exceptions (afin de ne pas en avoir deux en même temps, ce qui appellerait std::terminate()
). De même, les constructeurs et assignements par move ne devraient pas lancer d'exceptions.
D'autres fonctions gagnent à être noexcept, comme la fonction swap (échangeant le contenu de deux objets) ou les constructeurs par défaut.
Exceptions vs codes d'erreur
De nombreuses fonctions signalent une erreur sans lancer d'exceptions, en retournant ou en modifiant un code d'erreur. Cela comporte plusieurs désavantages, notamment :
Le chemin d’exécution normal du code n'est pas séparé du traitement des erreurs
Les erreurs doivent être remontées manuellement
Les interfaces sont moins claires
L'erreur peut être ignorée si le résultat de la fonction est ignoré
Les constructeurs ou des opérateurs overload ne peuvent pas retourner de code d'erreur
Cependant, vérifier un code d'erreur est rapide, alors que le mécanisme d'exceptions est très lent. Les codes d'erreur sont donc parfois préféré lorsque l'erreur arrive souvent. Cependant, si les exceptions ne sont pas lancées, le code correspondant sera aussi (voire plus) rapide que le code équivalent retournant un code d'erreur.
De plus, utiliser les exceptions nécessite de penser aux niveaux de garantie offerts, ce qui peut compliquer le développement. Les codes d'erreurs peuvent alors être préférés dans du code ancien ne permettant pas de lancer une exception sans leak de ressources.
Pour aller plus loin
std::exception_ptr
permet de stocker une exception. C'est utilisé par exemple pour propager une exception depuis un thread : le pointeur sera ensuite vérifié depuis un autre thread, et pourra relancer l'erreur stockée.
Une proposition de système d'exceptions a été créé, pour garder le traitement des erreurs semblable aux exceptions mais obtenir les performances des codes d'erreur.
Last updated