Quelques raccourcis :
Cet article porte sur la question des exceptions : ce qu'elles sont, quand et comment les lever, quand et comment les attraper, quand ne pas y avoir recours, etc. Ce document peut être vu comme un complément aux textes sur la pratique de la programmation de même qu'à celui sur les schémas de conception, tous deux plutôt généraux.
La question des exceptions entraîne une ferveur quasi-religieuse chez bien des gens. Je ne suis pas très dogmatique sur la question, je dois l'avouer, alors il est possible que certaines propositions dans ce qui suit vous choquent. Prenez ce qui vous convient, donc, et réfléchissez au reste, quitte à ce que ce ne soit que pour développer un argumentaire à l'effet contraire de ce que j'aurai avancé et qui vous aura fait réagir.
J'ai participé à des débats publics en 2015 sur la question de la qualité du code généré par les compilateurs lors de levées d'exceptions. J'ai tiré quelques chiffres sommaires (texte en anglais) qui n'ont pas de prétention autre que soulever un problème, et qui ont alimenté des échanges houleux dans Internet, à CppCon 2015 et à la rencontre du WG21 à Kona.
Sachez que j'utilise des exceptions dans mon propre code; je suis d'avis que c'est une bonne pratique dans plusieurs cas, bien qu'il ne s'agisse pas d'une panacée. Je pense toutefois que le problème de vitesse est bien réel et qu'il mérite qu'on s'y penche.
Vous trouverez une introduction quelque peu simpliste à la gestion d'exceptions en C++ dans l'article ../Divers--cplusplus/CPP--Exceptions.html. Il s'agit toutefois d'un texte axé sur la pratique plus que sur la réflexion. Je me propose ici d'y aller sous un autre angle.
Comme bien des mécanismes, les exceptions ne sont pas nécessaires à la programmation... mais elles le sont presque. Les principes de base, exprimés de manière générale, sont les suivants :
En effet, prenons l'exemple à droite. Bien que simple, il illustre la simplicité d'un programme s'exécutant dans des conditions « normales » ou exemptes de cas inhabituels ou erronés (de cas littéralement « exceptionnels »). On présume ici :
|
|
Quels sont les cas hors-normes dont nous pourrions devoir nous préoccuper ici? On peut en imaginer plusieurs, dont :
Une validation préventive des valeurs (une sorte d'anti-encapsulation, où le code client devrait valider les éventuels états de ses objets) pourrait, s'exprimer de plusieurs manières, toutes aussi mauvaises les unes que les autres.
Si la validation des bornes minimale et maximale de la hauteur et de la largeur sont faites de manière individuelle et explicite par le code client, alors le code deviendrait l'étrange créature à droite. On constate sans peine le poids du traitement des cas d'erreurs, qui occupe huit instructions (si on ne compte pas le if qui valide la lecture des entiers à proprement dit) en comparaison avec celui du code véritablement souhaité (trois instructions, incluant la construction du Rectangle lui-même). La complexité accrue n'est qu'une partie du problème ici. Bien pire est le fait qu'il y a un réel risque que, en multipliant les validations dans le code client, on en vienne à manquer un cas (ou plus), rendant du même coup le code plus fragile dans son ensemble. |
|
La complexité peut se réduire si des services de la classe Rectangle sont utilisés et si nous limitons nos efforts à identifier une erreur de bornes, sans distinguer les cas où le débordement serait du côté de la valeur minimale ou de la valeur maximale. Ceci nous permet tout de même de couvrir des cas où les politiques de validité des états seraient plus complexes qu'une simple validation du respect des bornes minimale et maximale – pensez par exemple à un Rectangle dont la largeur et la hauteur devraient être des entiers impairs positifs, ce que la validation initiale dans le code client ne couvrait pas. |
|
Une autre approche possible, mais qui brise l'encapsulation, serait de laisser le Rectangle se créer même avec des intrants invalides, mais de faire en sorte que ce Rectangle soit alors illégal et reconnaissable comme tel. Le problème est qu'il est alors difficile de garantir que tout code client prendra soin de valider chaque Rectangle avant de s'en servir. Une telle approche est foncièrement fragile... et est nuisible du côté des performances à l'exécution, les tests de validité d'un Rectangle devant être faits partout – normalement, l'encapsulation nous permet de garantir que si un Rectangle existe, c'est qu'il est dans un état correct. Si nous adoptons les exceptions, le code demeure simple. |
|
Un programme qui ne désire pas se préoccuper des cas d'exceptions possibles demeure simple, tel que dans notre cas initial (répété ici pour fins de clarté). Notons que si une exception est levée dans un constructeur, alors l'objet n'a jamais été construit. Si nous atteignons l'affichage des états de r sur cout, alors en vertu de l'encapsulation, nous savons que r est dans un état correct. Un programme qui désirerait gérer les cas d'exceptions possibles pourrait aussi le faire, mais hors de la séquence normale des opérations du programme. On essaie d'abord de faire le code jugé « normal », puis on enchaîne avec le traitement des cas qui nous semblent pertinents. Ici, j'ai supposé des classes internes HauteurInvalide et LargeurInvalide dans Rectangle et capables d'indiquer la valeur posant problème, mais en pratique j'utilise surtout des classes vides. |
|
De façon optionnelle, il est possible d'attraper « toute autre exception » par la notation reposant sur une ellipse, le catch(...). Quelle est l'utilité première de catch(...)? Le catch(...), ou Catch-Any, est particulièrement utile dans un cas où l'on manipule une entité polymorphique ou générique. En effet, dans un tel cas, il se peut qu'une exception a priori imprévisible soit levée; la fonction manipulant l'entité ayant levé l'exception ne peut alors pas, en général, prévoir les cas d'exceptions qui lui seront levés, mais peut tout de même avoir quelques opérations à réaliser pour finaliser ses ressources. Par exemple :
Cet extrait de code n'est pas un exemple à suivre (ici, utiliser std::vector<T> aurait été une nettement préférable à utiliser un tableau brut!), mais il nous servira à titre d'illustration. L'allocation dynamique du tableau p de n éléments du type T peut lever une exception (probablement un std::bad_alloc) si la mémoire disponible ne suffit pas à allouer un bloc de n * sizeof(T) bytes consécutifs en mémoire. Dans un tel cas, rien de spécial à faire, et mieux vaut laisser sortir l'exception vers le code client, dans un souci de neutralité. L'initialisation du tableau alloué dynamiquement implique appeler T::T() pour les n éléments, ce peut lever toute exception possible dans le constructeur par défaut de T. Du point de vue de notre fonction, il est a priori impossible de savoir quelles exceptions peuvent être levées, mais ici encore, si une exception est levée, mieux vaut la laisser simplement s'envoler vers le code client, qui pourra en tirer un diagnostic. Lors de l'appel à std::fill(), par contre, qui appelle l'affectation d'un T à un T pour chaque élément du tableau, une levée d'exception serait un désastre puisque la mémoire du tableau (dont nous sommes alors responsables) ne serait pas libérée, et puisque les objets dans le tableau ne seraient alors jamais finalisés. Si T est une connexion à une base de données, les conséquences risquent d'être des plus déplaisantes. Une implémentation plus correcte serait :
Ici, le catch(...) permet de saisir n'importe quoi, le temps de nettoyer un peu, alors que throw; (le Re-Throw) permet de laisser sortir précisément ce qui a été intercepté. Évidemment, avec un pointeur intelligent, nous avons droit à la fois au beurre et à l'argent du beurre :
|
|
Les exceptions ont souvent été présentées comme ces erreurs que l'on ne peut négliger. C'est vrai en Java par exemple, où une méthode doit soit annoncer qu'elle lève une exception, soit l'attraper mais ce l'est moins en C++. Ce qui est vrai par contre est qu'une exception qui est levée mais n'est pas attrapée porte à conséquences : dans un tel cas, le programme plante.
Une exception dénote donc le signal qu'une erreur dont il est possible de récupérer (outre quelques cas pathologiques, comme un manque de mémoire, dont à peu près personne ne cherche à récupérer, sauf dans des cas particuliers ou extrêmes) et qui, si on l'ignore, est suffisamment sérieuse pour entraîner la fin de l'exécution du programme.
Quand une exception n'est pas appropriée, les principales alternatives sont :
Le langage C++ a – jusqu'à C++ 11 – permis de déclarer les exceptions potentiellement levées par une fonction. Le code résultant pouvait ressembler à celui présenté à droite. On y lit :
Le problème ici est que f0(int) ment, bien malgré elle. En effet, f0(int) appelle entre autres f2(int) qui, elle, n'a rien promis, et peut donc lever n'importe quoi si bon lui semble. Les fonctions ne déclarant pas leurs intentions quant aux exceptions en C++ sont légales (il faut tout de même gérer le code C) et peuvent lever n'importe quoi, n'ayant pas fait de promesses à l'effet contraire. |
|
Un compilateur C++ doit gérer de tels cas, sinon il deviendrait essentiellement impossible d'écrire de vrais programmes. Cela signifie que tout code muni de clause throw(XYZ) peu importe le type XYZ devra être encadré (même silencieusement) par un bloc try {} catch(...) {} qui couvrira les cas où la fonction appelée (f0(int) par exemple) aura menti, pour au moins appeler std::unexpected() et fermer le programme dans le respect du standard. Conséquemment, le code annonçant ses intentions quant aux exceptions en C++ n'était pas plus sécuritaire que celui ne le faisant pas, mais il était plus lent dû au code injecté silencieusement par les compilateurs. Une mauvais idée, donc, mais on ne le savait pas à l'époque. Les pionniers de cette pratique étaient les concepteurs de Java, où un exception doit être annoncée ou attrapée, dans une optique de sécurité. Ainsi, en Java, dans un programme comme celui proposé à droite :
Cela semble être une bonne idée au préalable. Les clauses throws de Java sont transitives et permettent au code client de savoir à quoi s'attendre lors d'un appel de méthode. |
|
Le problème avec l'implémentation de Java est que les méthodes d'instance (outre les constructeurs et celles qualifiées de final) sont toutes polymorphiques, et que les clauses throws constituent un contrat hérité par les classes dérivées, ce qui est la seule approche raisonnable dans les circonstances. Conséquemment, les exceptions déclarées forcent le concepteur d'une interface (d'une classe parent) à penser dès le début à tous les cas d'exceptions que pourront un jour vouloir lever ses éventuels enfants.
En pratique, c'est une contrainte déraisonnable, qui mène le code client à écrire du code muni de try...catch(Exception)... génériques sans grande pertinence et dans lesquels le code de traitement d'erreur se limite souvent à afficher le message de l'exception et l'état de la pile.
Avec C++, le constat a été fait et le verdict est tombé : la seule clause pertinente pour générer du code de qualité était la clause throw(), le no-throw, mais cette clause était perfectible (des no-throw conditionnels aux no-throw des fonctions utilisées étaient requis, en particulier dans le code générique) donc throw() est déprécié en faveur de noexcept. En Java, le débat fait encore rage :
Du côté de C#, Eric Lippert a lancé une discussion en 2014 à savoir ce que pensent les programmeuses et les programmeurs des Checked Exceptions. Il n'a pas caché, au passage, le fait que cet aspect de C# n'est pas un des points forts du langage, bien que les Checked Exceptions elles-mêmes ne soient pas non plus une réussite dans les langages qui les utilisent. Pour en savoir plus :
Vous trouverez des liens généraux sur les exceptions déclarées en Java dans la section à cet effet.
En 2012, les spécifications d'exceptions de C++ ont été officiellement dépréciées, et throw() a été remplacé par noexcept : http://herbsutter.com/2010/03/13/trip-report-march-2010-iso-c-standards-meeting/
La clause noexcept de C++ 11 permet à un sous-programme de déclarer qu'il ne lèvera pas d'exception. Par défaut, noexcept équivaut à noexcept(true). Il est aussi possible d'écrire noexcept(false) pour affirmer qu'on ne peut jurer ne jamais lever d'exception. Techniquement, c'est plus subtil que ça : une fonction noexcept est telle que, si une exception est levée pendant qu'elle s'exécute, le programme se terminera drastiquement, sans même finaliser ses états globaux. On utilise donc noexcept quand on peut garantir ne pas lever d'exceptions... ou quand on choisit d'en accepter les conséquences. Cette subtilité est importante : puisque le compilateur n'est pas tenu de générer du code de finalisation lorsqu'une clause noexcept n'est pas respectée, il est en mesure de générer du code plus rapide. Ainsi, dans le cas des opérations de mouvement par exemple, la qualification noexcept permet à des conteneurs comme vector de « prendre des risques calculés » tout en offrant (en théorie) la garantie forte en cas d'exceptions. Que les opérations de mouvement soient noexcept est une opportunité précieuse d'optimisation. Par défaut, un destructeur devrait être noexcept, sans qu'il soit nécessaire de l'indiquer explicitement. Cependant, tous ne sont pas d'accord, ce qui explique que l'on puisse encore exprimer ce fait de manière optionnelle. Une fonction peut expliciter son caractère noexcept. Ainsi, dans le code à droite, f(int,int) ne lèvera pas d'exception et peut l'affirmer. |
|
Quand le code est générique, il est parfois plus complexe de garantir qu'on ne lèvera pas d'exceptions. Ainsi, dans le code à droite (qui présume que x+y retournera un T si x et y sont tous deux des const T&, ce qui n'est pas sûr du tout) indique qu'il ne lèvera pas d'exception si x + y ne lève pas d'exceptions. On utilise ici l'opérateur statique noexcept(expr) où expr est x+y, qui permet d'évaluer à la compilation le risque ou non de lever une exception. Ce code serait plus complet s'il tenait compte du caractère noexcept ou non du constructeur de copie et du constructeur de mouvement de T. |
|
Textes de tierces personnes :
Rien n'est parfait :
Ce qui suit touche à un sujet qui génère des débats entre experts, plusieurs demandant son inclusion dans C++ et plusieurs autres (pas les moindres!) s'y opposant.
Supposons la situation suivante :
// ne lèvera pas d'exception (par exemple, ne fait qu'appeler du code C) mais n'est pas annotée comme telle
int f(int);
// explicitement marquée noexcept
int g(int) noexcept;
// à risque de lever une exception
int f(double);
// on ne sait pas a priori pour celle-ci, ça dépend des types impliqués
template <class T, class U>
int h(T t,U u) noexcept (noexcept(f(t)) && noexcept(f(u))) {
return g(f(t) + f(u)); // les deux f() retournent un int, alors...
}
Nous avons exprimé la clause noexcept de h(T,U) sur la base de la conjonction logique des qualifications noexcept de f(t) et de f(u), sachant que g(int) est inconditionnellement noexcept donc que si une exception est levée, elle le sera pas un appel à f(t) ou par un appel à f(u). L'écriture de cette clause noexcept est subtile et peut, pour des fonctions plus complexes, devenir très lourde, allant jusqu'à dupliquer le code entier de la fonction dans les cas les plus extrêmes. Par exemple, si le type de retour des fonctions f() n'était pas un simple int, il y aurait pu y avoir un effort significatif d'écriture de la part des programmeuses et des programmeurs ici, et avec cet effort vient un risque d'erreur.
Pour cette raison, certains proposent d'ajouter à C++ la clause noexcept(auto), qui aurait pour effet de faire déduire au compilateur la qualification noexcept d'une fonction de par son implémentation. Évidemment, cela n'aurait de sens que pour les fonctions inline (sinon, le compilateur n'aurait pas accès à la définition et ne pourrait par faire le raisonnement demandé).
Ainsi, l'extrait ci-dessus deviendrait :
// ne lèvera pas d'exception (par exemple, ne fait qu'appeler du code C) mais n'est pas annotée comme telle
int f(int);
// explicitement marquée noexcept
int g(int) noexcept;
// à risque de lever une exception
int f(double);
// on ne sait pas a priori pour celle-ci, ça dépend des types impliqués
template <class T, class U>
int h(T t,U u) noexcept(auto) { // <-- ICI
return g(f(t) + f(u)); // les deux f() retournent un int, alors...
}
Ceci allègerait manifestement la tâche des programmeuses et des programmeurs dans le cas où cette approche serait applicable. Certains vont jusqu'à suggérer que ce soit là le comportement par défaut, donc que le compilateur inspecte systématiquement les fonctions inline et formule les clauses noexcept pour nous à moins que nous ne choisissions d'imposer notre volonté sur le code (p. ex. : sur le plan formel, une fonction risquerait de lever une exception si un pointeur passé en paramètre s'avérait nul, mais nous savons par construction que cela ne surviendra pas alors nous faisons le choix de la marquer inconditionnellement noexcept). Pour les fonctions qui ne sont pas inline (les fonction f() et g() ci-dessus), les clauses noexcept demeureraient bien sûr sous la responsabilité des programmeuses et des programmeurs.
Il y a des voix (importantes) qui s'élèvent contre cette approche. De leur point de vue, expliciter les clauses noexcept n'est pas une tâche que l'on devrait s'imposer pour chaque fonction (après tout, présentement, la majorité des fonctions n'ont pas recours à cette approche) mais plutôt de manière ciblée, dans le cas de fonctions clés. Par exemple, les destructeurs devraient être noexcept, et mieux vaut être explicite avec les opérations de la règle de . Surtout, de leur point de vue, générer automatiquement les clauses noexcept reviendrait à faire dépendre l'interface, contrat sémantique entre l'appelant et l'appelé d'une fonction, de son implémentation, ce qui constituerait une inversion de ce que nous considérons traditionnellement une saine pratique de programmation.
Le débat se poursuit.
Caractéristique peu connue de C++ : toute fonction peut se placer entièrement dans un bloc try. Ceci inclut main() :
#include <iostream>
using namespace std;
int f();
int g(int);
int main()
try {
cout << f(g(3)) << endl;
} catch(...) {
cerr << "Erreur grave!" << endl;
}
Cette fonctionnalité est particulièrement utile dans les constructeurs. En effet, le système de types de C++, contrairement à plusieurs autres, admet de réels objets (et non pas des références indirectes vers des objets), ce qui implique que les attributs d'un objet soient construits avant que ne s'entame le constructeur de l'objet en soi. Pour cette raison, il peut être utile de réagir à une levée d'exceptions provoquée dans cette phase de « préconstruction » :
class Risque { /* ... constructeurs susceptibles de lever une exception */ };
class X {
//
// en C++, les attributs d'instance sont construits dans l'ordre de leur déclaration
//
int *p = nullptr; // pointeur brut (mauvaise idée, mais bon)
Risque risque;
public:
X()
try : p{ new int[100] }, Risque{} {
} catch(...) {
delete [] p; // nettoyage d'une donnée initialisée en préconstruction mais dont this est responsable
}
};
Dans une fonction logée entièrement dans un bloc try, une éventuelle exception sera automatiquement re-levée dans le catch. Ceci est particulièrement utile dans un constructeiur (un objet dont le constructeur ne s'est pas complété dû à une levée d'exception est inutilisable en pratique).
Pour des textes d'autres sources :
Traditionnellement, les exceptions et la multiprogrammation ne faisaient pas bon ménage, dû à la difficulté de faire passer une exception levée dans un thread vers l'espace d'un autre thread, les deux opérant sur des piles distinctes.
Texte de 2002 (je crois) par Matti Rintala expliquant les enjeux du transport d'exceptions entre threads à travers des futures : http://www.cs.tut.fi/cgi-bin/run/bitti/download/multip-article-15.pdf
Texte de Pavan Kumar MJ, en 2012, décrivant les types d'exceptions possibles lors d'interactions avec la technologie AMP : http://blogs.msdn.com/b/nativeconcurrency/archive/2012/01/27/c-amp-runtime-exceptions.aspx
Avec C++ 11, il existe un mécanisme standard pour copier des exceptions tout en conservant leur sémantique, ce qui permet entre autres de les propager d'un thread à l'autre. À ce sujet, un texte d'Anthony Williams en 2011 : http://www.justsoftwaresolutions.co.uk/cplusplus/copying_exceptions.html
Sections de MSDN eexpliquant :
On voit souvent apparaître la mention Exception-Safe, sans nécessairement expliquer ce que l'on entend par là. Heureusement, certains y ont réfléchi, dans le cas de la programmation générique (donc dans un sens le plus large possible). En particulier, voir ce texte de Dave Abrahams, en 2010, sur ce que signifie être Exception-Safe, sur l'importance d'être Exception-Neutral, et sur les degrés de garanties de sécurité qu'il est possible d'offrir dans du code générique, voir http://www.boost.org/community/exception_safety.html
Je résume brièvement le fruit de ses travaux :
À titre de complément :
Un exemple de code n'offrant aucune garantie est donné à droite. Ici, f() appelle g() et h() et bien que g() soit noexcept, h() n'offre de son côté aucune garantie. Dans un tel cas, à moins que la programmeuse ou le programmeur de f() ne soit convaincu(e) que h() ne lèvera jamais d'exception (peut-être est-ce du code dont elle ou il contrôle les sources?), on ne peut rien affirmer quant aux garanties d'Exception-Safety offertes par f() dans ce cas-ci. |
|
Le code de l'opérateur d'affectation de la classe X à droite offre probablement la garantie de base, sans plus. En effet :
Évidemment, si les deux affectations réussissent, l'affectation d'un X à un autre réussit aussi. Pour bien implémenter l'affectation, privilégiez l'idiome d'affectation sécuritaire! |
|
Un exemple de code offrant la garantie forte serait X::remplacer_par(int), à droite. Ici :
|
|
Dans X::remplacer_par(int) ci-dessus, l'ordre des opérations est important. En effet, si nous avions choisi de faire le new avant le if, nous aurions obtenu le spaghetti visible à droite. Pourquoi? Parce que suite au succès du new, le pointé (l'entier de valeur n nouvellement alloué) est sous la responsabilité de l'instance de X l'ayant créé. Ainsi, si nous levons Negatif, nous devons libérer ce vers quoi pointe p au préalable. Il se trouve que new, lors d'un échec, lève généralement std::bad_alloc, mais peut aussi lever autre chose. Ne sachant pas ce qui nous explosera au visage, nous devons attraper n'importe quoi (le catch(...)), procéder au nettoyage, puis laisser filtrer la nature de l'erreur vers le code client (le throw; qui, en C++, est un re-throw, qui laisse filtrer l'exception attrapée sans savoir de quoi il s'agit). Le code client saura peut-être quoi faire, mais nous, ici, ne le savons pas. C'est la nature même des exceptions que de découpler signalement d'erreur et traitement d'erreur, après tout. |
|
Le code générique contraint les garanties d'Exception-Safety possibles du fait que le code serveur est généré sur la base des types manipulés. Ainsi, même une fonction comme celles proposées à droite, f(int) est no-throw alors que f(T) ne peut l'être du fait qu'il est possible que T::T(const T&) lève une exception pour certains types T. |
|
Il est par contre possible en C++ 11, à l'aide de traits, d'exprimer f(T) comme étant noexcept si la copie d'un T est aussi noexcept. L'exemple à droite montre comment exprimer cette idée à même le code. Une telle clause est utile car elle permet de véhiculer de manière transitive le fait que la copie d'un T soit noexcept amène que f(T) soit aussi noexcept, et ce dès la compilation. |
|
Être Exception-Safe ne suffit pas : en général, il faut aussi être Exception-Neutral. L'idée ici est de ne pas masquer le problème lorsque celui-ci est reconnu. Après tout, l'idée de base derrière les exceptions est que le code signalant le problème n'est habituellement pas capable de le traiter; il faut donc laisser la nature du problème remonter intacte jusqu'au point où quelqu'un saura quoi en faire.
Un exemple de ce qu'il ne faut pas faire est donné par le code de f() à droite. Voulant bien faire, sans doute, la progammeuse ou le programmeur a placé le code susceptible de lever une exception (ici, imaginons que ce code se limite au new[] par souci de simplicité) dans un bloc try...catch, puis capte toute exception potentielle et la filtre, remplaçant l'exception réelle par une instance d'ErreurGrave. Ce faisant, le code client ne connaîtra pas la nature de l'erreur réellement rencontrée et sa capacité d'agir sera grandement réduite. C'est moins pire, remarquez, que d'avaler tout simplement l'erreur sans rien dire. La pire chose à faire (de loin!) serait :
Notez que dans ce cas, afficher un message d'erreur est aussi mal que de ne rien faire puisque le code client n'est toujours pas informé qu'un problème, peut-être grave, a été rencontré. Il vient peut-être de manquer de mémoire et nous le lui avons caché. C'est très grave! Peu importe le langage, attraper toutes les exceptions sans raison utile est ce que certains nomment du Pokemon Exception Handling, au sens de « attrapez-les tous »... Au contraire, g() fait son travail et (présumant que le code en commentaire ne risque pas de provoquer d'exceptions) laisse l'exception potentielle provenant de new[] filtrer directement vers le code client, sans aucune modification. Ainsi, ce dernier aura en mains tout ce qu'il lui faut pour travailler. |
|
Il y a bien sûr des exceptions (!) à la règle. Comme me l'a fait remarquer un participant à CppCon 2016, il se peut que l'exception effective (disons, un cas de division par zéro signalé par voie d'une levée d'exception) s'avère pauvre en contenu, et ne permette pas de faire un diagnostic là où le contexte deviendra suffisant pour soutenir cette responsabilité.
Par exemple, imaginons que f() appelle g() et h(), et que les tâches faites par g() et h(), bien que distinctes, impliquent toutes deux de l'arithmétique sur des entiers, avec possibilité de division entière. Si une division par zéro est levée par les opérations de g(), il se peut que le sens à donner à l'exception soit différent de celui que l'on lui accorderait dans le cas où une même situation surviendrait de par l'action de h(). Ici, f() seule connaît le contexte dans lequel le problème est survenu, donc f() seule sait si g() a levé l'exception ou si h() l'a fait. Il pourrait donc être raisonnable pour f() d'intercepter l'exception, sans nécessairement la traiter, mais simplement pour lever en remplacement une exception distincte et chargée cette fois de l'information quant à la nature de la fonction ayant échoué.
Il ne faut pas confondre « conseils généraux » et obligation morale; ici comme ailleurs, mieux vaut réfléchir plutôt qu'appliquer aveuglément un conseil. même si ce dernier mèn habituellement aux meilleures pratiques.
À ce sujet :
En 2012, un texte d'Eric Lippert expliquant pourquoi il est obligatoire, dans les langages tels que C#, Java ou C++ d'utiliser des accolades pour délimiter les blocs try, catch et (pour les langages qui requièrent cette fonctionnalité) finally : http://ericlippert.com/2012/12/04/why-are-braces-required/
Pourquoi est-il parfois utile de ne pas gérer les exceptions? Un texte de Simon Copper en 2013 : http://geekswithblogs.net/simonc/archive/2013/06/03/why-unhandled-exceptions-are-useful.aspx
Exceptions et Microsoft Windows en version 64 bits, un texte de 2007 : http://www.nynaeve.net/?p=105
À propos de la valeur de messages significatifs mais concis dans les exceptions, texte de Chris Oldwood en 2015 : http://accu.org/index.php/journals/2110
L'une des difficultés intrinsèques à certaines caractéristiques techniques de langages contemporains est d'intégrer leurs concepts à des programmes d'une autre époque. Ceci vaut en particulier pour l'intégration de code utilisant les exceptions à du code qui n'en tient pas compte.
Les pratiques associées à la gestion d'exceptions varient beaucoup selon les langages de programmation.
Texte de 1986, propriété du gouvernement américain, sur la place des exceptions dans le langage Ada 83 : http://archive.adaic.com/standards/83rat/html/ratl-14-04.html
« [O]ne should not throw an instance of int » – Thomas Rodgers dans https://twitter.com/rodgertq/status/769983367106486272
« The method of error reporting is absolutely irrelevant to state guarantees. Error codes just make things harder » – Stephan T. Lavavej dans https://twitter.com/StephanTLavavej/status/771063402835783680
Trucs variés à propos des exceptions en C++ et de leur gestion :
À propos des exceptions avec C# :
Je ne connais pas assez D pour disserter à ce sujet, mais d'autres le peuvent :
Le langage Go ne supporte pas les exceptions, préférant des fonctions à valeurs de retour multiples (code de succès + valeur retournée, typiquement) et un mécanisme nommé panic() pour planter inconditionnellement lors de cas irrécupérables.
Selon Dave Cheney en 2012, cette approche est saine, et préférable aux exceptions déclarées de Java et aux mécanismes de C++ : http://dave.cheney.net/why-go-gets-exceptions-right
Autre réflexion sur l'approche préconisée en Go, dans une entrevue avec James Ward en 2011 : http://www.artima.com/weblogs/viewpost.jsp?thread=331407
Les exceptions et les erreurs dans Haskell : http://www.haskell.org/haskellwiki/Error_vs._Exception
Étude de 2016 par Suman Nakshatri, Maithri Hegde et Sahithi Thandra, qui montre que les programmeuses et les programmeurs Java tendent à ne pas utiliser les exceptions à des fins utiles (au sens du bon déroulement d'un programme, du moins) : http://plg.uwaterloo.ca/~migod/846/current/projects/09-NakshatriHegdeThandra-report.pdf Voir aussi cette analyse sommaire de Greg Wilson, en 2016 : http://neverworkintheory.org/2016/04/26/java-exception-handling.html Ce qui est triste ici est que ces pratiques montrent une mauvaise compréhension de ce mécanisme qu'est la gestion d'exceptions, et du rôle que ce mécanisme peut jouer dans un programme. Pour plusieurs, manifestement, c'est surtout un outil de débogage, permettant de localiser les affichages, sans plus. Ça ressemble à un échec pédagogique... | ![]() |
Lisp et la gestion des conditions, équivalent local de la gestion des exceptions, texte de 2012 : http://lubutu.com/soso/condition-handling-for-non-lispers
Texte de Jason Clark en 2004 expliquant comment la mécanique de la plateforme .NET gère les cas d'exceptions inattendues (celles qui échappent au code client) : http://msdn.microsoft.com/en-gb/magazine/cc188720.aspx
Les coûts de la gestion des exceptions sur plateforme .NET, par Vagif Abolov en 2005 : www.codeproject.com/Articles/11265/Performance-implications-of-Exceptions-in-NET
Comment échouer en OCaml, selon Jane Street en 2014 : https://blogs.janestreet.com/how-to-fail-introducing-or-error-dot-t/
En 2014, Jane Street propose de joindre gestion des exceptions et Pattern Matching : https://blogs.janestreet.com/pattern-matching-and-exception-handling-unite/
Truc amusant Ruby utilise Raise pour lever une exception... et Rescue pour le bloc qui la traitera
Un guide destiné aux débutant(e)s dans ce langage, proposé par Starr Horne en 2017 : http://blog.honeybadger.io/a-beginner-s-guide-to-exceptions-in-ruby/
Tout d'abord, comme je le dis souvent à mes étudiant(e)s, il faut se souvenir qu'un programme qui plante n'est pas nécessairement une mauvaise chose.
Évidemment, il faut faire attention à l'interprétation de cette phrase : certains systèmes ne doivent absolument pas planter. Avec ceux-là, mieux vaut sortir tout notre arsenal de sécurisation (tests, validation statique, assistants automatisés aux preuves, etc.) et prévoir de façon maniaque des portes de sortie même quand les choses vont extrêmement mal.
À titre d'exemple, toute fonction et toute méthode en C++ peut voir son code entier placé dans un bloc try...catch. On voit surtout cette pratique avec les constructeurs (pour attraper certaines exceptions en cours de préconstruction), mais elle peut aussi s'appliquer à main(). Ceci permet d'appliquer des mesures de dernier recours, par exemple appeler à l'aide ou faire un signal à une ressource matérielle qui fera basculer la tâche du système vers un module auxiliaire. Il peut bien sûr y avoir plusieurs blocs catch distincts pour plusieurs types d'exceptions ici. Le bloc catch(...) doit, évidemment, être le dernier du lot pour éviter qu'il n'avale tout. Notez qu'ici, les blocs catch n'ont accès à aucune autre information que ce qui est véhiculé par l'exception attrapée elle-même. C'est un peu contraignant. |
|
Une variante possible est d'allouer des ressources au début de la fonction (ici, au début de main()) et de placer le bloc de gestion d'exceptions à l'intérieur de la fonction. Ceci permet aux blocs catch d'avoir accès aux ressources en question et peut leur donner un peu plus de latitude dans le traitement du problème qui a été rencontré. |
|
Autre cas où il est important de planter avec soin : les système répartis. Idéalement, nous souhaitons alors collaborer avec l'infrastructure de communication pour faciliter les diagnostics du problème rencontré chez nos pairs. Une discussion des mécanismes impliqués serait un peu complexe pour cette page, cependant.
Pour le reste, la règle pour moi est de planter le plus tôt possible. Ceci réduit le nombre de cas d'exceptions que j'ai à gérer de manière significative en pratique.
Lorsque j'ai recours à des exceptions, je tends à lever des exceptions sémantiquement chargées, à travers des instances de classes vides. Pourquoi? Examinez les deux exemples ci-dessous :
Exemple A | Exemple B |
---|---|
|
|
Vous conviendrez que les deux font le même travail : ils tentent de réaliser une division entière entre num et denom lus à la console, gèrent les risques de division par zéro à travers une levée d'exception, et affichent un message significatif si un tel problème survient.
Dans ma pratique, c'est le cas de droite (Exemple B) qui est utilisé, pour plusieurs raisons :
De manière générale, j'essaie de faire en sorte que le type de l'exception soit le message. Les messages en format texte ramènent sur les épaules des programmeuses et des programmeurs des problèmes qu'ils ne devraient pas avoir à gérer.
Par exemple, imaginez une exception munie du message "bad value: 5" et dans laquelle nous voudrions aller chercher la valeur 5... Est-ce qu'on veut vraiment faire du traitement de chaîne de caractères sur un message dans du code de récupération d'une erreur?
Il peut arriver qu'une instance d'une classe vide ne suffise pas, comme par exemple si l'on souhaite rapporter qu'une valeur est hors bornes tout en souhaitant indiquer la valeur et les bornes pour faciliter le diagnostic. Dans un tel cas, je procède par exemple comme dans le code à droite. Notez que les instances de la classe HorsBornes se copient sans lever d'exception, ce qui est important (lever une exception dans un throw mène à std::unexpected() ce qui termine violemment le programme) et sont relativement légères sur le plan des ressources. Une instance de la classe Note accepte des valeurs entre Note::MINVAL et Note::MAXVAL inclusivement. La méthode de classe Note::valider(int) ne laisse filtrer que les valeurs candidates acceptées et lève un HorsBornes correctement instancié dans les autres cas. Ici, qu'un HorsBornes possède les états pertinents à la description du problème rencontré peut faciliter le diagnostic même si la Note instanciée est dans une fonction différente de celle qui traitera le problème; l'exemple à droite, qui se limite à un main(), ne le montre pas clairement, mais imaginez une fonction appelée par main() et lisant des valeurs pour plusieurs notes alors que main() attraperait le HorsBornes si l'une des instanciations de Note devait échouer. |
|
Question de réflexion : je prétends que HorsBornes ci-dessus est pleinement encapsulée, bien que cette classe soit munie d'attributs publics. Êtes-vous d'accord avec cette affirmation? Expliquez votre position.
Une mauvaise habitude que j'ai, personnellement, est de récupérer des erreurs sans en garder de trace. Assurez-vous que, dans vos programmes, surtout s'ils sont complexes, on trouve des mécanismes permettant de signaler les exceptions aux usagers, si c'est ce que vous souhaitez (c'est rarement le cas, mais en période de développement ça peut être utile) ou de garder dans un journal quelque part une trace des exceptions soulevées et traitées. Cette habitude peut vous épargner beaucoup de maux de tête lorsque quelque chose tourne mal.
À titre de réflexion, permettez-moi de signaler que les mots « exception » et « erreur » sont distincts. L'un signale une condition inhabituelle ou hors du flot normal des opérations, l'autre un problème. Certaines bibliothèques (je pense en particulier à Boost.Graph ici) tiennent compte de ce fait et utilisent par exemple les exceptions lors de la recherche d'un élément pour signaler que celui-ci a été trouvé. Pourquoi? Parce qu'il s'agit alors du cas inhabituel, pas du cas commun, et parce que la recherche est typiquement récursive – une exception permet de sortir efficacement d'une fouille récursive à multiples niveaux.
Je sais que certains trouvent cette pratique inacceptable et estiment qu'elle est trop différente des usages les plus typiques pour être appliquée en général. Il est clair à mes yeux qu'il ne faut pas en abuser (par exemple, une fouille linéaire dans un tableau, par exemple std::find(), se termine aisément par un return et il n'y a pas lieu de d'utiliser une exception dans un tel cas), mais je vous suggère de garder l'esprit ouvert.
Brève note historique : lors des débats qui ont mené à l'avènement de la programmation structurée, dans les années '70, le célèbre Edsger W. Dijkstra a écrit un texte qui a fait école (et en a engendré plusieurs autres du même style) intitulé GOTO Statements Considered Harmful. Son argumentaire était (je paraphrase) que les GOTO, ou sauts inconditionnels à un endroit quelconque dans le code, permettent de briser la structure d'un programme et peuvent rendre difficile, voire même impossible, de raisonner sur le code pour en démontrer le fonctionnement correct.
De son côté, le tout aussi célèbre Donald E. Knuth a fait valoir que les GOTO pouvaient mener à du code structuré de qualité s'ils sont utilisés avec discipline (voir http://sbel.wisc.edu/Courses/ME964/Literature/knuthProgramming1974.pdf pour connaître le détail de son argumentaire). L'une des pratiques les pus importantes qu'il mettait de l'avant était de placer le code de traitement des erreurs hors du flot normal des opérations, et de faire en sorte que noter un problème soit une chose mais que le traitement correspondant soit placé ailleurs. Essentiellement, Knuth était le précurseur de ce que nous nommons maintenant le traitement des exceptions.
Les critiques des exceptions comme mécanisme de signalement d'erreurs sont nombreuses, et plusieurs préconisent des approches alternatives. En particulier, les exceptions sont peu ou pas utilisées dans certaines entreprises de haut profil (Google, par exemple), dans le monde du jeu vidéo et dans le domaine aérospatial. Les raisons varient :
Il est trop facile de voir les exceptions comme un coût. Comparez par exemple l'exemple suivant :
enum code_completion { Ok, ErreurMineure, ErreurGrave };
code_completion f(int &valeur, int param);
// ...
#include <iostream>
using namespace std;
int main() {
int resultat;
int val;
if (cin >> val) {
code_completion code = f(resultat, val);
switch (code) {
case Ok:
cout << resultat << endl;
// ... tigidou! Utiliser val...
break;
case ErreurMineure:
// ... traiter la situation
break;
case ErreurGrave:
// ... traiter la situation
break;
default:; // duh?
}
}
}
code_completion f(int &valeur, int param) {
if (param < 0)
return ErreurMineure;
else if (param == 0)
return ErreurGrave;
valeur = param;
return Ok;
}
... qui, avec Visual Studio 2015, donne un programme de 7680 bytes lorsque compilé en mode Release, avec celui-ci :
class ErreurMineure {};
class ErreurGrave {};
int f(int); // peut lever ErreurMineure ou ErreurGrave
// ...
#include <iostream>
using namespace std;
int main() {
try {
int val;
if (cin >> val)
cout << f(val) << endl;
// ... tigidou! Utiliser resultat
} catch (ErreurMineure&) {
// ... traiter la situation
} catch (ErreurGrave&) {
// ... traiter la situation
} catch(...) {
// duh?
}
}
int f(int param) {
if (param < 0)
throw ErreurMineure{};
else if (param == 0)
throw ErreurGrave{};
return param;
}
... qui, avec Visual Studio 2015, donne un programme de 8704 bytes lorsque compilé en mode Release. Il y a manifestement un léger coût, du moins à petite échelle.
En toute honnêteté, à code équivalent, le code avec exceptions est plus élégant (car la fonction appelée n'est pas dénaturée pour traiter l'erreur), plus rapide (le chemin jugé normal, au sens de « sans exceptions », est le chemin le plus rapide) et place le traitement d'erreurs à part, puisqu'il est extérieur au fonctionnement jugé normal du programme, quitte à le faire tout simplement traiter par l'appelant.
Il est plus difficile de critiquer les exceptions sur une base montrant les avantages et inconvénients de ce mécanisme et de ses alternatives, en particulier avec des compilateurs contemporains.
Quelques textes de tierces personnes suivent.