Système de gestion d'exceptions

Dans le contexte des langages de programmation fonctionnels et impératifs, un système de gestion d'exceptions ou SGE permet de gérer les conditions exceptionnelles pendant l'exécution du programme. Lorsqu'une exception se produit, l'exécution normale du programme est interrompue et l'exception est traitée.

Les erreurs/exceptions les plus courantes sont probablement l'accès non autorisé à une zone mémoire (erreur de manipulation de pointeur) et la division par zéro (on ne prévoit pas le cas où le diviseur est nul).

Intérêt

Tout programme en exécution peut être sujet à des erreurs pour lesquelles des stratégies de détection et de réparation sont possibles. Ces erreurs ne sont pas des bugs mais des conditions particulières (ou conditions exceptionnelles, ou exceptions) dans le déroulement normal d'une partie d'un programme.

Par exemple, l'absence d'un fichier utile n'est pas un bogue du programme ; par contre, ne pas gérer son absence en provoquerait un.

Le traitement des situations exceptionnelles fait apparaître deux besoins :

  • une syntaxe spéciale, pour distinguer l'exécution normale du traitement des erreurs,
  • un flot de contrôle « non local », pour traiter et réparer les situations exceptionnelles.

Dans les langages de programmation sans SGE, on n'a pas d'outil pour séparer l'exécution normale et l'exécution exceptionnelle du programme. Un algorithme, dont l'exécution normale s'exprime de façon simple et élégante, peut devenir illisible (et donc difficile à maintenir) une fois « enrobé » par une logique de traitement des situations exceptionnelles ; disposer dans un langage de programmation d'une syntaxe pour différencier l'exécution normale de l'exécution dans un contexte exceptionnel peut être utile.

Le traitement d'une situation exceptionnelle peut nécessiter de revenir « dans le passé » de l'exécution du programme, c'est-à-dire remonter brutalement la chaîne d'appels pour annuler une opération fautive, ou encore modifier les valeurs de certaines variables, puis reprendre l'exécution du programme un peu avant le site de l'erreur. D'où le besoin d'associer, à la syntaxe spéciale, des opérateurs spéciaux pour effectuer des sauts et des modifications de variables à des points arbitraires de la chaîne d'appels.

Principes généraux

Types d'erreur

Les exceptions suivantes peuvent survenir dans pratiquement tous les programmes :

  • arithmétique (débordement, division par zéro…) ;
  • collections (débordement d'indices) ;
  • allocation mémoire (mémoire insuffisante) ;
  • signaux système (interruption du programme par le système d'exploitation).

Dans certains langages à objets, le type d'une exception doit impérativement être une classe. Une hiérarchie prédéfinie et extensible de types d'exceptions, correspondant au type des erreurs qu'elles représentent, est fournie. D'autres langages, comme C++, autorisent aussi les types primitifs.

Gestionnaire d'exceptions

Un gestionnaire d'exception établit un ensemble de routines de traitement d'erreurs, définies par le programmeur, sur un bloc (dans une fonction ou une méthode du programme) ; ces routines sont activées pendant toute la durée d'exécution du bloc protégé.

La notion d'exécution du bloc protégé inclut toute la chaîne d'appels (de fonctions, procédures ou méthodes) à partir de ce bloc : on dit que les gestionnaires d'exception sont actifs dans la portée dynamique du bloc.

Le signalement d'une exception peut être automatique, s'il correspond à une exception définie dans le langage de programmation ou une bibliothèque fournie, ou bien déclenché par le programmeur par l'utilisation d'une primitive de signalement. Il est généralement possible de construire de nouveaux types d'exceptions et de programmer leur signalement.

Le gestionnaire d'exception peut être complété par un ensemble de restarts, qui sont des routines permettant la modification des environnements lexicaux entre le site de signalement et le point d'établissement des gestionnaires d'exception. Un restart permet à un gestionnaire d'exception de choisir de réparer et redémarrer un calcul plutôt que de l'abandonner intégralement. Un restart est également actif dans la portée dynamique du bloc sur lequel il est défini.

Signalement d'une erreur

Lorsqu'une condition d'erreur est détectée (par une primitive de signalement, une trappe processeur, le système d'exploitation ou encore l'environnement d'exécution du programme), on dit qu'elle est signalée : un bloc de traitement d'erreur (un handler) est recherché dans la liste des gestionnaires actifs. L'exécution du programme est déférée au bloc de traitement, qui effectue des actions correctrices et décide si le calcul où l'erreur a été signalée est terminé ou bien repris (si c'est possible, c'est-à-dire en présence de restarts).

Il se peut qu'aucun gestionnaire n'ait été prévu par le programmeur, auquel cas un gestionnaire par défaut, dont le comportement est pré-défini, est sélectionné.

Réparation d'une erreur, reprise

Dans un bloc de traitement, le programmeur a deux options :

  • arrêt brutal du calcul fautif et choix d'un autre calcul : la pile d'appel du bloc protégé est alors détruite ;
  • réparations en différents points de la chaîne d'appel ayant conduit à l'erreur, et reprise du calcul à un lieu préalablement défini.

Opérateurs

  • Signalement d'une condition d'erreur (ne détruit pas la pile) : signal, error, cerror…
  • L'installation d'un bloc de traitement d'erreur est réalisé avec des primitives du type : try/catch/finally, handler-bind…
  • L'installation d'un restart (un bloc de réparation de contexte lexical) est permise par : restart-case, restart-bind…
  • La destruction de la pile d'appel entre l'expression signalant une exception et le bloc de traitement, ou un point de reprise est effectuée par : throw, raise

En Python, par exemple, le signalement implique la destruction de la pile d'appels jusqu'au premier bloc de traitement disponible. Dans ce cas, throw (ou raise) est la seule primitive de signalement, et la réparation et la reprise sont impossibles.

Dans les langages de programmation

BASIC

Le langage Basic comporte un système de traitement d'erreurs de type 'piège à erreurs' mais qui n'en est pas moins une gestion d'exceptions

function toto(x as double) as double
     DIM f as double
     f = 1.0         REM on affecte la valeur 1, jusqu'ici tout va bien
     ON LOCAL ERROR GOTO MonException  REM [try] on débute un bloc protégé
     f = 1.0 / 0.0    REM  provoque une division par zéro, ou alors f vaut +infini
     f = sin(f) REM  sinus de +infini est indéfini sinon
     toto = f  REM Retourne le résultat
     EXIT FUNCTION REM Indispensable, sinon on entre dans la zone de traitement d'exception
     REM [except] =============== 
     MonException:    REM on gère les exceptions
    
     REM Autre cas: Correction puis poursuite
     f = 1.0
     RESUME NEXT REM Passe à la ligne suivante (f = sin(f)
     toto = f
end function;

Delphi / Lazarus

Exemple en Delphi ou en Lazarus :

procedure TForm1.Button1Click(Sender: TObject);
var f: Real;        // soit f un nombre réel 
begin
     f := 1;        // on affecte la valeur 1, jusqu'ici tout va bien

     try            // on débute un bloc protégé
       f := 1/0;    // provoque une division par zéro, ou alors f vaut +infini
       f := sin(f); // sinus de +infini est indéfini sinon

     except         // on gère les exceptions
       on e: Exception do  // on appelle e l'exception qui vient d'arriver
         Application.MessageBox( PChar('Message : '+e.Message), 'Exception', 0);
     end;           // fin du bloc protégé

     Application.MessageBox( PChar('Valeur de f : '+FloatToStr(f)), 'Resultat', 0);
end;

Java

Java offre un SGE terminal, donc sans réparation ni reprise.

Exemple de traitement d'une division par zéro :

public class FooBar {
  FooBar () { }

  int foo (String b) {
    int resultat;
    try {
      resultat = bar (b);
    }
    catch (Exception e) {
      System.out.println ("erreur pendant l'exécution de bar : " + e.toString ());
      resultat = 666;
    }
    return resultat;
  }

  int bar (String s) {
    System.out.println ("tiens, un " + s);
    System.out.println ("faisons quelque chose de mal...");
    int a = 42 / 0;    // <- quelque chose de mal
    a = a + 7;
    return a;
  }
}

Particularités

Dans CLU [Lyskov-Snyder 79] et Java, une distinction est faite entre :

  • les exceptions déclarées dans la signature d'une méthode (CheckedException en Java) ; par exemple
 void foo () throws ThisExceptionType { ... },
  • les exceptions à l'exécution (RuntimeException en Java), qui correspondent à des évènements impossibles à localiser lexicalement à la compilation (les exceptions asynchrones), ou pouvant survenir à tout moment dans l'exécution du programme, comme les problèmes d'allocation mémoire.

Les checked exceptions essayent de résoudre un problème de contrat. L'interface d'un module (d'une bibliothèque de classes) représente un contrat entre l'auteur du module et son utilisateur : l'argument est qu'un tel contrat ne devrait pas passer sous silence les exceptions susceptibles d'être propagées hors des frontières du module.

En spécifiant les exceptions dans les signatures des méthodes, on introduit toutefois un problème. En effet, les méthodes clientes doivent choisir dans l'alternative :

  • installer un GE pour les exceptions du module ;
  • ou bien déclarer à leur tour ces exceptions.

Les méthodes utilisant des checked exceptions contaminent leurs clients avec l'obligation de décorer leur signature, s'ils n'installent pas de GE pour ces exceptions. Cette contamination trahit en partie le contrat d'indépendance entre le lieu du signalement d'une exception et le lieu de son traitement, en exposant toutes ces déclarations d'interfaces dans le chemin d'appel ; en somme elles nous ramènent aux inconvénients des langages de programmation sans SGE (transmission de l'exception par une valeur de retour spéciale, prévue en tout point de la chaîne d'appels). Les checked exceptions violent finalement la philosophie des exceptions (la non-localité entre le lieu de la condition et le lieu de son traitement).

Du coup, la bibliothèque standard de Java utilise en pratique des runtime exceptions pour les opérateurs les plus courants (arithmétique, collections, allocation mémoire) afin éviter la pollution lexicale des checked exception.

PHP

Traitement d'une division par zéro en PHP. On peut alors faire appel à la classe « Exception » directement proposée par ce langage.

// Note : PHP_EOL représente un simple retour à la ligne
function diviser($x, $y) {
  if ($y == 0)
    throw new Exception('Division par zéro');
  else
    return ($x / $y);
}

try {
  echo diviser(1, 2) . PHP_EOL; // 1/2
  echo diviser(3, 0) . PHP_EOL; // 3/0 : instruction qui déclenchera l'exception
  echo diviser(2, 1) . PHP_EOL; // 2/1 : cette instruction ne sera pas exécutée, la précédente ayant déclenché une exception
}
catch (Exception $e) {
  echo $e->getMessage() . PHP_EOL; // afficher le message lié à l'exception
  // À cet emplacement, une éventuelle instruction supplémentaire qui sera exécutée après le déclenchement de l'exception
}
echo "Malgré la division par zéro, l'exécution du script sera poursuivie et cette instruction echo sera prise en compte.";

Si la variable y est égale à 0, l'exécution sera poursuivie à l'extérieur de l'instruction try délimitée par les accolades.

Le résultat sera donc :

0.5
Division par zéro
Malgré la division par zéro, l'exécution du script sera poursuivie et cette instruction echo sera prise en compte.

Python

Il est possible pour l'utilisateur de définir ses propres exceptions et de dire au programme quand les lever grâce au mot-clé raise.

Exemple :

class TestError(Exception): #Cette ligne permet d'hériter de la classe de base Exception qui est une erreur basique.
    def __init__(self, message):#Le paramètre message se trouve dans toutes les classes d'exception.
        self.message=message
        
#Ici on va tester l'erreur sur une division par 0
def diviser(dividende, diviseur):
    try:
        if diviseur != 0:
            return dividende/diviseur
    except Exception:
        raise TestError("division par zéro")
        
#Ensuite on teste avec des variables
x=float(input("Entrez le dividende : "))
y=float(input("Entrez le diviseur : "))
print(diviser(x, y))    #A cette ligne si y vaut 0 alors la division renverra notre exception TestError.

Smalltalk

En pratique, les exceptions signalées peuvent n'être que relativement bénignes, ou transitoires ; dans ce cas, un idiome combinant le SGE, des variables, des tests, des boucles, doit être mis en œuvre pour recommencer un calcul qui aurait échoué pour des raisons bénignes.

En Smalltalk, ces difficultés sont mitigées par les possibilités suivantes :

  • réessayer un bloc protégé, avec de nouveaux arguments,
  • reprendre l'exécution du calcul signalant, éventuellement en fournissant une « valeur de retour ».

Ré-essayer

Les mots clefs retry et retryUsing permettent, respectivement d'exécuter à nouveau le bloc protégé par le handler sans utiliser de bouclage explicite, ou d'exécuter un nouveau bloc à la place du bloc qui a signalé l'exception. Voici un exemple :

    | fileContents |
    fileContents := ['myfile.txt' asFilename readStream contents]
        on: Error
        do: [:ex |
            | newName |
            newName := Dialog prompt: 'Problem reading file. Another name?'.
            ex retryUsing: [newName asFilename readStream contents]]

Reprendre

Certaines exceptions sont dites « continuables ». Cela signifie qu'un gestionnaire peut envoyer un message « resume » (qui transmet son argument au retour de l'expression signalante) à l'exception, ce qui provoque le transfert du contrôle sur le retour de l'expression signalante.

Voyons un exemple, sur un bout de programme effectuant la lecture des « options » d'un fichier de configuration (couples variable = valeur). Le premier fragment analyse la prochaine option située dans un stream représentant le fichier :

   MyApplication>>readOptionsFrom: aStream
      | option |
      [aStream atEnd] whileFalse:
         [option := self parseOptionString. "nil if invalid"
         option isNil
            ifTrue: [InvalidOption signal]
            ifFalse: [self addOption: option]]

Le second fragment utilise le premier pour lire la configuration complète ; le gestionnaire de l'exception « InvalidOption » y est défini.

  MyApplication>>readConfiguration
      [self readOptionsFrom: 'options' asFilename readStream]
         on: InvalidOption
         do: [:ex |
            (Dialog confirm: 'Invalid option line. Continue loading?')
               ifTrue: [ex resume]
               ifFalse: [ex return]]

Conséquence sur le signalement

Puisqu'on a introduit la possibilité de reprendre un calcul sur l'instruction suivant le signalement, on doit se garder de détruire la pile d'appels au moment du signalement : cette destruction doit prendre place au moment où le programme sort du dernier gestionnaire impliqué dans le signalement.

Le système de conditions de Common Lisp

Les SGE des langages précédents ne considèrent pas la possibilité de réparer le contexte signalant l'exception et de redémarrer le calcul dans le contexte ainsi réparé. Smalltalk permet de fournir une valeur de retour de substitution pour l'expression signalant une exception, mais le gestionnaire n'a pas accès à l'environnement lexical fautif.

Une condition est une généralisation d'une erreur [Pitman 01] : toutes les conditions ne sont pas indésirables.

À la hiérarchie des types d'exceptions des SGE à terminaison correspond une hiérarchie de types de conditions, incluant une branche pour les conditions non-fatales. Cette hiérarchie est décrite avec le Common Lisp Object System, c'est donc également une hiérarchie de classes d'exceptions.

Dans le SCCL, un bloc de traitement d'un gestionnaire de condition est une fonction fermée sur l'environnement lexical du gestionnaire d'exception, et qui s'exécute dans l'environnement dynamique de la routine où la condition est signalée ; le contexte dynamique de la routine signalante n'est pas détruit.

Cela signifie que le signalement n'implique pas de transférer le flot de contrôle de façon non locale : on ne détruit pas la pile d'appels au moment du signalement. Le signalement débute par un appel de fonction au GE adéquat ; il peut être écrit comme suit :

(defun signal (condition)
  (funcall (find-first-active-handler-of-type (type-of condition))
           condition))


Un restart est une fonction contenant les instructions nécessaires à la réparation d'une situation exceptionnelle, et fermée sur un environnement lexical proche du lieu du signalement. Il est donc situé, dans la chaîne d'appels, entre le GE adéquat et la condition signalée. Un restart est typiquement invoqué par le gestionnaire d'une condition pour modifier l'environnement lexical ou dynamique de la procédure signalant la condition (réparer la situation) et effectuer un saut non-local vers un point de cette procédure (reprise).

Opérateurs

Nous mentionnons les opérateurs les plus significatifs du système de conditions.

Opérateurs
Établir un GEHANDLER-BIND
Établir des restartsRESTART-BIND
Trouver des restartsFIND-RESTART
Invoquer un restartINVOKE-RESTART
Signaler une conditionSIGNAL, ERROR, WARN …
Saut non-local vers une marqueTHROW
Marquer le cadre courantCATCH

(catch symbol) et (throw symbol) sont à la disposition du programmeur Lisp pour, respectivement, marquer avec un symbole le cadre courant, et détruire la pile d'appels en la remontant jusqu'à la première marque correspondant au symbole passé en argument. Ils sont utilisés implicitement par le système de condition.

Si handler-bind implique un catch, les primitives de signalement ne débutent jamais par un throw. Throw n'est invoqué que si le gestionnaire effectue un saut non-local vers son contexte lexical (qui est différent de son contexte dynamique au moment où il est appelé), ce qui signifie qu'on veut effectivement détruire la pile d'appels.

Utilisation terminale

Le SCCL peut être utilisé juste comme les SGE à terminaison : on établit un gestionnaire, on signale, on se demande quoi faire.

1 . (defun foo ()
2 .    (tagbody
3 .       (print « foo »)
4 .       (handler-bind ((some-condition
5 .                         (lambda (condition) 
6 .                            (print (type-of condition))
7 .                            (go after-the-fact))))
8 .           (bar))
9 .      after-the-fact
10.       (print "après bar, dans foo"))
11.
12. (defun bar ()
13.    (print « bar »)
14.    (error "C'est bien une erreur" 'some-condition)
15.    (print "ce code est inatteignable")) 

Examinons cet exemple, du point de vue du flot de contrôle. La trace d'un appel à foo est :

3 . foo
13. bar
6 . SOME-CONDITION
10. après bar, dans foo

La décision de détruire la pile d'appels est prise par le « (go after-the-fact) » du gestionnaire pour some-condition. Le système Lisp doit faire un throw juste avant d'exécuter le go.

Utilisation d'un restart

Le schéma suivant exprime les étapes mises en œuvre lorsqu'on utilise un restart.

Signalement : abandon ou réparation et reprise

Reprenons ces étapes (cas de la reprise) :

1. établissement du gestionnaire G (pour le type de condition C) dans l'environnement dynamique du programme (cela signifie que G est accessible en tout cadre de la chaîne d'appels sous son cadre d'établissement) ; G peut capturer des variables lexicales du cadre où il est déclaré (c'est une fermeture lexicale),

2. appel de la forme « protégée » par G,

3. appel à une nouvelle procédure, d'où sera signalée une condition de type C,

4. établissement d'un restart R protégeant une expression de cette procédure, dans l'environnement lexical de cette dernière,

5. l'expression protégée de la procédure signale une condition de type C : le gestionnaire G est trouvé dans l'environnement dynamique (c'est le gestionnaire actif le plus récent pour les conditions de type C),

6. G décide de reprendre le calcul, il invoque le restart R,

7. R effectue, dans le contexte lexical de la procédure signalante une réparation (si besoin) et transfère le contrôle à la procédure (saut non-local), qui reprend son travail (selon toute raison, en #4).

Bien sûr, si le gestionnaire décide d'abandonner le calcul (#6 bis), un saut non-local est effectué vers le cadre où G a établi ses liaisons lexicales (et en particulier l'étiquette utilisée pour le saut) ; la pile d'appels est détruite, R est détruit, et juste après le saut, G lui-même est détruit.

Exemple avec reprise

Nous développons l'idée d'utiliser le système de conditions pour exploiter les résultats d'un solveur de contraintes. Un solveur de contraintes, lorsqu'il trouve une solution, la signale à la routine qui a demandé le calcul. Si la routine est satisfaite de la solution, elle peut arrêter le solveur ; elle peut également reprendre le calcul pour obtenir la solution suivante.

On commence par définir un nouveau type de condition, correspondant à une solution trouvée par le solveur, avec un slot pour la solution :

(define-condition backtrack-solution (condition)
  ((sol :initarg solution :reader solution)))  

On établit un gestionnaire d'exceptions dans la routine qui a besoin des résultats du solveur ; ici, on choisit d'analyser le résultat du solveur au fur et à mesure de sa progression :

1 . (defun foobar (...)
2 .   (let ((solutions-we-dont-like))
3 .     (handler-bind 
4 .        ((backtrack-solution    ; type de la condition
5 .           (lambda (condition) 
6 .              ;; on décide quoi faire
7 .              (let ((sol (solution condition))
8 .                (when (good-enough sol)
9 .                  (return-from foobar sol))
10.                (push sol solutions-we-dont-like)
11.                (invoke-restart 
12.                  (first (find-restarts condition)))))))
13.        ;; l'appel initial au solveur
14.        (backtrack-with args in the env))
15.     (choose-best-amongst solutions-we-dont-like)))

On observe que le gestionnaire peut décider, en fonction de la « qualité » de la solution, de la retourner (ligne #9), ce qui implique l'abandon du calcul en cours et la destruction des cadres associés à ce calcul ; ou bien de l'empiler (ligne #10) et de reprendre la recherche (lignes #11-#12).

Dans le code du solveur, on doit signaler la condition bactrack-solution lorsqu'une solution est trouvée :

(defun backtrack-with (vars future inst foo)
  ;; s'il n'y a plus de variable à instancier, on signale une solution
  (if (not future)       
      (cerror "Autres solutions"
              'backtrack-solution 
              :solution (instances vars)) 
       ... ))

La primitive cerror condense en une seule opération l'établissement d'un restart et le signalement d'une condition. On aurait pu écrire, plus verbeusement, mais avec le même effet :

(tagbody
    (restart-bind ((more-solutions 
                     (lambda () 
                        (print « restarting »)
                        (go local-restart-point))))
      (error "Autres solutions"
             'backtrack-solution
             :solution (instances vars)))
 local-restart-point)

Historique

Années 70 (langage PL/I)

Le premier langage à avoir systématisé la gestion des exceptions a été PL/I (circa 1970).

Il permet d'activer dans la portée d'un bloc une ou plusieurs des conditions suivantes, et de leur associer un traitement, l'association restant valable jusqu'à

  • la fin du bloc ;
  • ou son remplacement par un autre traitement ;
  • ou le masquage temporaire par un autre traitement de la même exception dans un bloc interne.

Les conditions reconnues sont : AREA, CHECK (changement de valeur d'une variable), CONDITION (condition définie par le programmeur), CONVERSION, ENDFILE, ENDPAGE, KEY, UNDEFINEDFILE, FIXEDOVERFLOW, OVERFLOW, UNDERFLOW1, ZERODIVIDE, STORAGE, STRINGRANGE, SUBSCRIPTRANGE, ERROR, FINISH, ANYCONDITION.

Exemple :

on check (valeur, somme, verif) call anomalie;

exécute le programme anomalie chaque fois que l'une des trois valeurs valeur, somme ou verif voit sa valeur changer (on peut ainsi implémenter l'équivalent de ce que sont aujourd'hui les triggers dans les bases de données relationnelles.

ERROR est activé pour chaque condition non traitée explicitement, en plus du traitement implicite (en général un message d'erreur, ou rien du tout si elle n'est pas activée par défaut pour des raisons de performance, comme STRINGRANGE ou SUBSCRIPTRANGE).

FINISH est invoquée systématiquement à la fin de tout programme, quelle que soit sa cause. En calcul scientifique, on peut en profiter par exemple pour effectuer une sauvegarde de toutes les données afin de reprendre le calcul plus tard à partir du point d'arrêt.

On acquitte une exception par REVERT et on la transmet au niveau supérieur (puisqu'il y a presque toujours une chaîne d'interruptions en cascade) au moyen de RESIGNAL.

Années 80 et 90

L'utilisation des gestionnaires d'exceptions s'est généralisée sur PC avec l'utilisation du mode protégé sous DOS puis avec les systèmes d'exploitation multitâches. Auparavant, une erreur de programmation pouvait facilement aboutir à un plantage du programme voire de l'ordinateur.

Articles connexes

Références

Le principe de la gestion d'exceptions a été étudié sérieusement par John Goodenough dans un article de 1975. (John B. Goodenough, Exception Handling: Issues and a Proposed Notation. Commun. ACM 18(12): 683-696 (1975))

  • Portail de la programmation informatique
Cet article est issu de Wikipedia. Le texte est sous licence Creative Commons - Attribution - Partage dans les Mêmes. Des conditions supplémentaires peuvent s'appliquer aux fichiers multimédias.