• 10 heures
  • Difficile

Ce cours est visible gratuitement en ligne.

course.header.alt.is_video

course.header.alt.is_certifying

J'ai tout compris !

Mis à jour le 10/02/2022

Gérez des erreurs avec les exceptions

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.

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

Découvrez les erreurs les plus courantes

En programmation, quel que soit le langage utilisé (et donc en C++), il existe plusieurs types d'erreurs pouvant survenir, comme :

  • Les erreurs de syntaxe qui surviennent lorsque l'on fait une faute dans le code source : l'oubli du point-virgule à la fin d'une ligne, par exemple.

(Ces erreurs sont faciles à corriger car le compilateur peut les signaler).

  • Les erreurs d'implantation qui surviennent lorsque le programme exécute une action interdite, comme lire la 10e case d'un tableau de 8 éléments, ou encore calculer la racine carrée d'un nombre négatif.

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.

Traitez les erreurs d'implémentation

Pour comprendre le but de la gestion des exceptions, le plus simple est de prendre un exemple concret. 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 : si b vaut 0.

En effet, la division par 0 n'est pas une opération arithmétique valide. Si on lance le programme avec b=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 si b vaut 0, mais que faire à la place ?

Voyons quelques-uns des réflexes que vous pourriez avoir pour résoudre ce problème. Attention, ce ne sont pas de bons réflexes !

Évitez de tomber dans le piège de ces fausses solutions

Fausse solution n° 1 : 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 pour ERREUR  . Mais cela pose un nouveau problème : quelle valeur choisir pour ERREUR  ? 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.

Fausse solution n° 2 : afficher un message d'erreur

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

  1. Non seulement la fonction ne renvoie aucune valeur en cas d'erreur.

  2. Mais, de surcroît, elle génère alors un effet de bord.

Il faut comprendre que la fonction division n'est pas forcément censée utiliser cout  .

Fausse solution n° 3 : modifier la signature et le type de retour de la fonction

Cela pourrait être fait 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, 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.

Traitez les erreurs d'implémentation avec la gestion des exceptions

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

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é sur une île déserte. Vous lanceriez à la mer une bouteille contenant 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 :

  1. try{ ...}  ("essaie", en français) signale une portion de code où une erreur peut survenir ;

  2. throw  ("lance", en français) signale l'erreur en lançant un objet ;

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

Voyons cela plus en détail.

Le mot-clé  try

try 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.
}
Le mot-clé throw

throw  permet de "lancer la bouteille à la mer". La syntaxe est la suivante : throw expression

On peut lancer n'importe quoi comme objet ; par exemple :

  • un int qui correspond au numéro de l'erreur ;

  • ou une string contenant 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
Le mot-clé  catch

catch permet de créer un bloc de gestion d'une exception survenue.

Il faut créer un bloc catch  par type d'objet lancé.

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

On attrape les exceptions par référence constante, d'où la présence du &  (pas par valeur), pour :

  • éviter une copie ;

  • préserver le polymorphisme de l'objet reçu.

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érence n'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 instructions try et les blocs catch n'étaient pas là.

Par contre, au moment où l'ordinateur arrive sur une instruction throw  :

  1. Il saute toutes les instructions suivantes, et appelle le destructeur de tous les objets déclarés à l'intérieur du bloc try  .

  2. Il cherche le bloc catch correspondant à l'objet lancé.

  3. Arrivé au bloc catch  , il exécute ce qui se trouve dans le bloc, et reprend l'exécution du programme après le bloc catch  .

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

Trouvez 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 si b vaut 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;
}

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 serait alors de lancer l'exception dans la fonction et de récupérer l'erreur, si elle se produit, dans le main  . 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 le throw ne se trouve pas directement à l'intérieur du bloc try  ,  mais qu'il se trouve à l'intérieur d'une fonction qui est appelée, elle, dans un bloc try  .

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

Gérez 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 ou string  . 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 classe exception de la bibliothèque standard du C++. Eh oui, là aussi la SL vient à notre secours.

Dérivez la classe exception

La classe exception est 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 fichier exception  .

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 deux 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 classe exception   , alors 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.

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 type exception mais, grâce aux fonctions virtuelles et à la référence (les deux ingrédients), c'est la méthode what() 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 :

  1. bad_alloc  : lancée s'il se produit une erreur en mémoire.

  2. bad_cast  : lancée s'il se produit une erreur lors d'un  dynamic_cast  .

  3. bad_exception  : lancée si aucun catch ne correspond à un objet lancé.

  4. bad_typeid  : lancée s'il se produit une erreur lors d'un typeid  .

  5. ios_base::failure  : lancée s'il se produit une erreur avec un flux.

On peut par exemple observer un exemple de bad_alloc avec 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

Utilisez le fichier  stdexcept  pour prémâcher le travail

Si comme moi, 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 fichier stdexcept contient 9 classes d'exceptions séparées en 2 catégories :

  1. Les exceptions logiques ("logic errors", en anglais).

  2. Les exceptions d'exécution ("runtime errors", en anglais).

Les exceptions logiques

Nom de la classe

Description

domain_error

Erreur de domaine mathématique.

invalid_argument

Argument invalide passé à une fonction.

length_error

Taille invalide.

out_of_range

Erreur d'indice de tableau.

logic_error

Autre problème de logique.

Les exceptions d'exécution

Nom de la classe

Description

range_error

Erreur de domaine.

overflow_error

Erreur d'overflow.

underflow_error

Erreur d'underflow.

runtime_error

Autre type d'erreur.

Si vous ne savez pas quoi choisir, prenez simplement runtime_error  .

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'argument b est nul. Choisissons donc de lancer une domain_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;
}

Connaissez les exceptions de vector

Je vous ai dit plus haut qu'une erreur possible (et courante) était le cas où on cherche à accéder à la 10e case d'un vector de 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 []  .

C'est pour cela que les vector  (et les deque  ) proposent une méthode appelée at  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 décimaux

    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 classe exception  , et notre catch "standard" est donc suffisant. Par conséquent, il n'y a qu'une seule syntaxe à apprendre. 

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 bloc catch afin de la traiter une deuxième fois, plus loin dans le code. Pour ce faire, il faut utiliser le mot-clé throw sans 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.
}

Détectez et gérez les erreurs avec les assertions

Les exceptions c'est bien, mais il y a des cas où mettre en place tous ces blocs try  / catch est fastidieux. Ce n'est pas pour rien que vector propose les []  pour accéder aux éléments. On n'a pas toujours envie d'avoir à traiter les exceptions.

Claquez une assertion

Pour utiliser les assertions, il faut inclure le fichier d'en-tête cassert  . 

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 de b  , le message suivant s'affiche alors à l'exécution :

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

Mais pourquoi utiliser des exceptions si les assertions sont mieux ?

Les deux méthodes de gestion des erreurs ont leur domaine d'application. Exemples :

  • Si vous claquez une assertion, le programme s'arrête brutalement. Il n'y a aucun moyen de réparer l'erreur et de tenter de continuer.

  • Si vous avez un programme de tchat et qu'il n'arrive pas à se connecter au serveur, c'est une erreur. Vous aimeriez bien que votre programme réessaie 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 lignes assert(...)  , 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.

Avec cette option activée, le code d'exemple précédent s'exécute sans problème, même si a est différent de b  . 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 bloc try  , et rattrapées par un bloc catch  .

  • La bibliothèque standard propose la classe exception comme 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.

Continuons avec une nouvelle notion très intéressante et qui sera très utile une fois que vous la maîtriserez. Dans le prochain chapitre, vous allez voir comment rendre votre code plus générique grâce aux templates, mais je vous préviens, c’est un concept assez complexe ; donc accrochez-vous !

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