Chapitre 9. Les exceptions en C++

Table des matières
9.1. Lancement et récupération d'une exception
9.2. Remontée des exceptions
9.3. Liste des exceptions autorisées pour une fonction
9.4. Hiérarchie des exceptions
9.5. Exceptions dans les constructeurs

Une exception est l'interruption de l'exécution du programme à la suite d'un événement particulier. Le but des exceptions est de réaliser des traitements spécifiques aux événements qui en sont la cause. Ces traitements peuvent rétablir le programme dans son mode de fonctionnement normal, auquel cas son exécution reprend. Il se peut aussi que le programme se termine, si aucun traitement n'est approprié.

Le C++ supporte les exceptions logicielles, dont le but est de gérer les erreurs qui surviennent lors de l'exécution des programmes. Lorsqu'une telle erreur survient, le programme doit lancer une exception. L'exécution normale du programme s'arrête dès que l'exception est lancée, et le contrôle est passé à un gestionnaire d'exception. Lorsqu'un gestionnaire d'exception s'exécute, on dit qu'il a attrapé l'exception.

Les exceptions permettent une gestion simplifiée des erreurs, parce qu'elles en reportent le traitement. Le code peut alors être écrit sans se soucier des cas particuliers, ce qui le simplifie grandement. Les cas particuliers sont traités dans les gestionnaires d'exception.

En général, une fonction qui détecte une erreur d'exécution ne peut pas se terminer normalement. Comme son traitement n'a pas pu se dérouler normalement, il est probable que la fonction qui l'a appelée considère elle aussi qu'une erreur a eu lieu et termine son exécution. L'erreur remonte ainsi la liste des appelants de la fonction qui a généré l'erreur. Ce processus continue, de fonction en fonction, jusqu'à ce que l'erreur soit complètement gérée ou jusqu'à ce que le programme se termine (ce cas survient lorsque la fonction principale ne peut pas gérer l'erreur).

Traditionnellement, ce mécanisme est implémenté à l'aide de codes de retour des fonctions. Chaque fonction doit renvoyer une valeur spécifique à l'issue de son exécution, permettant d'indiquer si elle s'est correctement déroulée ou non. La valeur renvoyée est donc utilisée par l'appelant pour déterminer la nature de l'erreur, et, si erreur il y a, prendre les mesures nécessaires. Cette méthode permet à chaque fonction de libérer les ressources qu'elle a allouées lors de la remontée des erreurs, et d'effectuer ainsi sa part du traitement d'erreur.

Malheureusement, cette technique nécessite de tester les codes de retour de chaque fonction appelée, et la logique d'erreur développée finit par devenir très lourde, puisque ces tests s'imbriquent les uns à la suite des autres et que le code du traitement des erreurs se trouve mélangé avec le code du fonctionnement normal de l'algorithme. Cette complication peut devenir ingérable lorsque plusieurs valeurs de codes de retour peuvent être renvoyées afin de distinguer les différents cas d'erreur possible, car il peut en découler un grand nombre de tests et beaucoup de cas particuliers à gérer dans les fonctions appelantes.

Certains programmes utilisent donc une solution astucieuse, qui consiste à déporter le traitement des erreurs à effectuer en dehors de l'algorithme par des sauts vers la fin de la fonction. Le code de nettoyage, qui se trouve alors après l'algorithme, est exécuté complètement si tout se passe correctement. En revanche, si la moindre erreur est détectée en cours d'exécution, un saut est réalisé vers la partie du code de nettoyage correspondante au traitement qui a déjà été effectué. Ainsi, ce code n'est écrit qu'une seule fois, et le traitement des erreurs est situé en dehors du traitement normal.

La solution précédente est tout à fait valable (en fait, c'est même la solution la plus simple), mais elle souffre d'un inconvénient. Elle rend le programme moins structuré, car toutes les ressources utilisées par l'algorithme doivent être accessibles depuis le code de traitement des erreurs. Ces ressources doivent donc être placées dans une portée relativement globale, voire déclarées en tête de fonction. De plus, le traitement des codes d'erreurs multiples pose toujours les mêmes problèmes de complication des tests.

La solution qui met en oeuvre les exceptions est beaucoup plus simple, puisque la fonction qui détecte une erreur peut se contenter de lancer une exception. Cette exception interrompt l'exécution de la fonction, et un gestionnaire d'exception approprié est recherché. La recherche du gestionnaire suit le même chemin que celui utilisé lors de la remontée des erreurs : à savoir la liste des appelants. La première fonction appelante qui contient un gestionnaire d'exception approprié prend donc le contrôle, et effectue le traitement de l'erreur. Si le traitement est complet, le programme reprend son exécution normale. Dans le cas contraire, le gestionnaire d'exception peut relancer l'exception (auquel cas le gestionnaire d'exception suivant est recherché) ou terminer le programme.

Le mécanisme des exceptions du C++ garantit que tous les objets de classe de stockage automatique sont détruits lorsque l'exception qui remonte sort de leur portée. Ainsi, si toutes les ressources sont encapsulées dans des classes disposant d'un destructeur capable de les détruire ou de les ramener dans un état cohérent, la remontée des exceptions effectue automatiquement le ménage. De plus, les exceptions peuvent être typées, et caractériser ainsi la nature de l'erreur qui s'est produite. Ce mécanisme est donc strictement équivalent en termes de fonctionnalités aux codes d'erreurs utilisés précédemment.

Comme on le voit, les exceptions permettent de simplifier le code, en reportant en dehors de l'algorithme normal le traitement des erreurs. Par ailleurs, la logique d'erreur est complètement prise en charge par le langage, et le programmeur n'a plus à faire les tests qui permettent de déterminer le traitement approprié pour chaque type d'erreur. Les mécanismes de gestion des exceptions du C++ sont décrits dans les paragraphes suivants.

9.1. Lancement et récupération d'une exception

En C++, lorsqu'il faut lancer une exception, on doit créer un objet dont la classe caractérise cette exception, et utiliser le mot clé throw. Sa syntaxe est la suivante :

throw objet;
objet est l'objet correspondant à l'exception. Cet objet peut être de n'importe quel type, et pourra ainsi caractériser pleinement l'exception.

L'exception doit alors être traitée par le gestionnaire d'exception correspondant. On ne peut attraper que les exceptions qui sont apparues dans une zone de code limitée (cette zone est dite protégée contre les erreurs d'exécution), pas sur tout un programme. On doit donc placer le code susceptible de lancer une exception d'un bloc d'instructions particulier. Ce bloc est introduit avec le mot clé try :

try
{
    // Code susceptible de générer des exceptions...
}

Les gestionnaires d'exceptions doivent suivre le bloc try. Ils sont introduits avec le mot clé catch :

catch (classe [&][temp])
{
    // Traitement de l'exception associée à la classe
}

Notez que les objets de classe de stockage automatique définis dans le bloc try sont automatiquement détruits lorsqu'une exception fait sortir le contrôle du programme de leur portée. C'est également le cas de l'objet construit pour lancer l'exception. Le compilateur effectue donc une copie de cet objet pour le transférer au premier bloc catch capable de le recevoir. Cela implique qu'il y ait un constructeur de copie pour les classes d'exceptions non triviales.

De même, les blocs catch peuvent recevoir leurs paramètres par valeur ou par référence, comme le montre la syntaxe indiquée ci-dessus. En général, il est préférable d'utiliser une référence, afin d'éviter une nouvelle copie de l'objet de l'exception pour le bloc catch. Toutefois, on prendra garde au fait que dans ce cas, les modifications effectuées sur le paramètre seront effectuées dans la copie de travail du compilateur et seront donc également visibles dans les blocs catch des fonctions appelantes ou de portée supérieure, si l'exception est relancée après traitement.

Il peut y avoir plusieurs gestionnaires d'exceptions. Chacun traitera les exceptions qui ont été générées dans le bloc try et dont l'objet est de la classe indiquée par son paramètre. Il n'est pas nécessaire de donner un nom à l'objet (temp) dans l'expression catch. Cependant, cela permet de le récupérer, ce qui peut être nécessaire si l'on doit récupérer des informations sur la nature de l'erreur.

Enfin, il est possible de définir un gestionnaire d'exception universel, qui récupérera toutes les exceptions possibles, quels que soient leurs types. Ce gestionnaire d'exception doit prendre comme paramètre trois points de suspension entre parenthèses dans sa clause catch. Bien entendu, dans ce cas, il est impossible de spécifier une variable qui contient l'exception, puisque son type est indéfini.

Exemple 9-1. Utilisation des exceptions

#include <iostream>

using namespace std;

class erreur   // Première exception possible, associée
               // à l'objet erreur.
{
public:
    int cause;  // Entier spécifiant la cause de l'exception.
    // Le constructeur. Il appelle le constructeur de cause.
    erreur(int c) : cause(c) {}
    // Le constructeur de copie. Il est utilisé par le mécanisme
    // des exceptions :
    erreur(const erreur &source) : cause(source.cause) {}
};

class other {};   // Objet correspondant à toutes
                  // les autres exceptions.

int main(void)
{
    int i;            // Type de l'exception à générer.
    cout << "Tapez 0 pour générer une exception Erreur, "
        "1 pour une Entière :";
    cin >> i;         // On va générer une des trois exceptions
                      // possibles.
    cout << endl;
    try               // Bloc où les exceptions sont prises en charge.
    {
        switch (i)    // Selon le type d'exception désirée,
        {
        case 0:
            {
                erreur a(0);
                throw (a);   // on lance l'objet correspondant
                             // (ici, de classe erreur).
                             // Cela interrompt le code. break est
                             // donc inutile ici.
            }
        case 1:
            {
                int a=1;
                throw (a);   // Exception de type entier.
            }
        default:             // Si l'utilisateur n'a pas tapé 0 ou 1,
            {
                other c;     // on crée l'objet c (type d'exception
                throw (c);   // other) et on le lance.
            }
        }
    }                 // fin du bloc try. Les blocs catch suivent :
    catch (erreur &tmp) // Traitement de l'exception erreur ...
    {                 // (avec récupération de la cause).
        cout << "Erreur erreur ! (cause " << tmp.cause << ")" << endl;
    }
    catch (int tmp)   // Traitement de l'exception int...
    {
        cout << "Erreur int ! (cause " << tmp << ")" << endl;
    }
    catch (...)       // Traitement de toutes les autres
    {                 // exceptions (...).
                      // On ne peut pas récupérer l'objet ici.
        cout << "Exception inattendue !" << endl;
    }
    return 0;
}

Selon ce qu'entre l'utilisateur, une exception du type erreur, int ou other est générée.