| auteur : Aurélien Regat-Barrel |
Les exceptions sont un nouveau moyen de gérer les erreurs dans les programmes. La grande différence vis à vis du classique code d'erreur renvoyé par une fonction est qu'une exception se propage depuis l'appelé vers l'appelant jusqu'à ce qu'elle rencontre un bloc de code qui s'occupe de la traiter. Au contraire d'un code d'erreur qui peut être ignoré (ce qui est malheureusement souvent le cas) une exception doit être traitée. Si elle ne l'est pas dans la fonction qui en est à l'origine, elle doit l'être dans l'une des fonctions appelantes. Le compilateur s'occupe tout seul de faire en sorte que l'exception remonte le long de la pile des appels jusqu'à l'endroit où un bloc a été prévu pour la traiter. Cela permet donc de facilement faire "remonter" les erreurs des fonctions appelées vers les fonctions appelantes. L'apparition d'une exception interrompt l'exécution normale du programme et provoque sa reprise dans le gestionnaire d'exception le plus proche (qui peut se trouver beaucoup plus en amont dans une fonction appelante).
Le programmeur n'a donc plus à se soucier de tester la réussite ou non des fonctions qu'il appelle au moyen d'un grand nombre de tests comme dans l'exemple suivant :
bool F1 ();
bool F2 ();
bool F3 ();
bool F4 ();
bool Test1 ()
{
if ( ! F1 () )
{
return false ;
}
if ( ! F2 () )
{
return false ;
}
return true ;
}
bool Test2 ()
{
if ( ! Test1 () )
{
return false ;
}
if ( ! F3 () )
{
return false ;
}
return true ;
}
bool Test3 ()
{
if ( ! Test2 () )
{
return false ;
}
if ( ! F4 () )
{
return false ;
}
return true ;
}
int main ()
{
if ( ! Test3 () )
{
std:: cerr < < " Une erreur est survenue, mais je ne sais pas où ! " .
}
}
|
Les exceptions permettent de grandement simplifier le code précédent tout en améliorant la qualité du renseignement sur l'origine de l'erreur :
void F1 ();
void F2 ();
void F3 ();
void F4 ();
void Test1 ()
{
F1 ();
F2 ();
}
void Test2 ()
{
Test1 ();
F3 ();
}
void Test3 ()
{
Test2 ();
F4 ();
}
int main ()
{
try
{
Test3 ();
}
catch ( const std:: bad_alloc & )
{
std:: cerr < < " Erreur : mémoire insuffisante.\n " :
}
catch ( const std:: out_of_range & )
{
std:: cerr < < " Erreur : débordement de mémoire.\n " :
}
}
|
|
| auteur : Aurélien Regat-Barrel |
Le code susceptible de déclencher des exceptions doit être placé dans un bloc try...catch (essaye...attrape) de cette manière :
int * ptr;
try
{
ptr = new int [ 100 ];
}
catch ( const std:: bad_alloc & )
{
}
|
On peut mettre autant de blocs catch qu'il y a d'exceptions à rattraper.
Mauvais chaînage des blocs catch | try
{
std:: vector< int > tableau ( 10 );
tableau.at ( 10 );
}
catch ( const std:: exception & Exp )
{
std:: cerr < < " Erreur : " < < Exp.what () < < " .\n " ;
}
catch ( const std:: bad_alloc & )
{
std:: cerr < < " Erreur : mémoire insuffisante.\n " ;
}
catch ( const std:: out_of_range & )
{
std:: cerr < < " Erreur : débordement de mémoire.\n " ;
}
|
Les exceptions levées dans le bloc try vont être filtrées par les différents blocs catch suivant leur ordre d'apparition. Ce filtrage est effectué en fonction du type de l'exception, et est naturellement sensible au polymorphisme. C'est-à-dire que le premier bloc catch rencontré capable de traiter l'exception levée est celui qui est utilisé. Les autres sont ignorés, même si certains seraient mieux adaptés. En l'occurrence, dans l'exemple précédent, l'exception out_of_range est levée et ce serait tout naturellement le dernier bloc catch qui devrait la traiter. Mais std::out_of_range dérive de std::exception qui est la classe de base pour les exceptions standards, et donc c'est le premier bloc catch qui est appelé.
Il existe aussi un moyen d'attraper toutes les exceptions, en utilisant une ellipse (...) comme type de l'exception. Mais alors il n'y a aucun moyen de connaître l'origine et le type de l'exception (sauf à la relancer et la traiter dans un nouveau bloc try...catch). L'utilisation de cette forme générique doit être restreinte car elle ne permet de savoir si l'exception capturée peut être traitée et ignorée ou si elle nécessite de terminer le programme (corruption de la mémoire, etc...). On l'utilise en général comme dernier recours.
try
{
std:: vector< int > tableau ( 10 );
tableau.at ( 10 );
}
catch ( const std:: bad_alloc & )
{
std:: cerr < < " Erreur : mémoire insuffisante.\n " ;
}
catch ( const std:: out_of_range & )
{
std:: cerr < < " Erreur : débordement de mémoire.\n " ;
}
catch ( const std:: exception & Exp )
{
std:: cerr < < " Erreur : " < < Exp.xhat () < < " .\n " ;
}
catch ( ... )
{
std:: cerr < < " Erreur inconnue.\n " ;
}
|
Il est donc important de faire apparaître les blocs catch des classes dérivées en premier.
Notez que les exceptions sont récupérées par référence, comme cela est expliqué dans la question Pourquoi faut-il capturer les exceptions par référence ?. Ces références ne sont cependant pas des références sur l'objet initial qui est à l'origine de l'exception, mais sur une copie de celui-ci, car l'objet initial risque d'être détruit si l'on quitte la fonction qui a levé l'exception.
|
| auteur : Aurélien Regat-Barrel |
Comme discuté dans la question Comment lever une exception ?, il est fortement recommandé de lever des exceptions par valeur. En revanche, il vaut mieux les capturer par référence et non pas par valeur. Tout d'abord cela permet d'éviter une recopie, mais aussi et surtout cela permet de conserver le polymorphisme. L'exemple suivant illustre les problèmes posés par un traitement des exceptions par valeur :
# include <iostream>
# include <stdexcept>
int main ()
{
using namespace std;
try
{
throw logic_error ( " exception de test " );
}
catch ( exception e )
{
cerr < < e.what ();
}
}
|
Cet exemple affiche le message Unknown exception. Si l'on remplace le traitement par valeur par un traitement par référence :
catch ( const exception & e )
|
Alors on obtient le message attendu exception de test. Ceci est dû au fait que le polymorphisme nécessite d'utiliser un pointeur ou une référence, autrement dans notre cas l'objet de type std::logic_error est "tronqué" en un objet de type std::exception. La fonction membre what appelée n'est donc pas celle de std::logic_error mais celle de std::exception, qui n'est pas d'une grande utilité.
Donc à moins de rechercher volontairement ce comportement, il est recommandé de traiter les exceptions par référence, de préférence constantes afin de permettre au compilateur d'effectuer des optimisations.
|
| auteur : LFE |
Malheureusement, non, ce mécanisme n'est pas possible. Un catch ne pouvant capturer qu'un seul type d'exceptions, il faut définir autant de
blocs try/catch qu'il y a d'exceptions possibles.
L'utilisation de
permet de capturer toutes les exceptions pouvant survenir, mais il est, dans ce cas, impossible de faire la distinction.
|
| auteur : Aurélien Regat-Barrel |
Le mot-clé throw permet de lever une nouvelle exception, mais aussi de relancer celle qui est en cours de traitement.
# include <iostream>
# include <stdexcept>
void Test ()
{
try
{
throw std:: logic_error ( " Exception de test " );
}
catch ( const std:: logic_error & e )
{
std:: cerr < < " L'exception ' " < < e.what ()
< < " ' a été levée et va être relancée.\n " ;
throw ;
}
}
int main ()
{
try
{
Test ();
}
catch ( const std:: logic_error & e )
{
std:: cerr < < " Erreur : " < < e.what () < < " .\n " ;
}
}
|
|
| auteur : Aurélien Regat-Barrel |
Lorsqu'une exception est déclenchée, le compilateur recherche un bloc catch capable de la traiter. S'il n'en trouve pas, il remonte la pile d'exécution (déroulage de la pile) afin d'en trouver un plus en amont dans la hiérarchie des appels. Dépiler un appel revient à quitter une fonction. A cette occasion ses objets locaux sont détruits et les destructeurs appelés, ce qui permet de quitter proprement la fonction en libérant toutes les ressources acquises si les destructeurs on été bien écrits. L'objet qui a servi à lever l'exception est lui même détruit car il est local à la fonction. C'est pourquoi l'objet qui est transmis aux blocs catch est toujours une copie de l'objet initial qui a déclenché l'exception.
Si la pile des appels est vidée (donc que l'on est arrivé à main) et qu'aucun bloc catch satisfaisant n'a été trouvé, la fonction standard terminate est appelée ce qui provoque par défaut un arrêt pur et simple du programme. Ce comportement peut être modifié au moyen de la fonction set_terminate définie dans l'en-tête standard <exception>. Cette fonction installe un nouveau gestionnaire et renvoie l'adresse du précédent. Elle s'utilise de cette manière :
# include <iostream>
# include <exception>
void (* old_handler)();
void my_handler ()
{
std:: cerr < < " Exception inattendue.\n " ;
(old_handler)();
}
int main ()
{
old_handler = set_terminate ( my_handler );
throw " test " ;
}
|
Le code précédent provoque le résultat suivant avec le compilateur Visual C++ 7.1 :
Exception inattendue.
This application has requested the Runtime to terminate it in an unusual way.
Please contact the application's support team for more information.
|
| auteur : Aurélien Regat-Barrel |
N'importe quel type de base ou classe C++ peut être utilisé comme type d'exception. Mais il est préférable de créer son type qui hérite de la classe de base standard pour les exceptions : std::exception définie dans l'en-tête <exception>. Cette classe possède une fonction membre virtuelle what qu'il convient de redéfinir :
# include <iostream>
# include <sstream>
# include <exception>
class my_exception : public std:: exception
{
public :
my_exception ( const char * Msg, int Line )
{
std:: ostringstream oss;
oss < < " Erreur ligne " < < Line < < " : "
< < Msg;
this - > msg = oss.str ();
}
virtual ~ my_exception () throw ()
{
}
virtual const char * what () const throw ()
{
return this - > msg.c_str ();
}
private :
std:: string msg;
} ;
int main ()
{
try
{
throw my_exception ( " exception test " , __LINE__ );
}
catch ( const std:: exception & e )
{
std:: cerr < < e.what () < < " \n " ;
}
}
|
Cet exemple produit le résultat suivant :
Erreur ligne 29 : exception test
|
| auteurs : Aurélien Regat-Barrel, Luc Hermitte |
Tout à fait. C'est même une des seules manières d'indiquer que l'initialisation de l'objet a échoué. Il faut cependant être prudent car une exception levée dans un constructeur peut être à l'origine de fuites de mémoires ou d'autres problèmes de non libération de ressources. C'est le cas dans l'exemple suivant :
Exemple de mauvaise gestion d'exceptions | class Test
{
public :
Test ( int A, int B ) :
tableau1 ( 0 ),
tableau2 ( 0 )
{
this - > tableau1 = new int [ A ];
this - > tableau2 = new int [ B ];
}
~ Test ()
{
delete [] this - > tableau2;
delete [] this - > tableau1;
}
private :
int * tableau1;
int * tableau2;
} ;
|
L'idée du code ci-dessus est d'initialiser les pointeurs tableau1 et tableau2 à zéro ainsi si l'une des allocations échoue on peut tout de même appeler en toute sérénité delete [] dans le destructeur et ainsi éviter les fuites de mémoire (souvenez vous, appeler delete sur un pointeur nul ne fait rien, voir Que se passe-t-il si je fais un delete sur un pointeur qui vaut NULL ?).
Le problème est que si une exception est levée lors de la construction d'un objet, c'est donc que celle-ci a échoué, et donc que l'objet n'est pas créé. Comme il n'est pas créé, il n'a pas à être détruit, et donc son destructeur ne sera pas appelé. Autrement dit, si une exception est levée dans le constructeur d'un objet, son destructeur ne sera pas appelé.
Il faut donc toujours s'assurer que le code contenu dans le constructeur est exception safe, c'est-à-dire qu'il résiste aux exceptions en ne provoquant pas de pertes de ressources. Dans notre exemple précédent cela signifie qu'il faut gérer l'exception bad_alloc de cette manière :
class Test
{
public :
Test ( int A, int B ) :
tableau1 ( 0 )
{
try
{
this - > tableau1 = new int [ A ];
this - > tableau2 = new int [ B ];
}
catch ( const std:: bad_alloc & )
{
delete [] this - > tableau1;
throw ;
}
}
~ Test ()
{
delete [] this - > tableau2;
delete [] this - > tableau1;
}
private :
int * tableau1;
int * tableau2;
} ;
|
Le nouveau code produit toujours une exception bad_alloc en cas d'échec d'allocation, mais cette fois-ci il n'y a plus de fuite de mémoire. Ce qui a pu être alloué est libéré, et l'exception bad_alloc capturée est relancée via l'instruction throw (à ce sujet lire Comment relancer une exception que l'on a capturé ?).
Il est à noter que si en cas d'exception dans le constructeur le destructeur n'est pas appelé, tous les membres construits jusqu'au point de l'exception sont quant à eux bien détruits.
|
| auteur : Aurélien Regat-Barrel |
Il est possible de lever une exception dans un destructeur, mais c'est extrêmement déconseillé et considéré comme une très mauvaise pratique. La raison en est simple : si la destruction d'un objet échoue, que faut-il faire ? Mais aussi un autre problème plus grave peut apparaître. Lorsqu'une exception est levée, la pile des appels est remontée (on parle de stack unwinding ou déroulage de la pile) et à cette occasion les objets locaux de la fonction que l'on s'apprête à quitter sont détruits. Donc leur destructeur respectif est appelé. Si l'un d'entre eux vient à lever une exception, la situation devient alors très complexe : laquelle des deux exceptions faut-il gérer ? N'oubliez pas qu'à ce moment nous ne sommes toujours pas dans un bloc catch, mais en train de nous y rendre en quittant les fonctions appelées qui nous en sépare.
Or, en quittant l'une d'entre elles, on détruit un objet qui lance une nouvelle exception, et nous nous retrouvons alors avec deux exceptions à traiter en même temps. La situation étant insoluble, la norme définit que la fonction standard terminate est appelée dans un tel cas, ce qui provoque la fin brutale du programme.
Pour cette très bonne raison, il est important que les destructeurs ne lèvent jamais d'exceptions. On peut s'en assurer en appelant uniquement des fonctions n'échouant jamais (voir Comment indiquer qu'une fonction ne lève jamais d'exception ?).
|
lien : Question de la C++ FAQ Lite ayant inspiré cette réponse
|
| auteur : Aurélien Regat-Barrel |
Pour indiquer qu'une fonction ne lève jamais d'exceptions (ce qui est important si on veut l'appeler depuis un destructeur par exemple (voir Peut-on lever des exceptions dans les destructeurs ?), on rajoute l'instruction throw () à la suite du prototype de la fonction de cette façon :
Attention à bien faire en sorte qu'aucune exception ne soit effectivement levée, car la norme dit que si tel était le cas la fonction standard unexpected serait appelée, ce qui se traduirait par un arrêt brutal du programme.
|
| auteur : Aurélien Regat-Barrel |
Dans certains langages (tels que Java ou C#), il est possible de créer un bloc finally à la suite d'un bloc try...catch afin de s'assurer
qu'une opération (de libération de ressource par exemple) soit bien effectuée. Par exemple, en C++ on ne peut pas écrire ceci :
char * buffer = new char [ 100 ];
try
{
}
finally
{
delete [] buffer;
}
|
l'équivalent de l'écriture ci-dessus serait :
char * buffer = new char [ 100 ];
try
{
}
catch ( ... )
{
delete [] buffer;
throw ;
}
delete [] buffer;
|
Mais il ne s'agit là que d'une traduction en C++ d'une approche issue d'un autre langage. Or, quand on programme dans un langage, il
convient de le faire selon les concepts propres à ce langage, et non avec ceux d'un autre. L'approche C++ à ce problème consiste à
encapsuler cette gestion au sein d'un objet qui s'assurera dans son destructeur de la bonne libération de la ressource qu'il gère.
Ainsi, on est assuré de ne pas avoir de fuite même en cas d'exception, tout en ayant une écriture plus légère car le bloc try...catch devient
inutile dans ce cas.
Ce principe s'appelle le RAII, et est développé dans la question Comment gérer proprement des allocations / désallocations de ressources ? Le RAII !.
|
Consultez les autres F.A.Q.
|
|