Exceptions

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.

La base

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.

Pourquoi des exceptions

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 :

  • Qu'il existe une classe Rectangle
  • Que cette classe est déclarée dans Rectangle.h
  • Qu'elle expose un constructeur paramétrique recevant deux entiers, respectivement sa hauteur et sa largeur
  • Qu'elle offre deux acccesseurs de premier ordre pour fins de consultation des états (hauteur() et largeur()), qui peut présumer const, et
  • Qu'elle offre un service public dessiner() projetant sa représentation sur un flux standard (ici, sur la console)
#include "Rectangle.h"
#include <iostream>
int main() {
   using namespace std;
   if (int lar, hau; cin >> hau >> lar) {
      Rectangle r{hau, lar};
      cout << "Rectangle de hauteur "
           << r.hauteur()
           << " et de largeur "
           << r.largeur() << endl;
      r.dessiner(cout);
   }
}

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.

#include "Rectangle.h"
#include <iostream>
int main() {
   using namespace std;
   if (int lar, hau; cin >> hau >> lar)
      if (hau < Rectangle::HAUTEUR_MIN)
         cerr << "Hauteur " << hau << " trop petite" << endl;
      else if (hau > Rectangle::HAUTEUR_MAX)
         cerr << "Hauteur " << hau << " trop grande" << endl;
      else if (lar > Rectangle::LARGEUR_MIN)
         cerr << "Largeur " << lar << " trop petite" << endl;
      else if (lar > Rectangle::LARGEUR_MAX)
         cerr << "Largeur " << lar << " trop grande" << endl;
      else {
         Rectangle r{hau, lar};
         cout << "Rectangle de hauteur "
              << r.hauteur()
              << " et de largeur "
              << r.largeur() << endl;
         r.dessiner(cout);
      }
}

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.

#include "Rectangle.h"
#include <iostream>
int main() {
   using namespace std;
   if (int lar, hau; cin >> hau >> lar)
      if (!Rectangle::hauteurValide(hau))
         cerr << "Hauteur " << hau << " hors bornes" << endl;
      else if (!Rectangle::largeurValide(lar))
         cerr << "Largeur " << lar << " hors bornes" << endl;
      else {
         Rectangle r{ hau, lar };
         cout << "Rectangle de hauteur "
              << r.hauteur()
              << " et de largeur "
              << r.largeur() << endl;
         r.dessiner(cout);
      }
}

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.

#include "Rectangle.h"
#include <iostream>
int main() {
   using namespace std;
   if (int lar, hau; cin >> hau >> lar) {
      Rectangle r{hau, lar};
      if (r.est_valide()) {
         cout << "Rectangle de hauteur " << r.hauteur()
              << " et de largeur " << r.largeur() << endl;
         r.dessiner(cout);
      }
   }
}

 

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.

#include "Rectangle.h"
#include <iostream>
int main() {
   using namespace std;
   if (int lar, hau; cin >> hau >> lar) {
      Rectangle r{hau, lar};
      cout << "Rectangle de hauteur " << r.hauteur()
           << " et de largeur " << r.largeur() << endl;
      r.dessiner(cout);
   }
}

 

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 :

#include <algorithm>
template <class T>
   T* f(const T& val, int n) {
      auto p = new T[n];
      std::fill(p, p + n, val);
      return p;
   }

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 :

#include <algorithm>
template <class T>
   T* f(const T& val, int n) {
      auto p = new T[n];
      try {
         std::fill(p, p + n, val);
         return p;
      } catch(...) { // peu importe ce que c'est
         delete [] p; // on nettoie
         throw; // on laisse sortir l'exception interceptee
      }
   }

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 :

#include <algorithm>
#include <memory>
template <class T>
   std::unique_ptr<T[]> f(const T& val, int n) {
      std::unique_ptr<T[]> p { new T[n] };
      std::fill(p, p + n, val);
      return p;
   }
#include "Rectangle.h"
#include <iostream>
int main() {
   using namespace std;
   if (int lar, hau; cin >> hau >> lar)
      try {
         Rectangle r{ hau, lar };
         cout << "Rectangle de hauteur " << r.hauteur()
              << " et de largeur " << r.largeur() << endl;
         r.dessiner(cout);
      } catch (Rectangle::HauteurInvalide hi) {
         cerr << "Hauteur invalide: " << hi.valeur() << endl;
      } catch (Rectangle::LargeurInvalide li) {
         cerr << "Largeur invalide: " << li.valeur() << endl;
      } catch (...) {
         cerr << "Échec pour causes suspectes" << endl;
      }
}

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 :

Les exceptions déclarées (Checked Exceptions)

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 :

  • Que X et Y sont des classes représentant (entre autres) des exceptions potentielles. En C++, contrairement à ce qui prévaut par exemple en Java ou en C#, tout type peut être levé à titre d'exception, incluant le plus humble int
  • Que f3(int) jure qu'il ne lèvera aucune exception – et plus précisément, qu'il ne sert à rien de tenter d'attraper des exceptions résultant d'un appel de cette fonction car s'il y en a, elles seront si graves que la situation sera irrécupérable
  • Que f1(int) jure ne lever que des X et rien d'autre, et
  • Que f0(int) jure ne lever que des X ou des Y, rien d'autre

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.

class X {};
class Y {};
int f3(int) throw();
int f2(int);
int f1(int) throw(X);
int f0(int n) throw(X,Y) {
   if (!n) throw Y{};
   return f1(n) + f2(n) + f3(n);
}

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 :

  • La méthode Test.f2(int) ne lèvera pas d'exception
  • La méthode Test.f1(int) lèvera peut-être un X mais ne lèvera rien d'autre; e
  • La méthode Test.f0(int) lèvera peut-être un X (dû à l'appel de f1(int) sans l'inclure dans un bloc try...catch...), peut-être un Y, mais ne lèvera rien d'autre

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.

class Test {
   static class X extends Exception {
   }
   static class Y extends Exception {
   }
   int f2(int n) {
      return n;
   }
   int f1(int n) throws X {
      if (n < 0) {
         throw new X();
      }
      return n;
   }
   int f0(int n) throws X,Y {
      if (n == 0) {
         throw new Y();
      }
      return f1(n) + f2(n);
   }
}

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.

La clause noexcept

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.

int f(int x, int y) noexcept {
   return x + y;
}

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) 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.

template <class T>
   T f(const T &x, const T&y) noexcept(noexcept(x+y)) {
      return x + y;
   }

Textes de tierces personnes :

Rien n'est parfait :

Alléger l'écriture – noexcept(auto)

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.

Exceptions et blocs try de fonctions

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 :

Exceptions et multiprogrammation

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 :

Être Exception-Safe

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 :

Exemple sans garanties d'Exception-Safety

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.

int g() noexcept;
int h();
int f() {
   return g() + h();
}

Exemple offrant la garantie de base

Le code de l'opérateur d'affectation de la classe X à droite offre probablement la garantie de base, sans plus. En effet :

  • Si l'affectation d'un T0 à un autre lève une exception, alors l'affectation d'un X à un autre échoue, les X impliqués demeurent inchangés et aucune ressource n'a fui
  • Si l'affectation d'un T1 à un autre lève une exception, alors l'affectation d'un X à un autre échoue mais le X à gauche de l'affectation (*this) est partiellement modifié. Aucune ressource n'a fui, mais la cohérence de ce X est peut-être détruite – les invariants d'un X seront conservés seulement si tout T0 et tout T1 peuvent cohabiter dans un X. Il n'est pas clair qu'un X à demi modifié comme dans ce cas-ci demeurera pleinement utilisable dans un programme

É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!

template <class T0, class T1>
   class X {
      T0 v0;
      T1 v1;
      // ...
   public:
      X& operator=(const X &autre) {
         v0 = autre.v0;
         v1 = autre.v1;
         return *this;
      }
      // ...
   };

Exemple offrant la garantie forte

Un exemple de code offrant la garantie forte serait X::remplacer_par(int), à droite. Ici :

  • Si n est négatif, un Negatif est levé
  • Il est possible qu'une exception (probablement std::bad_alloc) soit levée lors du new
  • Toutefois, si l'une ou l'autre des exceptions est levée, le code ne fuira pas (aucune ressource n'a été allouée) et les invariants de X (p_ ne pointe pas sur un entier négatif) seront respectés. Ici, l'ordre des opérations est important, car si le new était fait avant le if, alors il faudrait compliquer le code (ajouter un try...catch(...)... puis détruire ce vers quoi pointe p avant de faire un re-throw))
  • Plus encore, si l'une ou l'autre des exceptions est levée, l'instance de X demeurera pleinement inchangée
class Negatif {};
class X {
   int *p_;
public:
   X() : p_{} {
   }
   void remplacer_par(int n) {
      if (n < 0) throw Negatif{};
      int *p = new int{ n };
      delete p_;
      p_ = p;
   }
   // ... etc.
};

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.

class Negatif {};
class X {
   int *p_;
public:
   X() : p_{} {
   }
   void remplacer_par(int n) {
      int *p = new int{n };
      try {
         if (n < 0) throw Negatif{};
         delete p_;
         p_ = p;
      } catch(...) {
         delete p;
         throw;
      }
   }
   // ... etc.
};

Exemple offrant la garantie no-throw

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.

int f(int n) noexcept {
   return n;
}
template <class T>
   T f(T elem) {
      return elem;
   }

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.

#include <type_traits>
template <class T>
   T f(T elem)
      noexcept(
         std::is_nothrow_copy_constructible<
            T
         >::value
      )
   {
      return elem;
   }

Être Exception-Neutral – Sécurité et neutralité

Ê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 :

int f(int n) {
   try {
      int *p = new int[n];
      // ...utiliser p...
      delete [] p;
   } catch (...) { // très vilain!!!
   }
   return 0;
}

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.

class ErreurGrave {};
//
// f(int) n'est pas Exception-
// Neutral. C'est pas gentil
//
int f(int n) {
   try {
      int *p = new int[n];
      // ...utiliser p...
      delete [] p;
   } catch (...) {
      throw ErreurGrave{};
   }
   return 0;
}
int g(int n) {
   int *p = new int[n];
   // ...utiliser p...
   delete [] p;
   return 0;
}

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 :

Généralités techniques

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

Intégrer code avec exceptions et code sans exceptions

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.

Selon les langages

Les pratiques associées à la gestion d'exceptions varient beaucoup selon les langages de programmation.

Exceptions et Ada

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

Exceptions et C

Exceptions et Crystal

« [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

Exceptions et C++

Trucs variés à propos des exceptions en C++ et de leur gestion :

Exceptions et C#

À propos des exceptions avec C# :

Exceptions et D

Je ne connais pas assez D pour disserter à ce sujet, mais d'autres le peuvent :

Exceptions et Go

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

Exceptions avec Haskell

Les exceptions et les erreurs dans Haskell : http://www.haskell.org/haskellwiki/Error_vs._Exception

Exceptions et Java

É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...

Exceptions et Lisp

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

Exceptions et langages .NET

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

Exceptions (ou du moins gestion des erreurs) avec OCaml

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

Exceptions avec Ruby

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/

Mon approche

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.

Le cas du dernier recours

É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.

int main()
   try {
      // ... code normal ...
   } catch (X&) {
      // ...
   } catch (Y&) {
      // ...
   } catch(...) {
      // ...mesures de dernier recours...
   }

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é.

int main() {
   //
   // allouer les ressources qui
   // permettront les mesures de
   // dernier recours
   //
   try {
      // ... code normal ...
   } catch (X&) {
      // ...
   } catch (Y&) {
      // ...
   } catch(...) {
      // ...mesures de dernier recours...
   }
}

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.

Exceptions sémantiquement chargées

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
#include <exception>
#include <iostream>
int div_entiere(int num, int denom) {
   using std::exception;
   if (!denom)
      throw exception{"division par zéro"};
   return num/denom;
}
int main() {
   using namespace std;
   int num, denom;
   if (cin >> num >> denom)
      try {
         cout << div_entiere(num, denom) << endl;
      } catch (exception &e) {
         cerr << e.what() << endl;
      }
}
#include <iostream>
class DivisionParZero {};
int div_entiere(int num, int denom) {
   if (!denom)
      throw DivisionParZero{};
   return num/denom;
}
int main() {
   using namespace std;
   int num, denom;
   if (cin >> num >> denom)
      try {
         cout << div_entiere(num, denom) << endl;
      } catch (DivisionParZero&) {
         cerr << "Division par zéro" << endl;
      }
}

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 :

Le type est le message

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?

Exceptions paramétriques

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.

struct HorsBornes {
   const int val, minval, maxval;
   constexpr HorsBornes(int val, int minval, int maxval)
      : val{val}, minval{minval}, maxval{maxval}
   {
   }
};
class Note {
   enum { MINVAL = 0, MAXVAL = 100 };
   int val;
   static constexpr int valider(int val) {
      return val < MINVAL || MAXVAL < val?  throw HorsBornes{val, MINVAL, MAXVAL} : val;
   }
public:
   constexpr Note(int val) : val{valider(val)} {
   }
   // ...
};
#include <iostream>
int main() {
   using namespace std;
   int val;
   if (cin >> val)
      try {
         Note note{ val };
         // ...utiliser note...
      } catch (HorsBornes &hb) {
         cerr << "Valeur illégale: "
              << hb.val
              << "; intervalle valide: ["
              << hb.minval << ".." hb.maxval
              << "]\n";
      }
}

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.

Une exception est-elle une erreur?

À 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.

Note historique

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.

Critiques et alternatives

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.


Valid XHTML 1.0 Transitional

CSS Valide !