Exceptions

Que faire en cas d'erreur ?

Que le programme doit-il faire lorsqu'il découvre une condition d'erreur lors de son exécution: par exemple une division par 0, ou encore l'ouverture d'un fichier inexistant (ou qui ne peut pas être lu pour un problème de permissions). Le problème est généralement le suivant: l'erreur se produit dans une bibliothèque; un objet doit ouvrir un fichier dont on lui a passé le nom en paramètres. L'objet voit bien qu'il y a une erreur (il n'a pas trouvé le fichier), mais comment doit-il réagir ? En fait, il ne le sait pas... parce que ce n'est pas à lui de réagir. C'est au programme utilisateur de l'objet: suivant les cas, la réaction de celui-ci sera soit l'arrêt du programme avec impression d'un message, soit tentative de reprise après avoir demandé à l'utilisateur un nouveau nom de fichier, soit génération automatique d'un nouveau nom de fichier, ... L'objet doit donc se borner à prévenir le programme appelant qu'il y a eu une erreur. Il y a trois moyens:

Les deux premiers moyens présentent certains inconvénients: tout d'abord, si la fonction, dans son fonctionnement normal, doit déjà renvoyer une valeur, comment renvoyer le code d'erreur ? Par une valeur illégale peut-être, mais ce n'est pas toujours possible... ainsi une fonction qui doit renvoyer un entier ne peut renvoyer une valeur illégale. D'autre part, une bonne partie du code utilisateur risque d'être consacrée au traitement des erreurs... à condition que le programmeur ait assez de courage ou de conscience professionnelle. On estime que dans certains cas le code peut doubler simplement à cause du traitement d'erreurs. De manière plus fondamentale, on aura une totale imbrication du code de traitement d'erreurs et du code de l'application, d'où une mauvaise lisibilité du code. Le C++ offre un système d'exceptions qui améliore considérablement la situation.


Pour les terriensUne analogie avec la vie courante

Afin de bien comprendre le système des exceptions, imaginons l'administration des postes d'un pays quelconque. L'organisation est la suivante:

  1. Service National des Postes
  2. Service Régional des Postes
  3. Bureau de Postes de quartier
  4. Facteur

Il faut comprendre cette analogie de la manière suivante:

  1. Le Service National des Postes correspond au programme principal
  2. Le Service Régional correspond à une fonction appelée par le programme principal.
  3. Le Bureau de postes de quartier correspond à une fonction appelée par la fonction précédente.
  4. Le facteur est une méthode appartenant à un objet utilisé par la fonction précédente.

Imaginons donc le facteur, en train de distribuer le courrier. La plupart du temps, tout se passe correctement et la mission du facteur est menée à bien. Mais quelques problèmes peuvent survenir; par exemple, une lettre adressée à M. Dupond est notée 105 rue des Mimosas, alors que les dupond habitent au 15 rue des Mimosas: le facteur connait le quartier, il mettra l'enveloppe dans la boîte aux lettres des Dupond, même si l'adresse est mauvaise. Il s'agit d'un cas d'erreur qui a pu être corrigé par le facteur - par l'objet. Rien ni personne ne sera au courant qu'il y a eu un problème avec cette enveloppe. Autre problème possible: une lettre est adressée à M. Durand, or il n'y a pas de M. Durand dans le quartier. Cette fois, le facteur mettra la lettre dans une boîte appelée "Adresses inconnues", et il continuera sa tournée.
Voilà que le facteur tombe sur une lettre qui comporte la bonne adresse, mais il s'agit d'une rue située dans un autre quartier: le facteur mettra la lettre dans une nouvelle boîte, appelée "autres quartiers".
A la fin de sa tournée, le facteur regarde l'état de ses deux boîtes: si elles sont vides, il rentre chez lui tout simplement. La méthode "facteur" a fait son travail sans histoire. Si au moins l'une des deux est pleine, le facteur, avant de rentrer chez lui, va déposer à un endroit réservé à cet usage, au bureau de postes, le ou les cartons contenant les lettres en cause: il "lance une exception"; celle-ci sera traitée soit au niveau du bureau de poste du quartier, soit au niveau supérieur; mais en aucun cas le facteur ne prend de décision à propos de cette lettre: ce n'est tout simplement pas son travail.
Le bureau de poste de quartier, voyant qu'il y a une "exception", va alors la traiter: si l'adresse située sur la lettre "autres secteurs" correspond à un secteur géré par ce bureau de poste, il suffira de la donner à un autre facteur pour que le problème soit résolu. L'erreur a été corrigée au niveau Bureau de Poste, et personne à un plus haut niveau n'en saura rien. Sinon, le bureau de poste la renvoie à l'échelon supérieur (régional) qui se chargera du problème, à moins qu'il ne le renvoie à nouveau à un échelon supérieur...

C'est un système analogue qui est employé par le C++ pour traiter les exceptions:

Il est possible de renvoyer ainsi n'importe quel objet, et de mettre donc dans cet objet n'importe quelle information: un code d'erreur, par exemple, avec une chaîne de caractères explicative, mais aussi des données (d'autres objets, par exemple) permettant aux niveaux supérieurs de traiter effectivement l'exception.


Le système d'exceptions

Lorsque nous avons défini un tableau, les différents constructeurs ou opérator= appelaient une fonction malloc, mais ne vérifiaient jamais si l'allocation se faisait effectivement. Voici un constructeur:

tableau::tableau(int t): taille(t) {
    buffer = malloc(t * sizeof(char));
};

Il n'y a aucun traitement d'erreur, si malloc ne fonctionne pas (par exemple parce qu'il n'y a pas assez de mémoire dans la machine), on ne le détectera pas et on sera confronté à un plantage aléatoire. Voici une première manière d'introduire un traitement d'erreur:

tableau::tableau(int t): taille(t) {
    buffer = malloc(taille * sizeof(char));
    if (buffer == nullptr) {
        throw ("malloc ne marche pas");
    }
    copie(b);
};

La fonction se contente de "lancer" un const char*. Celui-ci sera "rattrapé" par une fonction située dans la pile d'appels (c'est-à-dire la fonction appelante, ou la fonction ayant appelé la fonction appelante, etc.) par exemple la fonction main, dont voici une première implémentation:

int main() {
   ...
   try
   {
        cout << "Entrez une taille souhaitée: ";
        cin >> taille;
        tableau t1(taille);
   }
   catch ( const char * c )
   {
        cout << c << "\n";
   }
   return 0;
}

La fonction main a "attrapé" l'objet envoyé (ici un const char *) et l'a simplement affiché. La version suivante va plus loin: elle demander à l'utilisateur de rentrer une taille correcte.

int main() {
   ...
   do
   {
      try
      {
         size_t taille;
         cout << "Entrez une taille souhaitée: ";
         cin >> taille;
         tableau t1(taille);

         // faire un truc avec le tableau
         ...
         // sortir de la boucle
         break;
      }
      catch ( const char * msg )
      {
         cout << msg << " Recommencez avec une taille plus petite\n";
      }
   } while (true);
   return 0;
}

On voit ici que si le traitement de l'erreur (dans la fonction main) a changé, la génération de l'erreur, elle, est la même. Le code suivant montre une troisième manière de procéder: tout le traitement d'erreur se fait ici au niveau de la fonction creation_tableau, du coup main n'a plus à faire de traitement d'erreur:

    ...
tableau creation_tableau() {
    do
    {
        try
        {
            size_t taille;
            cout << "Entrez une taille souhaitée: ";
            cin >> taille;
    
            tableau t(taille);
            return t;
        }
        catch ( const char * msg )
        {
            cout << msg << " Recommencez avec une taille plus petite\n";
        }
    }
    while (true);
}

int main() {
    tableau t = creation_tableau();
    ...
}

Les hiérarchies d'objets exceptions

Plutôt que d'envoyer directement des chaines de caractère, il est beaucoup plus riche d'encapsuler ces messages dans des objets. On peut bien sûr définir ses propres exceptions, mais il est plus simple d'utiliser les exceptions déjà définies dans la bibliothèque standard du C++. Si vous préférez définir des objets exceptions, faites-les dériver de l'une de ces classes (ne serait-ce que la classe exception).

La figure ci-dessous montre les différentes exceptions définies dans la bibliothèque standard, anisi que les liens d'hritage qui les relient. La classe de base (exception) possède une méthode abstraite: what(), qui renvoie le message d'erreur encapsulé par l'objet. Lors du throw, on pourra donc générer un message d'erreur suffisamment précis pour que le diagnostic de l'erreur soit aisé.

La hiérarchie d'exceptions standards

NomDérive deConstructeurSignification
exception exception()Toutes les exceptions dérivent de cette classe
bad_allocexceptionbad_alloc()Problème d'allocation mémoire, peut être lancée par l'opérateur new
ios_base::failureexceptionfailure(const string&)Problème d'entrées-sorties, peut être lancée par les fonctions d'entrées-sorties
runtime_errorexceptionruntime_error(const string&)Erreurs difficiles à éviter, en particulier dans des programmes de calcul.
range_errorruntime_errorrange_error(const string&)Erreur dans les valeurs retournées lors d'un calcul interne
overflow_errorruntime_erroroverflow_error(const string&)Dépassement de capacité lors d'un calcul (nombre trop gros)
underflow_errorruntime_errorunderflow_error(const string&)Dépassement de capacité lors d'un calcul (nombre trop proche de zéro)
logic_errorexceptionlogic_error(const string&)Erreur dans la logique interne du programme (devraient être évitables)
domain_errorlogic_errordomain_error(const string&)Erreur de domaine (au sens mathématique du terme). Exemple: division par 0
invalid_argumentlogic_errorinvalid_argument(const string&)Mauvais argument passé à une fonction
length_errorlogic_errorlength_error(const string&)Vous avez voulu créer un objet trop gros pour le système (par exemple une chaîne plus longue que std::string::max_size()
out_of_rangelogic_errorout_of_range(const string&) Par exemple: "index inférieur à 0" pour un tableau

Il est très simple d'utiliser ces exceptions dans votre programme. L'opérateur précédent peut être réécrit de la manière suivante:

tableau::tableau(int t): taille(t) {
    buffer = malloc(taille * sizeof(char));
    if (buffer == nullptr) {
        bad_alloc e;
         throw (e);
    }
};

ou encore, de manière plus concise:

tableau::tableau(int t): taille(t) {
    buffer = malloc(taille * sizeof(char));
    if (buffer == nullptr) {
        throw bad_alloc();
    }
};

La fonction creation_tableau s'écrira comme indiqué ci-dessous. Si une exception de type bad_alloc est attrapée par la fonction, elle la traite. Si une autre exception dérivant du type générique exception est émise, elle ne sera pas attrapée par creation_tableau, mais elle sera traitée de manière générique par main.

tableau creation_tableau() {
    do
    {
        try
        {
            size_t taille;
            cout << "Entrez une taille souhaitée: ";
            cin >> taille;
    
            tableau t(taille);
            return t;
        }
        catch ( bad_alloc )
        {
            cout << msg << " Recommencez avec une taille plus petite\n";
        }
    }
    while (true);
}

int main() {
    try {
        tableau t = creation_tableau();
        ...
    }
    catch ( const exception& e )
    {
      cout << e.what() << "\n";
    }
}

ATTENTION En fait, plusieurs programmes de capture d'exceptions auraient pu être écrits, suivant la finesse avec laquelle on veut traiter les exceptions:

REGLE  D'OR Il est donc important de passer l'objet exception par const exception &, afin de s'assurer que le bon objet sera au final utilisé (notamment la bonne version de la fonction what()).

REGLE  D'OR Il est plus simple d'utiliser les exception prédéfinies, néanmoins il est possible de redéfinir ses propres exception. Dans ce cas, il est important de les définir de manière hiérarchique, et de préférence comme des classes dérivées de la classe exception. Cela permet en effet le traitement hiérarchisé des exceptions, ainsi qu'on vient de le voir.

Exceptions non capturées

Que se passe-t-il si une exception domain_error est générée dans la fonction create_tableau ? Elle ne sera pas traitée: à la place, elle sera transmise à la fonction appelante, et ainsi de suite jusqu'à main. Si main ne prévoit aucune capture d'exception, l'exception se terminera par un arrêt du programme. Il est cependant possible de prévoir simplement un traitement d'erreur pour les exceptions non prévues:

  try {
    blabla
  }
  catch (const  & domain_error e) {
    blabla
  }
  catch (const  & bad_alloc e) {
     blabla
  }  
  catch (...) {
    cout << "Autre exception\n";
  };
} 

Introduire le numéro de ligne dans l'exception générée

Pour ajouter le nom du fichier et le numéro de la ligne, on peut utiliser la macro __LINE__ et la __FILE__. Attention, __LINE__ renvoie un entier. En C il faut passer par deux macros (voir ici), en C++11 on a plus de chance: on peut passer par un string et par la fonction to_string:

#include <stdexcept>

...
throw(runtime_error(static_cast<string>("ERREUR - y a qqchose qui cloche - Fichier ") + __FILE__ + ":" + to_string(__LINE__));

Afficher la pile d'appels lors d'une exception

Contrairement à java, python ou perl, Il n'est pas évident d'afficher la pile d'appels lorsqu'une exception est générée: le C++ est un langage compilé, et les symboles sont en général absents de l'exécutable. La manière la plus évidente de procéder est d'utiliser le programme à travers un débogueur (gdb par exemple); il est possible d'afficher la pile d'appels sans passer par le débogueur. Cependant, cela nécessite de faire appel à des primitives système, qui dépendent du compilateur: ce code ne sera par définition pas portable.

Le programme suivant, que vous pouvez télécharger et utiliser dans vos propres applications, vous offre une solution à ce problème, utilisable exclusivement avec gcc sous unix. On a défini une nouvelle exception, qui dérive de runtime_error, et qui formatte la pile d'appels dans son constructeur, de sorte que la pile d'appels est automatiquement affichée lors de l'exécution de la méthode what(). Cet objet repose sur les fonctions suivantes, de la bibliothèque de gnu:

Le programme peut être téléchargé ici:


Exceptions et ...

...constructeurs

Le système des exceptions est le système de traitement d'erreurs à employer pour des constructeurs d'objet, à l'exclusion de tout autre: on pourrait par exemple imaginer une variable err qui indiquerait que l'objet est construit, certes, mais dans un état "bizarre", donc pas vraiment utilisable. C'est ce qu'on appelle les "objets zombies"... cela peut conduire à des comportements inattendus (variables internes non initialisées, par exemple). Si le constructeur est interrompu par une exception, l'objet ne sera pas construit du tout... Or, un vrai mort vaut mieux qu'un faux zombie, qui ira prétendre le contraire ?

...destructeurs

Le système des exceptions est le système de traitement d'erreurs à ne pas employer avec les destructeurs: en effet, un destructeur peut être appelé lors du déroulement normal du programme; mais il peut aussi être appelé lors de la génération d'une autre exception. Dans ce cas, le programme sera immédiatement arrêté.
Evidemment, rien n'empêche un destructeur d'appeler des fonctions qui, elles, sont suceptibles de générer une exception. Mais dans ce cas, ces appels de fonction doivent être encadrés par des blocs try...catch, et aucune exception ne doit s'échapper du destructeur. Cela signifie que les destructeurs, s'ils ont une erreur à faire remonter, devront trouver un autre système. Par exemple écrire sur une fenêtre ou dans un fichier de log.

ATTENTION Cette dissymétrie peut paraître surprenante à première vue... mais en fait, en informatique comme dans la vie, il est bien plus simple de détruire que de construire: on peut avoir du mal à construire une maison, rien ne devrait pouvoir vous empêcher de la détruire... De même, le constructeur peut rencontrer un grand nombre de problèmes (ressources impossibles à trouver, par exemple), mais normalement le destructeur ne devrait pas générer d'erreur... ou alors, c'est grave, car cela signifie que le système refuse de récupérer une ressource.

noexcept

Si une fonction ne peut pas générer d'exceptions, il est important de le signaler: cela se fait par l'utilisation du mot-clé noexcept qui se place dans la déclaration du prototype, à la fin de celui-ci:

    void ma_fonction(float x)  noexcept;

Pourquoi est-ce important ? Cela ne changera rien à la logique de votre code, mais cette mention informe le compilateur qu'il a le droit d'utiliser certaines optimisations, sans casser le déroulement du code. En particulier il est très important d'utiliser noexcept lorsque vous déclarez un opérateur de déplacement ou un constructeur de déplacement avant.


xhtml Licence Creative Commons Emmanuel Courcelle <emmanuel.courcelle@inp-toulouse.fr>