C++ et le traitement d'exceptions

Ce document se veut une brève introduction à ce qu'on appelle les exceptions avec une optique C++. Je n'y explore toutefois pas toutes les ramifications cde ce mécanisme; c'est une introduction, sans plus. Ainsi, si vous voulez une perspective plus large sur le sujet, voir ce document.

Les exceptions ont fait leur apparition officielle en C++ avec la norme ISO (1998). Elles sont toutefois communément rencontrées dans plusieurs autres langages (notamment en Java, où elles foisonnent).

On utilise les exceptions lorsqu'une erreur peut survenir dans un sous-programme, mais ce sous-programme n'est pas le mieux placé pour gérer cette erreur; ou encore, lorsqu'il faudrait dénaturer un sous-programme pour qu'il signale correctement un cas exceptionnel d'erreur (d'où le terme « exception ») au sous-programme l'ayant appelé.

Un exemple très simple de sous-programme où une exception pourrait s'avérer de mise serait celui proposé à droite. Notez qu'en pratique, on aurait sans doute écrit simplement return num/denom; mais que la version un peu longue présentée ici l'est pour des fins pédagogiques, donc pour aider à la compréhension des détails de la mécanique.

Dans cet exemple, on a un cas d'erreur possible surviendra si denom vaut 0 à l'appel du sous-programme.

Les stratégies traditionnelles de réaction à l'erreur s'offrant à nous sont décrites ci-dessous.

int div_entiere(int num, int denom)
{
   int quotient;
   // ici: si denom == 0 ... Boum!
   quotient = num / denom;
   return quotient;
}

Approche 0 – afficher un message

Afficher un message d'erreur à même le sous-programme. C'est le réflexe qu'on a lorsqu'on ne produit que des programmes de type console.

Toutefois, ce sous-programme pourrait servir dans une application munie d'une interface usager graphique, et dans ce cas un affichage console serait inapproprié.

L'autre ennui significatif à cette stratégie est que si on affiche un message d'erreur, même dans une application console, l'usager saura qu'il y a eu un problème, mais le programme, lui, ne le saura pas. Le dilemme est illustré dans ce cas par la clause else...

#include <iostream>
int div_entiere(int num, int denom)
{
   using std::cerr;
   int quotient;
   if (denom) // if (denom != 0)
      quotient = num / denom;
   else
      // Que mettre dans «  quotient»?
      cerr << "ERREUR";
   return quotient;
}

Des variantes de ce schème sont possibles : afficher une fenêtre modale (un Message Box) exigeant une intervention humaine, par exemple, ou encore déclencher un signal d'alarme. Les problèmes sont chaque fois les mêmes.

Approche 1 – modifier la signature

Étant donné qu'un sous-programme communique surtout avec d'autres sous-programmes (bien plus en tout cas qu'avec des usagers), un mécanisme permettant de communiquer le problème au sous-programme appelant est préférable à un affichage dans une console quelconque.

Une façon d'y arriver serait de passer un paramètre additionnel signifiant qu'il y a eu erreur ou non. On pourrait déclarer ce paramètre comme étant booléen (qui prendra la valeur vrai si la division fut un succès et faux sinon, par exemple) ou entier (prenant la valeur 0 si tout va bien et une valeur négative sinon). Si plusieurs cas d'erreurs sont possibles, un type plus riche qu'un booléen peut être choisi.

L'ennui de cette stratégie est qu'elle dénature (et complexifie) le travail à faire. Le traitement d'erreur devient une préoccupation telle qu'elle altère l'interface (la convention d'appel) du sous-programme. Cette solution fonctionne, mais souffre d'inélégance.

Ici, si succes == false suite à l'appel, il ne faut pas utiliser la valeur retournée par la fonction – elle sera invalide.

int div_entiere(int num, int denom, bool &succes)
{
   int quotient = 0;
   if (denom)
   {
      quotient = num / denom;
      succes = true;
   }
   else
      succes = false;
   return quotient;
}

Dans la même veine, on pourrait faire en sorte que le sous-programme retourne un code d'erreur (ou de succès), et qu'il dépose le quotient (résultat de l'opération à effectuer) dans un 3e paramètre. C'est l'approche suivie par certaines infrastructures industrielles (dont COM) qui supportent entre autrs des langages (comme C) qui, eux, ne supportent pas de mécanismes de gestion d'exceptions.

Cette stratégie comporte les mêmes problèmes que la précédente. Elle rend l'utilisation moins confortable, moins naturelle. Encore une fois, si le code retournée en est un d'échec, il ne faut pas utiliser le quotient calculé par le sous-programme, cette variable n'ayant pas été remplie correctement.

const int SUCCES = 0,
          ECHEC = -1;
int div_entiere(int num, int denom,
               int &quotient)
{
   int resultat;
   if (denom)
   {
      quotient = num / denom;
      resultat = SUCCES;
   }
   else
      resultat = ECHEC;
   return resultat;
}

La raison pour laquelle chacune de ces solutions possibles comporte des problèmes est que les cas d'erreur ne font pas partie du traitement normal du sous-programme. Ce sont des cas d'exception, des cas problèmes. Le mécanisme de traitement d'exceptions de langages tels que C++ permet de les traiter comme tels.

Utiliser le traitement d'exceptions en C++

Nous travaillerons, pour illustrer l'emploi d'exceptions, avec le petit programme proposé à droite.

Ce programme lit un numérateur et un dénominateur, calcule la division entière correspondante, et affiche le résultat de cette division. Il fonctionne très bien dans la mesure où personne n'a la mauvaise idée de fournir un dénominateur nul.

Notre tâche sera ici de le compléter en le protégeant de cette ignoble exception, tout en prenant soin de ne pas altérer l'interface de notre sous-programme – de ne pas le dénaturer inutilement.

#include <iostream>
int div_entiere(int num, int denom);
int main()
{
   using namespace std;
   int numerateur, denominateur;
   if (cin >> numerateur >> denominateur)
   {
      int resultat = div_entiere(numerateur, denominateur);
      cout << resultat << endl;
   }
}
int div_entiere(int num, int denom)
{
   int quotient = num / denom;
   return quotient;
}

Bibliothèque standard <exception>

Il est fréquent que les gens utilisent la bibliothèque standard <exception>. Sans être nécessaire, elle contient tous les détails pertinents à l'utilisation d'exceptions standards en C++. Pour ce premier exemple, nous utiliserons le type std::exception pour signaler un problème, mais notez que ce n'est pas la meilleure approche (signaler un problème sans en décrire la nature n'est que d'une utilité limitée en pratique).

Notre programme est maintenant tel que visible ci-dessous; à ce stade, il ne gère encore aucun cas d'exception.

#include <iostream>
#include <exception>
int div_entiere(int, int);
int main()
{
   using namespace std;
   int numer,
       denom;
   if (cin >> numer >> denom)
   {
      int resultat = div_entiere(numer, denom);
      cout << resultat << endl;
   }
}
int div_entiere(int num, int denom)
{
   int quotient = num / denom;
   return quotient;
}

Reconnaître et lever une exception

Le sous-programme appelé est responsable de réaliser si un cas d'exception est rencontré. Cela demande un peu d'analyse de la part des programmeuses et des programmeurs du sous-programme en question – le même genre d'analyse qu'on demandera de toute façon pour tout traitement d'erreur.

Lorsque le sous-programme rencontrera un cas d'exception, son travail sera de le signaler au sous-programme l'ayant appelé. On dira alors que le sous-programme appelé « lance » (throws) ou « lève » (raises) une exception au sous-programme appelant – à l'aide de l'instruction throw (littéralement : « lance! »).

Remarquez la syntaxe : on lève une exception, à laquelle on joint (du moins dans cet exemple) un bout de texte descriptif – le message de l'exception.

Notre programme est maintenant tel que visible ci-dessous.

#include <iostream>
#include <exception>
int div_entiere(int num, int denom);
int main ()
{
   using namespace std;
   int numer, denom;
   if (cin >> numer >> denom)
   {
      int resultat = div_entiere(numer, denom);
      cout << resultat << endl;
   }
}
int div_entiere(int num, int denom)
{
   using std:exception;
   if (!denom)
      throw exception{"Div. par zéro"};
   int quotient = num / denom;
   return quotient;
}

Attraper une exception et y réagir

Le sous-programme appelant (le code client) est responsable de capter l'exception et d'y réagir. S'il n'est pas en mesure de réagir correctement à l'erreur, il peut la laisser filtrer au sous-programme l'ayant lui-même appelé (à son propre client, donc) ou la relancer explicitement au sous-programme l'ayant lui-même appelé, qui peut la relancer lui aussi, et ainsi de suite jusqu'à ce qu'un sous-programme convenable finisse par traiter l'exception.

Appeler un sous-programme susceptible de lever une exception est toujours un essai, puisqu'on ne sait pas si le sous-programme va compléter normalement ou s'il va lever une exception avant de se terminer. Aussi, si l'on souhaite réagir à une éventuelle levée d'exception, faut-il envelopper l'appel dans ce qu'on appelle un bloc try.

Tout bloc try doit être suivi d'un bloc catch, qui sert à « attraper » – s'il y a lieu – l'exception lancée par le sous-programme appelé. C'est dans le bloc catch qu'on effectuera le traitement d'erreur.

Plusieurs langages offrent aussi un bloc finally, qui sera sollicité inconditionnellement, que l'exécution soit passée par le bloc catch ou non. Ce bloc sert alors pour le nettoyage des ressources allouées à l'intérieur du sous-programme. En C++, on appliquera plutôt l'idiome RAII. Ce texte sur les exceptions discute de telles pratiques plus en profondeur.

Notre programme est maintenant tel que visible ci-dessous. Nous décrirons plus en détail son fonctionnement un peu plus bas.

#include <iostream>
#include <exception>
int div_entiere(int num, int denom);
int main()
{
   using namespace std;
   int numer, denom;
   if (cin >> numer >> denom)
   {
      try
      {
         int resultat = div_entiere(numer, denom);
         cout << resultat;
      }
      catch (exception e)
      {
         cout << "Exception rencontrée: "
              << e.what() << endl;
      }
   }
}
int div_entiere(int num, int denom)
{
   using std::exception;
   if (!denom)
      throw exception{"Div. par zéro"};
   int quotient = num / denom;
   return quotient;
}

Petite remarque : ce code attrape l'exception levée par valeur, mais il est aussi possible (préférable, même!) de l'attraper par référence (catch (exception &e)), ce qui peut être économique en fonction du type d'exception à gérer.

La mécanique (en détail)

Examinons en détail la mécanique de levée d'une exception et de sa gestion subséquente, à partir de notre petit exemple.

Dans le cas où le dénominateur vaut zéro, donc quand une exception sera reconnue, les lignes du sous-programme qui seront exécutées seront les lignes (0) où on fait la vérification de la valeur de denom, et (1) qui lève l'exception ayant pour message "Div. par zéro".

int div_entiere(int num, int denom)
{
   using std::exception;
   if (!denom)                          // (0)
      throw exception{"Div. par zéro"}; // (1)
   int quotient = num / denom;          // (2)
   return quotient;                     // (3)
}

Lever une exception complète l'exécution du sous-programme; c'est pourquoi, dans ce cas-ci, le return ne sera pas atteint.

Par contre, si le dénominateur diffère de zéro, les lignes du sous-programme qui seront exécutées seront les lignes (0) où on fait la vérification de la valeur de denom, (2) qui procède – en toute sécurité – à la division demandée et (3) qui retourne le quotient calculé.

Examinons maintenant le code où l'exception sera gérée.

Maintenant, si on porte attention à l'appel[1], on voit ce qui suit (à droite).

Que le sous-programme appelé (div_entiere) lance une exception ou non, les lignes (0) où se trouve le signal du début d'un bloc try, suivi de (1) où on fait l'appel du sous-programme, seront exécutées.

C'est ensuite que l'action commence.

// (... avant ...)
try                                          // (0)
{
   int resultat = div_entiere(numer, denom); // (1)
   cout << resultat << endl;                 // (2)
}
catch (exception e)                          // (3)
{
   cout << e.what();                         // (4)
}
// (... après...)

Si aucune exception n'est levée, alors l'exécution du sous-programme div_entiere() s'est complétée normalement et la fonction a retourné le résultat de ses calculs. Dans ce cas, l'exécution se poursuivra à la ligne (2), qui affiche le résultat calculé, puis à la ligne identifiée par (...après...), qui suit le bloc catch.

En effet, si aucune exception n'est levée, le bloc catch est escamoté – ce qui est raisonnable, puisque ce bloc sert au traitement des cas d'exception, et que dans ce cas, il n'y en a pas eu.

Si une exception est levée, par contre, l'appel à div_entiere() échouera. Après l'instruction à la ligne ayant lancé l'exception n'étant pas complétée, la variable devant recevoir le résultat de l'exécution du sous-programme (dans notre cas, la variable resultat dans l'expression resultat = div_entiere(num,denom);) n'aura pas reçu de valeur, puisque le sous-programme appelé n'aura pas effectué de return.

La ligne (1) ne complétera pas, donc, et la prochaine ligne exécutée sera la (3), où débute le traitement d'erreur associé à l'exception lancée. L'instruction catch est suivie de parenthèses à l'intérieur desquelles on voit le type d'exception, puis un nom de variable (ici : e). Cela signifie que l'exception levée par le sous-programme appelé sera nommée e à l'intérieur du bloc catch.

L'exception ici nommée e n'existe que pour la durée du bloc catch. Une fois les accolades de ce bloc fermes , la variable e n'existe officiellement plus, et est par conséquent inaccessible au sous-programme.

L'exécution lors d'une exception se poursuit à la ligne (4), où nous procédons au traitement d'erreur à proprement dit. Nous avons ici fait le traitement d'erreur le plus simple qui consiste à afficher le message de l'exception attrapée.

L'instruction e.what() retourne une chaîne de caractères contenant le message de l'exception.

Une fois le bloc catch terminé, l'exécution se poursuivra à la ligne identifiée par (...après...), tout comme dans le cas où aucune exception n'aurait été levée.

Exceptions et classes – un portrait plus complet

Examinons maintenant comment on pourrait créer un type plus près d.être « complet », type nommé TableauEntiers, qui utiliserait les exceptions à son avantage. Nous profiterons de cet examen pour montrer plus en détail comment les exceptions s'intègrent au langage C++. Pour un type plus complet, voir le code générique sur cette page.

Notre classe allouera un tableau d'entiers dont la taille sera connue à la construction d'une instance, et libérera ce tableau lors de sa destruction.

class TableauEntiers
{
   int *tab_;
   int nelems_;
public:
   int size() const
      { return nelems_; }
   int operator[](int n) const
      { return tab_[n]; }
   int& operator[](int n)
      { return tab_[n]; }
   TableauEntiers(int n)
      : nelems_{n}
   {
      tab_ = new int[size()];
   }
   ~TableauEntiers()
      { delete[] tab_; }
   //
   // note: pour compléter la Sainte-Trinité, il faudrait
   // ajouter à cette classe l'affectation et un constructeur
   // de copie. N'utilisez pas cette classe si ces ajouts n'y
   // ont pas été faits -- elle laisserait fuir des ressources!
   //
};

La classe pourrait être telle que proposée ci-dessus. On remarque :

Sachant ceci, demandons-nous quels sont les cas d'exception possibles lors d'opérations sur une instance de TableauEntiers.

Un premier cas identifiable serait celui où, lors de l'appel du constructeur, la taille passée en paramètre serait illégale (négative; il est légal de créer dynamiquement un tableau de zéro éléments en C++). On pourrait alors dire qu'il s'agit d'une exception de type TailleInvalide.

La manière la plus élégante de gérer un tel cas est de créer un type, par exemple une classe publique interne à la classe TableauEntiers, représentant le problème à souligner. Nous nommerons cette classe TailleInvalide et, en faisant d'elle une classe interne à TableauEntiers, son nom complet sera TableauEntiers::TailleInvalide.

Si la taille est invalide (vérifié par l'alternative en caractères gras), une instance de TailleInvalide sera levée. Les parenthèses suivant TailleInvalide indiquent explicitement qu'il s'agit d'un appel de constructeur (le constructeur par défaut).

Avant C++ 11, il arrivait qu'on ajoute la mention throw(TailleInvalide) pour documenter à même le code le risque de levée d'exception de ce type. Cette pratique est aujourd'hui obsolète, n'ayant essentiellement que des défauts (ce que nous ne savions pas à l'époque). Voir cet article pour plus de détails.

class TableauEntiers
{
   int *tab_;
   int nelems_;
public:
   class TailleInvalide { };
   int size() const
      { return nelems_; }
   int operator[](int n) const
      { return tab_[n]; }
   int& operator[](int n)
      { return tab_[n]; }
   TableauEntiers(int n)
      : nelems_{n}
   {
      if (size() < 0) throw TailleInvalide{};
      tab_ = new int[size()];
   }
   ~TableauEntiers()
      { delete[] tab_; }
};

Un second cas identifiable serait celui où, pendant la construction, la mémoire disponible s'avère insuffisante pour obtenir un tableau de la taille réclamée. On pourrait alors dire qu'il s'agit d'une exception de type MemoireInsuffisante, mais le standard exprime déjà ce concept à l'aide de l'exception standard std::bad_alloc, que vous trouverez dans l'en-tête standard <new>.

Nous verrons ci-dessous pourquoi il est pratique d'utiliser des classes distinctes pour chaque type d'exception. Notez aussi que nous aurions pu faire dériver nos classes d'exceptions de la classe exception standard, mais que cela n'est pas nécessaire.

class TableauEntiers
{
   int *tab_;
   int nelems_;
public:
   class TailleInvalide { };
   int size() const
      { return nelems_; }
   int operator[](int n) const
      { return tab_[n]; }
   int& operator[](int n)
      { return tab_[n]; }
   TableauEntiers(int n)
      : nelems_{n}
   {
      if (size() < 0) throw TailleInvalide{};
      tab_ = new int[size()]; // lève peut-être std::bad_alloc
   }
   ~TableauEntiers()
      { delete[] tab_; }
};

Un autre cas exceptionnel serait celui où un indice invalide serait passé aux opérateurs [] constant et non constant. Dans ce cas, l'exception pourrait se nommer HorsBornes; le standard de C++ offre aussi std::out_of_range à cette fin.

L'utilisation d'exceptions dans les méthodes des opérateurs [] peut sembler abusive, mais souvenez-vous que, contrairement à Java, C++ n'oblige pas l'appelant à envelopper chaque appel à un sous-programme sujet à lancer une exception d'un bloc try. Ainsi, un appelant bien élevé n'aura pas à être alourdi par cet ajout.

class TableauEntiers
{
   int *tab_;
   int nelems_;
public:
   class TailleInvalide { };
   class HorsBornes { };
   int size() const
      { return nelems_; }
   int operator[](int n) const
   {
      if (n< 0 || n >= size()) throw HorsBornes{};
      return tab_[n];
   }
   int& operator[](int n)
   {
      if (n < 0 || n >= size()) throw HorsBornes{};
      return tab_[n];
   }
   TableauEntiers(int n)
      : nelems_{n}
   {
      if (size() <= 0) throw TailleInvalide{};
      tab_ = new int[size()];
   }
   ~TableauEntiers()
      { delete[] tab_; }
};

Pour compléter le portrait, il faut noter que certaines méthodes garantissent ne pas lancer d'exceptions. On pense en particulier à la méthode size() et – surtout! – au destructeur. Un destructeur levant une exception signifie la fin du programme. Ne faites pas ça!

Indiquer dans le prototype d'un sous-programme qu'il ne lèvera jamais d'exception se fait en C++ 03 par l'ajout de la clause throw() sans nom de type entre les parenthèses (donc avec parenthèses vides), ce qui est remplacé en C++ 11 par la bien plus utile clause noexcept. Ceci est une garantie de sécurité précieuse : l'appelant sait alors que l'appelé s'engage à ne pas lancer d'exceptions au cours de son exécution, et – avec noexcept – le compilateur peut s'en assurer. Notez que les destructeurs sont implicitement noexcept en C++ 11.

Dans tous les cas, même avec les clauses throw(X,Y) de C++ 03, ne pas respecter une clause d'exception (annoncer ne lever que X et Y mais en venir à lever plutôt Z, par exemple) fera planter le programme en appelant une fonction standard nommée std::unexpected(). Le programme est alors officiellement mort.

class TableauEntiers
{
   int *tab_;
   int nelems_;
public:
   class TailleInvalide { };
   class HorsBornes { };
   int size() const noexcept
      { return nelems_; }
   int operator[](int n) const
   {
      if (n < 0 || n >= size()) throw HorsBornes{};
      return tab_[n];
   }
   int& operator[](int n)
   {
      if (n < 0 || n >= size()) throw HorsBornes{};
      return tab_[n];
   }
   TableauEntiers(int n)
      : nelems_{n}
   {
      if (nelems_ <= 0) throw TailleInvalide{};
      tab_ = new int[size()];
   }
   ~TableauEntiers() noexcept
      { delete[] tab_; }
};

L'avantage de classes différentes selon les types d'exceptions

Pourquoi utiliser des types différents pour chaque catégorie d'exception? Parce que cela permet de déduire la nature d'un problème à l'aide d'une séquence de blocs catch, ce qui est très simple et très élégant – sans compter que c'est plus rapide que la plupart des alternatives possibles.

À titre d'exemple, examinez le programme suivant, qui instancie un TableauEntiers à partir d'une taille arbitraire et non validée. On décode la nature (et la sévérité) de l'exception (si exception il y a) selon le bloc catch qui est entré.

#include "TableauEntiers.h"
#include <iostream>
#include <new>
int main()
{
   using namespace std;
   bool tres_grave = false;
   do
   {
      int n;
      try
      {
         if (cin >> n)
         {
            TabEntiers tab{n}; // risques d'exceptions
            // utiliser tab...
         }
      }
      catch (TableauEntiers::TailleInvalide)
      {
         //
         // ici, nous savons que n a été lu, car la lecture
         // avec cin ne lève pas d'exceptions lorsqu'elle
         // rencontre une erreur
         //
         cerr << "La taille " << n << "est invalide" << endl
              << "Veuillez recommencer: ";
      }
      catch (bad_alloc)
      {
         cerr << "Plus de mémoire; on abandonne..." << endl;
         tres_grave = true; // rien à faire avec ça
      }
      catch (...) // l'ellipse, ..., attrape n'importe quoi
      {
         cerr << "Erreur inconnue; on abandonne..." << endl;
         tres_grave = true; // rien à faire avec ça
      }
   }
   while (!tres_grave);
}

Voilà.


[1] Pour abréger, seule la section portant sur l'appel du sous-programme a été conservée.


Valid XHTML 1.0 Transitional

CSS Valide !