Que le programme doit-il faire lorsqu'il découvre une condition d'erreur lors de son exécution: par exemple une division par 0, ou encore l'ouverture d'un fichier inexistant (ou qui ne peut pas être lu pour un problème de permissions). Le problème est généralement le suivant: l'erreur se produit dans une bibliothèque; un objet doit ouvrir un fichier dont on lui a passé le nom en paramètres. L'objet voit bien qu'il y a une erreur (il n'a pas trouvé le fichier), mais comment doit-il réagir ? En fait, il ne le sait pas... parce que ce n'est pas à lui de réagir. C'est au programme utilisateur de l'objet: suivant les cas, la réaction de celui-ci sera soit l'arrêt du programme avec impression d'un message, soit tentative de reprise après avoir demandé à l'utilisateur un nouveau nom de fichier, soit génération automatique d'un nouveau nom de fichier, ... L'objet doit donc se borner à prévenir le programme appelant qu'il y a eu une erreur. Il y a trois moyens:
errno
en C)Les deux premiers moyens présentent certains inconvénients: tout d'abord, si la fonction, dans son fonctionnement normal, doit déjà renvoyer une valeur, comment renvoyer le code d'erreur ? Par une valeur illégale peut-être, mais ce n'est pas toujours possible... ainsi une fonction qui doit renvoyer un entier ne peut renvoyer une valeur illégale. D'autre part, une bonne partie du code utilisateur risque d'être consacrée au traitement des erreurs... à condition que le programmeur ait assez de courage ou de conscience professionnelle. On estime que dans certains cas le code peut doubler simplement à cause du traitement d'erreurs. De manière plus fondamentale, on aura une totale imbrication du code de traitement d'erreurs et du code de l'application, d'où une mauvaise lisibilité du code. Le C++ offre un système d'exceptions qui améliore considérablement la situation.
Afin de bien comprendre le système des exceptions, imaginons l'administration des postes d'un pays quelconque. L'organisation est la suivante:
Il faut comprendre cette analogie de la manière suivante:
Imaginons donc le facteur, en train de distribuer le courrier. La plupart du temps, tout se passe correctement
et la mission du facteur est menée à bien. Mais quelques problèmes peuvent survenir;
par exemple, une lettre adressée à M. Dupond est notée 105 rue des Mimosas, alors que les dupond
habitent au 15 rue des Mimosas: le facteur connait le quartier, il mettra l'enveloppe dans la boîte aux lettres des
Dupond, même si l'adresse est mauvaise. Il s'agit d'un cas d'erreur qui a pu être corrigé par le facteur - par
l'objet. Rien ni personne ne sera au courant qu'il y a eu un problème avec cette enveloppe.
Autre problème possible: une lettre est adressée à M. Durand, or il n'y a pas de M. Durand
dans le quartier. Cette fois, le facteur mettra la lettre dans une boîte appelée "Adresses inconnues",
et il continuera sa tournée.
Voilà que le facteur tombe sur une lettre qui comporte la bonne adresse, mais il s'agit d'une rue
située dans un autre quartier: le facteur mettra la lettre dans une nouvelle boîte, appelée
"autres quartiers".
A la fin de sa tournée, le facteur regarde l'état de ses deux boîtes: si
elles sont vides, il rentre chez lui tout simplement. La méthode "facteur" a fait son travail sans histoire.
Si au moins l'une des deux est pleine, le facteur, avant de rentrer chez lui, va déposer à un endroit
réservé à cet usage, au bureau de postes, le ou les cartons contenant les lettres en cause: il
"lance une exception"; celle-ci sera traitée soit au niveau du bureau de poste du quartier, soit au niveau
supérieur; mais en aucun cas le facteur ne prend de décision à propos de cette lettre:
ce n'est tout simplement pas son travail.
Le bureau de poste de quartier, voyant qu'il y a une "exception", va
alors la traiter: si l'adresse située sur la lettre "autres secteurs" correspond à un secteur
géré par ce bureau de poste, il suffira de la donner à un autre facteur pour que le problème
soit résolu. L'erreur a été corrigée au niveau Bureau de Poste, et personne à un
plus haut niveau n'en saura rien. Sinon, le bureau de poste la renvoie à l'échelon supérieur
(régional) qui se chargera du problème, à moins qu'il ne le renvoie à nouveau à un
échelon supérieur...
C'est un système analogue qui est employé par le C++ pour traiter les exceptions:
Il est possible de renvoyer ainsi n'importe quel objet, et de mettre donc dans cet objet n'importe quelle information: un code d'erreur, par exemple, avec une chaîne de caractères explicative, mais aussi des données (d'autres objets, par exemple) permettant aux niveaux supérieurs de traiter effectivement l'exception.
Lorsque nous avons défini un tableau, les différents constructeurs ou opérator= appelaient une fonction malloc, mais ne vérifiaient jamais si l'allocation se faisait effectivement. Voici un constructeur:
tableau::tableau(int t): taille(t) { buffer = malloc(t * sizeof(char)); };
Il n'y a aucun traitement d'erreur, si malloc
ne fonctionne pas (par exemple parce qu'il n'y a pas assez de mémoire dans la machine), on ne
le détectera pas et on sera confronté à un plantage aléatoire. Voici une première manière d'introduire un traitement d'erreur:
tableau::tableau(int t): taille(t) { buffer = malloc(taille * sizeof(char)); if (buffer == nullptr) { throw ("malloc ne marche pas"); } copie(b); };
La fonction se contente de "lancer" un const char*
. Celui-ci sera "rattrapé" par une fonction située dans la pile d'appels (c'est-à-dire
la fonction appelante, ou la fonction ayant appelé la fonction appelante, etc.) par exemple la fonction main, dont voici une première implémentation:
int main() { ... try { cout << "Entrez une taille souhaitée: "; cin >> taille; tableau t1(taille); } catch ( const char * c ) { cout << c << "\n"; } return 0; }
La fonction main
a "attrapé" l'objet envoyé (ici un const char *
) et l'a simplement affiché. La version suivante va plus loin: elle
demander à l'utilisateur de rentrer une taille correcte.
int main() { ... do { try { size_t taille; cout << "Entrez une taille souhaitée: "; cin >> taille; tableau t1(taille); // faire un truc avec le tableau ... // sortir de la boucle break; } catch ( const char * msg ) { cout << msg << " Recommencez avec une taille plus petite\n"; } } while (true); return 0; }
On voit ici que si le traitement de l'erreur (dans la fonction main) a changé, la génération de l'erreur, elle, est la même. Le code suivant montre
une troisième manière de procéder: tout le traitement d'erreur se fait ici au niveau de la fonction creation_tableau
, du coup main n'a plus à faire de traitement d'erreur:
... tableau creation_tableau() { do { try { size_t taille; cout << "Entrez une taille souhaitée: "; cin >> taille; tableau t(taille); return t; } catch ( const char * msg ) { cout << msg << " Recommencez avec une taille plus petite\n"; } } while (true); } int main() { tableau t = creation_tableau(); ... }
Plutôt que d'envoyer directement des chaines de caractère, il est beaucoup plus riche d'encapsuler ces messages dans des objets. On peut bien sûr définir ses propres exceptions, mais il est plus simple d'utiliser les exceptions déjà définies dans la bibliothèque standard du C++. Si vous préférez définir des objets exceptions, faites-les dériver de l'une de ces classes (ne serait-ce que la classe exception).
La figure ci-dessous montre les différentes exceptions définies dans la bibliothèque standard, anisi que les liens d'hritage qui les relient.
La classe de base (exception
) possède une méthode abstraite: what()
, qui renvoie le message d'erreur encapsulé par l'objet.
Lors du throw, on pourra donc générer un message d'erreur suffisamment précis pour que le diagnostic de l'erreur soit aisé.
Nom | Dérive de | Constructeur | Signification |
---|---|---|---|
exception | exception() | Toutes les exceptions dérivent de cette classe | |
bad_alloc | exception | bad_alloc() | Problème d'allocation mémoire, peut être lancée par l'opérateur new |
ios_base::failure | exception | failure(const string&) | Problème d'entrées-sorties, peut être lancée par les fonctions d'entrées-sorties |
runtime_error | exception | runtime_error(const string&) | Erreurs difficiles à éviter, en particulier dans des programmes de calcul. |
range_error | runtime_error | range_error(const string&) | Erreur dans les valeurs retournées lors d'un calcul interne |
overflow_error | runtime_error | overflow_error(const string&) | Dépassement de capacité lors d'un calcul (nombre trop gros) |
underflow_error | runtime_error | underflow_error(const string&) | Dépassement de capacité lors d'un calcul (nombre trop proche de zéro) |
logic_error | exception | logic_error(const string&) | Erreur dans la logique interne du programme (devraient être évitables) |
domain_error | logic_error | domain_error(const string&) | Erreur de domaine (au sens mathématique du terme). Exemple: division par 0 |
invalid_argument | logic_error | invalid_argument(const string&) | Mauvais argument passé à une fonction |
length_error | logic_error | length_error(const string&) | Vous avez voulu créer un objet trop gros pour le système (par exemple une chaîne plus longue que std::string::max_size() |
out_of_range | logic_error | out_of_range(const string&) | Par exemple: "index inférieur à 0" pour un tableau |
Il est très simple d'utiliser ces exceptions dans votre programme. L'opérateur précédent peut être réécrit de la manière suivante:
tableau::tableau(int t): taille(t) { buffer = malloc(taille * sizeof(char)); if (buffer == nullptr) { bad_alloc e; throw (e); } };
ou encore, de manière plus concise:
tableau::tableau(int t): taille(t) { buffer = malloc(taille * sizeof(char)); if (buffer == nullptr) { throw bad_alloc(); } };
La fonction creation_tableau
s'écrira comme indiqué ci-dessous. Si une exception de type bad_alloc
est attrapée
par la fonction, elle la traite. Si une autre exception dérivant du type générique exception
est émise,
elle ne sera pas attrapée par creation_tableau
, mais elle sera traitée de manière générique par main
.
tableau creation_tableau() { do { try { size_t taille; cout << "Entrez une taille souhaitée: "; cin >> taille; tableau t(taille); return t; } catch ( bad_alloc ) { cout << msg << " Recommencez avec une taille plus petite\n"; } } while (true); } int main() { try { tableau t = creation_tableau(); ... } catch ( const exception& e ) { cout << e.what() << "\n"; } }
En
fait, plusieurs programmes de capture d'exceptions auraient pu être
écrits, suivant la finesse avec laquelle on veut traiter les
exceptions:
bad_alloc
exception
Il est donc important de passer l'objet exception par
const exception &
,
afin de s'assurer que le bon objet sera au final utilisé (notamment la bonne version
de la fonction what()
).
Il est plus simple d'utiliser les exception prédéfinies, néanmoins il est possible de redéfinir
ses propres exception. Dans ce cas, il est
important de les définir de manière hiérarchique, et
de préférence comme des classes dérivées de la classe
exception
. Cela permet en effet le traitement
hiérarchisé des exceptions, ainsi qu'on vient de le voir.
Que se passe-t-il si une exception
domain_error
est générée dans la fonction create_tableau
? Elle ne sera pas traitée: à la place,
elle sera transmise à la fonction appelante, et ainsi de suite jusqu'à main
.
Si main
ne prévoit aucune capture
d'exception, l'exception se terminera par un arrêt du programme. Il est cependant possible de prévoir simplement
un traitement d'erreur pour les exceptions non prévues:
try { blabla } catch (const & domain_error e) { blabla } catch (const & bad_alloc e) { blabla } catch (...) { cout << "Autre exception\n"; }; }
Pour ajouter le nom du fichier et le numéro de la ligne, on peut utiliser la macro __LINE__ et la __FILE__. Attention, __LINE__ renvoie un entier. En C il faut passer par deux macros (voir ici), en C++11 on a plus de chance: on peut passer par un string et par la fonction to_string:
#include <stdexcept> ... throw(runtime_error(static_cast<string>("ERREUR - y a qqchose qui cloche - Fichier ") + __FILE__ + ":" + to_string(__LINE__));
Contrairement à java, python ou perl, Il n'est pas évident d'afficher la pile d'appels lorsqu'une exception est générée: le C++ est un langage compilé, et les symboles sont en général absents de l'exécutable. La manière la plus évidente de procéder est d'utiliser le programme à travers un débogueur (gdb par exemple); il est possible d'afficher la pile d'appels sans passer par le débogueur. Cependant, cela nécessite de faire appel à des primitives système, qui dépendent du compilateur: ce code ne sera par définition pas portable.
Le programme suivant, que vous pouvez télécharger et utiliser dans vos propres applications, vous offre une solution à ce problème, utilisable exclusivement avec gcc sous unix. On a défini une nouvelle exception, qui dérive de runtime_error, et qui formatte la pile d'appels dans son constructeur, de sorte que la pile d'appels est automatiquement affichée lors de l'exécution de la méthode what(). Cet objet repose sur les fonctions suivantes, de la bibliothèque de gnu:
Le programme peut être téléchargé ici:
Le système des exceptions est le système de
traitement d'erreurs à employer pour des constructeurs d'objet,
à l'exclusion de tout autre: on pourrait par
exemple imaginer une variable err
qui indiquerait que
l'objet est construit, certes, mais dans un état "bizarre", donc pas
vraiment utilisable. C'est ce qu'on appelle les "objets zombies"...
cela peut conduire à des comportements inattendus (variables internes
non initialisées, par exemple). Si le constructeur est interrompu par une exception, l'objet ne sera pas
construit du tout... Or, un vrai mort vaut mieux qu'un faux zombie, qui
ira prétendre le contraire ?
Le système des exceptions est le système de
traitement d'erreurs à ne pas employer avec les destructeurs:
en effet, un destructeur peut être appelé lors du déroulement normal
du programme; mais il peut aussi être appelé lors de la génération
d'une autre exception. Dans ce cas, le programme sera
immédiatement arrêté.
Evidemment, rien n'empêche un destructeur
d'appeler des fonctions qui, elles, sont suceptibles de générer une
exception. Mais dans ce cas, ces appels de fonction doivent être
encadrés par des blocs try...catch
, et aucune
exception ne doit s'échapper du destructeur. Cela signifie que
les destructeurs, s'ils ont une erreur à faire remonter, devront
trouver un autre système. Par exemple écrire sur une fenêtre ou dans
un fichier de log.
Cette dissymétrie peut paraître surprenante à
première vue... mais en fait, en informatique comme dans la vie, il
est bien plus simple de détruire que de construire: on peut avoir du mal à construire une maison, rien ne devrait
pouvoir vous empêcher de la détruire... De même, le constructeur peut
rencontrer un grand nombre de problèmes (ressources impossibles à
trouver, par exemple), mais normalement le destructeur ne devrait
pas générer d'erreur... ou alors, c'est grave, car cela signifie
que le système refuse de récupérer une ressource.
Si une fonction ne peut pas générer d'exceptions, il est important de le signaler: cela se fait par l'utilisation du mot-clé noexcept qui se place dans la déclaration du prototype, à la fin de celui-ci:
void ma_fonction(float x) noexcept;
Pourquoi est-ce important ? Cela ne changera rien à la logique de votre code, mais cette mention informe le compilateur qu'il a le droit d'utiliser certaines optimisations, sans casser le déroulement du code.
En particulier il est très important d'utiliser noexcept lorsque vous déclarez un opérateur de déplacement ou un constructeur de déplacement .