• 50 heures
  • Difficile

Ce cours est visible gratuitement en ligne.

Ce cours existe en livre papier.

course.header.alt.is_certifying

Vous pouvez être accompagné et mentoré par un professeur particulier par visioconférence sur ce cours.

J'ai tout compris !

Mis à jour le 02/08/2019

Gérez des erreurs avec les exceptions

Connectez-vous ou inscrivez-vous gratuitement pour bénéficier de toutes les fonctionnalités de ce cours !

Jusqu'ici, nous avons toujours supposé que tout se déroulait bien dans nos programmes. Mais ce n'est pas toujours le cas, des problèmes peuvent survenir. Pensez par exemple aux cas suivants : un fichier qui ne peut pas être ouvert, la mémoire qui est saturée, un tableau trop petit pour ce que l'on souhaite y stocker, etc.

Les exceptions sont un moyen de gérer efficacement les erreurs qui pourraient survenir dans votre programme ; on peut alors tenter de traiter ces erreurs, remettre le programme dans un état normal et reprendre l'exécution du programme.

Dans ce chapitre, je vais vous apprendre à créer des exceptions, à les traiter et à sécuriser vos programmes en les rendant plus robustes.

Un problème bien ennuyeux

En programmation, quel que soit le langage utilisé (et donc en C++), il existe plusieurs types d'erreurs pouvant survenir. Parmi les erreurs possibles, on connaît déjà les erreurs de syntaxe qui surviennent lorsque l'on fait une faute dans le code source, par exemple si l'on oublie un point-virgule à la fin d'une ligne.
Ces erreurs sont faciles à corriger car le compilateur peut les signaler.

Un autre type de problème peut survenir si le programme est écrit correctement mais qu'il exécute une action interdite. On peut citer comme exemple le cas où l'on essaye de lire la 10ème case d'un tableau de 8 éléments ou encore le calcul de la racine carrée d'un nombre négatif.
On appelle ces erreurs les erreurs d'implémentation.

La gestion des exceptions permet, si elle est réalisée correctement, de traiter les erreurs d'implémentation en les prévoyant à l'avance. Cela n'est pas toujours réalisable de manière exhaustive car il faudrait penser à toutes les erreurs susceptibles de survenir, mais on peut facilement en éviter une grande partie.
Pour comprendre le but de la gestion des exceptions, le plus simple est de prendre un exemple concret.

Exemple d'erreur d'implémentation

Cet exemple n'est pas très original (on le trouve dans presque tous les livres) mais c'est certainement parce que c'est un des cas les plus simples.

Imaginons que vous ayez décidé de réaliser une calculatrice. Vous auriez par exemple pu coder la division de deux nombres entiers de cette manière :

int division(int a,int b) // Calcule a divisé par b.
{
   return a/b;
}
 
int main()
{
   int a,b;
   cout << "Valeur pour a : ";
   cin >> a;
   cout << "Valeur pour b : ";
   cin >> b;
 
   cout << a << " / " << b << " = " << division(a,b) << endl;
   
   return 0;
}

Ce code est tout à fait correct et fonctionne parfaitement, sauf dans un cas : sibvaut 0. En effet, la division par 0 n'est pas une opération arithmétique valide. Si on lance le programme avecb=0, on obtient une erreur et le message suivant s'affiche :

Valeur pour a : 3
Valeur pour b : 0
Exception en point flottant (core dumped)

Il faudrait donc éviter de réaliser le calcul sibvaut 0, mais que faire à la place ?

Quelques solutions inadéquates

Une première possibilité serait de renvoyer, à la place du résultat, un nombre prédéfini. Cela donnerait par exemple :

int division(int a,int b) // Calcule a divisé par b.
{
   if(b!=0)   // Si b ne vaut pas 0.
      return a/b;
   else         // Sinon.
      return ERREUR;
}

Il faudrait spécifier une valeur précise pourERREUR. Mais cela pose un nouveau problème : quelle valeur choisir pourERREUR? On ne peut pas renvoyer un nombre puisque, dans un cas normal, tous les nombres sont susceptibles d'être renvoyés par la fonction. Ce n'est donc pas une bonne solution.

Une autre idée que l'on rencontre souvent consiste à afficher un message d'erreur, ce qui donnerait quelque chose comme :

int division(int a,int b) // Calcule a divisé par b.
{
   if( b!=0)   // Si b ne vaut pas 0.
      return a/b;
   else         // Sinon.
      cout << "ERREUR : Division par 0 !" << endl;
}

Mais cela pose deux nouveaux problèmes : non seulement la fonction ne renvoie aucune valeur en cas d'erreur mais, de surcroît, elle génère alors sur un effet de bord. Il faut comprendre que la fonctiondivisionn'est pas forcément censée utilisercout, surtout si, par exemple, on a réalisé un programme avec une GUI (Graphical User Interface) comme Qt.

La troisième et dernière solution, que l'on rencontre parfois dans certaines bibliothèques, consiste à modifier la signature et le type de retour de la fonction de la manière suivante :

bool division(int a,int b, int& resultat)
{
   if(b!=0)     // Si b est différent de 0.
   {
       resultat = a/b;   // On effectue le calcul et on met le résultat dans la variable passée en argument.
       return true;        // On renvoie vrai pour montrer que tout s'est bien passé.
   }
   else        // Sinon
       return false;       // On renvoie false pour montrer qu'une erreur s'est produite.
}

Cette solution est la meilleure des 3 proposées (ceux qui connaissent le C sont habitués à ces choses), mais elle souffre d'un gros problème : son utilisation n'est pas du tout évidente. Il est en particulier impossible de réaliser le calcul $\($$$a / (b / c)$$$\)$de manière simple et intuitive.

La gestion des exceptions

Voyons comment résoudre ce problème de manière élégante en C++.

Principe général

Le principe général des exceptions est le suivant :

  • on crée des zones où l'ordinateur va essayer le code en sachant qu'une erreur peut survenir ;

  • si une erreur survient, on la signale en lançant un objet qui contient des informations sur l'erreur ;

  • à l'endroit où l'on souhaite gérer les erreurs survenues, on attrape l'objet et on gère l'erreur.

C'est un peu comme si vous étiez coincés sur une île déserte. Vous lanceriez à la mer une bouteille contenant avec des informations qui permettent de vous retrouver. Il n'y aurait alors plus qu'à espérer que quelqu'un attrape votre bouteille (sinon vous mourrez de faim).
C'est la même chose ici, on lance un objet en espérant qu'un autre bout de code le rattrapera, sinon le programme plantera.

Les mot-clés du C++ qui correspondent à ces actions sont les suivants :

  • try{ ...}(en français essaye) signale une portion de code où une erreur peut survenir ;

  • throw(en français lance) signale l'erreur en lançant un objet ;

  • catch(...){...}(en français attrape) introduit la portion de code qui récupère l'objet et gère l'erreur.

Voyons cela plus en détail.

Les trois mot-clés en détail

Commençons partry, il est très simple d'utilisation. Il permet d'introduire un bloc sensible aux exceptions, c'est-à-dire qu'on indique au compilateur qu'une certaine portion du code source pourrait lancer un objet (la bouteille à la mer).

On l'utilise comme ceci :

// Du code sans risque.
try
{
   // Du code qui pourrait créer une erreur.
}

Entre les accolades du bloctryon peut trouver n'importe quelle instruction C++, notamment un autre bloctry.

Le mot-cléthrowest lui aussi très simple d'utilisation. C'est grâce à lui qu'on lance la bouteille à la mer. La syntaxe est la suivante :throw expression

On peut lancer n'importe quoi comme objet, par exemple unintqui correspond au numéro de l'erreur ou unstringcontenant le texte de l'erreur. On verra plus loin un type d'objet particulièrement utile pour les erreurs.

throw 123;   // On lance l'entier 123, par exemple si l'erreur 123 est survenue
 
throw string("Erreur fatale. Contactez un administrateur"); // On peut lancer un string.
 
throw Personnage; // On peut tout à fait lancer une instance d'une classe.

throw 3.14 * 5.12; // Ou même le résultat d'un calcul

Terminons avec le mot-clécatch. Il permet de créer un bloc de gestion d'une exception survenue. Il faut créer un bloccatchpar type d'objet lancé. Chaque bloctrydoit obligatoirement être suivi d'un bloccatch. Inversement, tout bloccatchdoit être précédé d'un bloctryou d'un autre bloccatch.

La syntaxe est la suivante :catch (type const& e){...}

Cela donne par exemple :

try
{
    // Le bloc sensible aux erreurs.
}
catch(int e) //On rattrape les entiers lancés (pour les entiers, une référencen'a pas de sens)
{
   //On gère l'erreur
}
catch(string const& e) //On rattrape les strings lancés
{
   // On gère l'erreur
}
catch(Personnage const& e) //On rattrape les personnages
{
   //On gère l'erreur
}

Qu'est-ce que cela va changer durant l'exécution du programme ?

À l'exécution, le programme se déroule normalement comme si les instructionstryet les blocscatchn'étaient pas là.
Par contre, au moment où l'ordinateur arrive sur une instructionthrow, il saute toutes les instructions suivantes et appelle le destructeur de tous les objets déclarés à l'intérieur du bloctry. Il cherche le bloccatchcorrespondant à l'objet lancé.
Arrivé au bloccatch, il exécute ce qui se trouve dans le bloc et reprend l'exécution du programme après le bloccatch.

Le mieux pour comprendre le fonctionnement est encore de reprendre l'exemple de la calculatrice et de la division par 0.

La bonne solution

Reprenons donc notre fonction de calculatrice.

int division(int a, int b)
{
    return a/b;
}

Nous savons qu'une erreur peut survenir sibvaut 0, il faut donc lancer une exception dans ce cas. J'ai choisi, arbitrairement, de lancer une chaîne de caractères. C'est néanmoins un choix intéressant, puisque l'on peut ainsi décrire le problème survenu.

int division(int a,int b)
{
    if(b == 0)
       throw string("ERREUR : Division par zéro !");
    else
       return a/b;
}

Souvenez-vous, unthrowdoit toujours se trouver dans un bloctryqui doit lui-même être suivi d'un bloccatch. Cela donne la structure suivante :

int division(int a,int b)
{
    try
    {
        if(b == 0)
           throw string("Division par zéro !");
        else
           return a/b;
   }
   catch(string const& chaine)
   {
       // On gère l'exception.
   }
}

Il ne reste plus alors qu'à gérer l'erreur, c'est-à-dire par exemple afficher un message d'erreur.

int division(int a,int b)
{
    try
    {
        if(b == 0)
           throw string("Division par zéro !");
        else
           return a/b;
   }
   catch(string const& chaine)
   {
       cerr << chaine << endl;
   }
}

Cela donne le résultat suivant :

Valeur pour a : 3
Valeur pour b : 0
ERREUR : Division par zéro !

Cette manière de faire est correcte. Cependant, cela ressemble un peu au mauvais exemple numéro 2 ci-dessus. En effet, la fonction est susceptible d'écrire dans la console alors que ce n'est pas son rôle. De plus, le programme continue alors qu'une erreur est survenue. Le mieux à faire serait alors de lancer l'exception dans la fonction et de récupérer l'erreur, si elle se produit, dans lemain. De cette manière, celui qui appelle la fonction a conscience qu'une erreur s'est produite.

int division(int a,int b) // Calcule a divisé par b.
{
   if(b==0)
      throw string("ERREUR : Division par zéro !");
   else
      return a/b;
}
 
int main()
{
    int a,b;
    cout << "Valeur pour a : ";
    cin >> a;
    cout << "Valeur pour b : ";
    cin >> b;
 
    try
    {
        cout << a << " / " << b << " = " << division(a,b) << endl;
    }
    catch(string const& chaine)
    {
        cerr << chaine << endl;
    }
    return 0;
}

Vous pouvez remarquer que lethrowne se trouve pas directement à l'intérieur du bloctrymais qu'il se trouve à l'intérieur d'une fonction qui est appelée, elle, dans un bloctry.

Cette fois, le programme ne plante plus et la fonction n'a plus d'effet de bord. C'est la meilleure solution.

Les exceptions standard

Maintenant que l'on sait gérer les exceptions, la question principale est de savoir quel type d'objet lancer.

Je vous ai présenté auparavant la possibilité de lancer des exceptions de type entier oustring. On peut aussi, par exemple, lancer un objet qui contiendrait plusieurs attributs comme :

  • une phrase décrivant l'erreur ;

  • le numéro de l'erreur ;

  • le niveau de l'erreur (erreur fatale, erreur mineure...) ;

  • l'heure à laquelle l'erreur est survenue ;

  • etc.

Un bon moyen de réaliser ceci est de dériver la classeexceptionde la bibliothèque standard du C++. Eh oui, là aussi la SL vient à notre secours.

La classeexception

La classeexceptionest la classe de base de toutes les exceptions lancées par la bibliothèque standard. Elle est aussi spécialement pensée pour qu'on puisse la dériver afin de réaliser notre propre type d'exception. La définition de cette classe est :

class exception 
{
public:
    exception() throw(){ } //Constructeur.
    virtual  exception() throw(); //Destructeur.
 
    virtual const char* what() const throw(); //Renvoie une chaîne "à la C" contenant des infos sur l'erreur.
};

Pour l'utiliser, il faut inclure le fichier d'en-tête correspondant soit, ici, le fichierexception.

On peut alors créer sa propre classe d'exception en la dérivant grâce à un héritage. Cela donnerait par exemple :

#include <exception>
using namespace std;
 
class Erreur: public exception
{
public:
    Erreur(int numero=0, string const& phrase="", int niveau=0) throw()
         :m_numero(numero),m_phrase(phrase),m_niveau(niveau)
    {}
 
     virtual const char* what() const throw()
     {
         return m_phrase.c_str();
     }
     
     int getNiveau() const throw()
     {
          return m_niveau;
     }
    
    virtual ~Erreur() throw()
    {}
 
private:
    int m_numero;               //Numéro de l'erreur
    string m_phrase;            //Description de l'erreur
    int m_niveau;               //Niveau de l'erreur
};

On pourrait alors réécrire la fonction de division de 2 entiers de la manière suivante :

int division(int a,int b) // Calcule a divisé par b.
{
   if(b==0)
      throw Erreur(1,"Division par zéro",2);
   else
      return a/b;
}
 
int main()
{
   int a,b;
   cout << "Valeur pour a : ";
   cin >> a;
   cout << "Valeur pour b : ";
   cin >> b;
 
   try
   {
       cout << a << " / " << b << " = " << division(a,b) << endl;
   }
   catch(std::exception const& e)
   {
       cerr << "ERREUR : " << e.what() << endl;
   }
 
   return 0;
}

Cela donne à l'exécution :

Valeur pour a : 3
Valeur pour b : 0
ERREUR : Division par zéro

Quel est l'intérêt de dériver la classeexceptionalors qu'on pourrait faire sa propre classe sans aucun héritage ?

Excellente question. Il faut savoir que vous n'êtes pas les seuls à lancer des exceptions. Certaines fonctions standard lancent elles aussi des exceptions. Toutes les exceptions lancées par les fonctions standard dérivent de la classeexceptionce qui permet, avec un code générique, de rattraper toutes les erreurs qui pourraient arriver. Ce code générique est le suivant :

catch(std::exception const& e)
{
   cerr << "ERREUR : " << e.what() << endl;
}

Cette possibilité résulte du polymorphisme. On attrape un objet de typeexceptionmais, grâce aux fonctions virtuelles et à la référence (les deux ingrédients !), c'est la méthodewhat()de la classe fille qui sera appelée, ce qui est justement ce que l'on souhaite.

La bibliothèque standard peut lancer 5 types d'exceptions différents résumés dans le tableau suivant :

Nom de la classe

Description

bad_alloc

Lancée s'il se produit une erreur en mémoire.

bad_cast

Lancée s'il se produit une erreur lors d'un dynamic_cast.

bad_exception

Lancée si aucuncatchne correspond à un objet lancé.

bad_typeid

Lancée s'il se produit une erreur lors d'untypeid.

ios_base::failure

Lancée s'il se produit une erreur avec un flux.

On peut par exemple observer un exemple debad_allocavec le code suivant :

#include <iostream>
#include <vector>
using namespace std; 

int main()
{
    try
    {
        vector<int> a(1000000000,1); //Un tableau bien trop grand
    }
    catch(exception const& e) //On rattrape les exceptions standard de tous types
    {
        cerr << "ERREUR : " << e.what() << endl; //On affiche la description de l'erreur
    }
    return 0;
}

Cela donne le résultat suivant dans la console :

ERREUR : std::bad_alloc

Le travail pré-mâché

Si comme moi (et beaucoup de programmeurs) vous êtes des fainéants et que vous n'avez pas envie de créer votre propre classe d'exception, sachez qu'il existe un fichier standard qui contient des classes d'exception pour les cas les plus courants.
Le fichierstdexceptcontient 9 classes d'exceptions séparées en 2 catégories, les exceptions « logiques » (logic errors en anglais) et les exceptions « d'exécution » (runtime errors en anglais).

Toutes les exceptions présentées dérivent de la classeexceptionet possèdent un constructeur prenant en argument une chaîne de caractères qui décrit le problème.

Nom de la classe

Catégorie

Description

domain_error

logique

Erreur de domaine mathématique.

invalid_argument

logique

Argument invalide passé à une fonction.

length_error

logique

Taille invalide.

out_of_range

logique

Erreur d'indice de tableau.

logic_error

logique

Autre problème de logique.

range_error

exécution

Erreur de domaine.

overflow_error

exécution

Erreur d'overflow.

underflow_error

exécution

Erreur d'underflow.

runtime_error

exécution

Autre type d'erreur.

Si vous ne savez pas quoi choisir, prenez simplementruntime_error, cela n'a de toute façon que peu d'importance.

Et comment les utilise-t-on ?

Reprenons une dernière fois notre exemple de division. Nous avons une erreur de domaine mathématique si l'argumentbest nul. Choisissons donc de lancer unedomain_error.

int division(int a,int b) // Calcule a divisé par b.
{
   if(b==0)
      throw domain_error("Division par zéro");
   else
      return a/b;
}

Les exceptions devector

Je vous ai dit dans l'introduction qu'une erreur possible (et courante !) était le cas où un utilisateur cherche à accéder à la 10ème case d'unvectorde 8 éléments.
Accéder aux objets stockés dans un tableau, vous savez le faire depuis longtemps : on utilise bien sûr les crochets[]. Or ces crochets ne font aucun test. Si vous fournissez un index invalide, le programme va planter et c'est tout.
Et après ce chapitre, on pourrait se demander si c'est vraiment une bonne idée. Utiliser une exception en cas d'erreur d'index vous paraît peut-être une bonne idée... et aux concepteurs de la STL aussi !
C'est pour cela que lesvector(et lesdeque) proposent une méthode appeléeat()qui fait exactement la même chose que les crochets mais qui lance une exception en cas d'indice erroné.

#include <vector>
#include <iostream>
using namespace std;

int main()
{
    vector<double> tab(5, 3.14);  //Un tableau de 5 nombres à virgule

    try
    {
        tab.at(8) = 4.;  //On essaye de modifier la 8ème case
    }
    catch(exception const& e)
    {
        cerr << "ERREUR : " << e.what() << endl;
    }
    return 0;
}

Cela nous donne :

ERREUR : vector::_M_range_check

Encore un nouveau type d'exception ! Oui, oui, mais ce n'est pas grave car, comme je vous l'ai dit, tous les types d'exceptions utilisés dérivent de la classeexceptionet notrecatch« standard » est donc suffisant. Par conséquent, il n'y a qu'une seule syntaxe à apprendre. Plutôt sympa non ?

Terminons avec un point qui pourrait vous sauver la vie lors de la lecture de codes sources obscurs.

Relancez une exception

Il est possible de relancer une exception reçue par un bloccatchafin de la traiter une deuxième fois, plus loin dans le code. Pour ce faire, il faut utiliser le mot-cléthrowsans expression derrière.

catch(exception const& e) // Rattrape toutes les exceptions
{
   //On traite une première fois l'exception
   cerr << "ERREUR: " << e.what() << endl;
 
   throw; // Et on relance l'exception reçue pour la retraiter 
          // dans un autre bloc catch plus loin dans le code.
}

Les assertions

Les exceptions c'est bien mais il y a des cas où mettre en place tous ces blocstry/catchest fastidieux. Ce n'est pas pour rien quevectorpropose les[]pour accéder aux éléments. On n'a pas toujours envie d'avoir à traiter les exceptions.
Il existe un autre mécanisme de détection et de gestion qui vient du langage C : les assertions.

Claquez une assertion

Pour utiliser les assertions, il faut inclure le fichier d'en-têtecassert. Et c'est certainement l'étape la plus difficile.

Une assertion permet de tester si une expression est vraie ou non. Si c'est vrai, rien ne se passe et le programme continue. Par contre, si le test est négatif, le programme s'arrête brutalement et un message d'erreur s'affiche dans le terminal.

#include <cassert>
using namespace std;

int main()
{
    int a(5);
    int b(5);

    assert(a == b) ; //On vérifie que a et b sont égaux
    
    //reste du programme
    return 0;
}

Lors de l'exécution, rien ne se passe. Normal, les deux variables sont égales. Par contre, si vous modifiez la valeur deb, le message suivant s'affiche alors à l'exécution :

monProg: main.cpp:9: int main(): Assertion `a == b' failed.
Abandon

C'est super : le message d'erreur indique le fichier où se situe l'erreur, le nom de la fonction et même la ligne ! Avec cela, impossible de ne pas trouver la cause d'erreur. Je vous avais bien dit que c'était simple !

Mais pourquoi utiliser des exceptions si les assertions sont mieux ?

Attention, je n'ai pas dit que les assertions étaient mieux ! Les deux méthodes de gestion des erreurs ont leur domaine d'application. Si vous claquez une assertion, le programme s'arrête brutalement. Il n'y a aucun moyen de réparer l'erreur et tenter de continuer. Si vous avez un programme de chat et qu'il n'arrive pas à se connecter au serveur, c'est une erreur. Vous aimeriez bien que votre programme réessaye de se connecter plusieurs fois. Il faut donc utiliser une exception pour tenter de réparer l'erreur. Une assertion aurait complètement tué le programme. Ce n'est clairement pas la bonne solution dans ce cas !
À vous de choisir ce dont vous avez besoin au cas par cas.

Désactivez les assertions

Un autre point fort des assertions est la possibilité de les désactiver totalement. Dans ce cas, le compilateur ignore simplement les lignesassert(...)et n'effectue pas le test qui se trouve entre les parenthèses. Ainsi, le code sera (légèrement) plus rapide, mais aucun test ne sera effectué. Il faut donc choisir.

Pour désactiver les assertions, il faut ajouter l'option-DNDEBUGà la ligne de compilation.

Si vous utilisez Code::Blocks, cela se fait via le menuproject>build options. Dans la fenêtre qui s'ouvre, sélectionnez l'ongletCompiler settingspuis, dans le champOther options, ajoutez simplement-DNDEBUGcomme à la figure suivante.

Désactiver les assertions

Avec cette option activée, le code d'exemple précédent s'exécute sans problème même siaest différent deb. La ligne de test a simplement été ignorée par le compilateur.

En résumé

  • Dans tous les programmes, des erreurs peuvent survenir. Les exceptions servent à réparer ces erreurs.

  • Les exceptions sont lancées grâce au mot-cléthrow, placé dans un bloctry, et rattrapées par un bloccatch.

  • La bibliothèque standard propose la classeexceptioncomme base pour créer ses exceptions personnalisées.

  • Les assertions permettent aux développeurs de trouver facilement les erreurs en faisant des tests lors de la phase de création d'un programme.

Exemple de certificat de réussite
Exemple de certificat de réussite