samedi 7 mars 2009

Erreurs

Programmer ne consiste pas seulement a faire réaliser un tâche particulière à un ordinateur: cela consiste également à prévoir et traiter les circonstances particulières qui peuvent se produire lors de cette tâche, autrement dit gérer les cas d'erreur.
La gestion d'erreur est vécue comme un corvée par la plupart des programmeurs. Une première raison est que cela peut représenter beaucoup travail sans qu'on ait l'impression que la valeur ajoutée au programme augmente: une bonne part de ce travail consiste en effet à gérer des cas dont on a l'impression qu'ils ne se produiront jamais. A tel point que cela pose parfois de sérieuses difficultés: comprendre pourquoi le système renvoie une erreur obscure et décider de la réponse à y donner peut nécessiter beaucoup de recherche et de réflexion. De plus, la gestion d'erreur "défigure" en quelque sorte un programme: si on compare un programme avec une gestion d'erreur minimale et ce même programme avec une gestion plus rigoureuse, on constate qu'il a facilement doublé de volume, et que le programme original est "noyé" dans le code dédié à la gestion des erreurs; la lisibilité s'en trouve grandement réduite.

Toutefois, les programmeurs savent aussi que c'est un mal nécessaire. Un programme qui ne gère pas correctement les erreurs "se plante", avec en prime des pertes de données pour l'utilisateur.
L'impression que tout ce code supplémentaire ne sert pas à grand chose est fausse, parce qu'on pense souvent qu'une erreur qui a peu de chances de se produire ne se produira jamais. Un joueur de Loto n'a qu'une chance très faible de gagner le gros lot, et pourtant il y a régulièrement des gagnants, car il y a beaucoup de joueurs. De même, on peut penser a priori que les chances sont quasi-nulles pour qu'un certain scénario d'erreur se produise; mais si le programme est utilisé quotidiennement par des milliers d'utilisateurs, vous aurez des centaines de gens qui viendront se plaindre sur votre forum.
La gestion d'erreur fait partie de l'intelligence du logiciel, car il s'agit bien de doter le programme d'un comportement sensé quand il rencontre des circonstances inhabituelles; c'est aussi en partie ce qui rend la tâche difficile au programmeur. Un programme qui gère mal les erreurs a l'air de se comporter de manière stupide, et laisse bien souvent l'utilisateur seul avec son problème. Un programme qui gère bien les erreurs donnera, s'il ne peut résoudre le problème lui-même, un diagnostic précis et des éléments de résolution.
Les programmes qui doivent se connecter à un serveur affichent typiquement un message laconique "Impossible de se connecter au serveur" et proposent à l'utilisateur d'essayer à nouveau de se connecter. C'est un cas de gestion médiocre d'erreur. Les programmes avec une bonne gestion d'erreur vérifient si la machine visée peut être contactée, si le client est bien connecté sur le réseau, et si les paramètres réseau sont corrects. Ses diagnostics permettent à l'utilisateur de résoudre le problème plus rapidement; on lui épargne au moins la tâche de faire lui-même ces diagnostics manuellement, et qui peuvent du reste être faux et aiguiller sur une fausse piste (confusion entre adresse IP du client et du serveur, par exemple).

La gestion d'erreur est donc pénible et difficile, mais elle est d'une importance majeure. Une gestion d'erreur éronnée est un comble que les programmeurs ne savent pas toujours éviter. Elle mérite donc qu'on y réfléchisse, notamment afin de définir une stratégie saine. Avec en perspective des possibilités de soutien de cette stratégie par le langage de programmation.

Deux techniques bien connues existent:

  • La technique des codes d'erreur,
  • La technique des exceptions.
La technique des codes d'erreur est la plus basique et la plus ancienne. Quand une erreur se produit, la fonction retourne une valeur particulière et le programme appelant doit effectuer des actions supplémentaires pour connaître la cause précise de l'erreur.
Ce mode de gestion a plusieurs inconvénients: elle nécessite de dédier une des valeurs que peut retourner la fonction pour le signalement de l'erreur, et cette valeur peut varier d'une fonction à une autre suivant les contraintes (parfois c'est 0, d'autre fois c'est -1, parfois c'est une constante symbolique comme EOF, etc.), ce qui est source d'erreur. Un autre inconvénient est que le code d'erreur étant en quelque sorte encodé dans la valeur de retour, la distinction entre la logique de traitement d'erreur et la logique de l'application s'en trouve brouillée. Les langages qui permettent aux fonctions de retourner plusieurs valeurs améliorent un peu les choses, mais cela reste globalement insatisfaisant. Enfin, un reproche qui est souvent fait à cette méthode est qu'il faut faire "redescendre" les codes d'erreur de l'appelé à l'appelant, puis à son appelant, etc.

La technique des exceptions évite ces inconvénients. Avec les exceptions, une fonction qui échoue peut lancer une sorte de signal d'erreur qui peut être intercepté et traité par l'appelant, mais peut aussi être ignoré, auquel cas le signal sera traité par la dernière fonction parente qui aura fait en sorte d'intercepter ce signal. Ce qui permet à une fonction de ne traiter que les erreurs qui "l'intéresse". Cela donne parfois un code plus léger par rapport à la première méthode.
Cependant, il n'y a pas de magie, le code de gestion d'erreur que l'on trouvait dans la première méthode doit nécessairement se retrouver quelque part avec cette méthode. On le retrouve généralement au niveau de la fonction parente qui intercepte les exceptions éventuellement émises par les fonctions qu'elle appelle elle-même; ce code est "délocalisé" en quelque sorte, pour le regrouper avec d'autres gestions d'erreur. Cette délocalisation est un point qui peut être reproché à la technique des exceptions: la gestion des erreurs est déléguée d'une fonction à une autre, ce qui peut constituer une entorse au principe d'encapsulation: une fonction d'un niveau N peut se retrouver à gérer des exceptions du niveau N-2 si le niveau N-1 "oublie" de les intercepter. Cela constitue à mon sens à diluer la responsabilité de la gestion d'erreur. Ce qui se confirme dans les faits, car les concepteurs de Java ont pris des décisions qui ont provoqué une petite polémique, dans le but d'éradiquer ou du moins de limiter les cas de programmes "terminés" sans autre forme de procès par une exception qui est "remontée" jusqu'à la fonction principale parce qu'aucune fonction de la chaine d'appel n'a daigné l'intercepter et la traiter. J'exagère un peu ici: les programmes ne se plantaient pas vraiment: ils consignaient ladite exception dans leur journal d'évènements, et informaient l'utilisateur qu'ils étaient au regret de mettre fin à leur exécution...

La décision polémique que j'ai évoqué paraît pourtant être une bonne idée : que les exceptions émises par une fonction soit déclarées, ce qui permet au compilateur d'offrir un service supplémentaire de vérification. Il est parfaitement justifié d'imposer que les caractéristiques de comportement en erreur fassent partie de l'interface de la fonction au même titre que le type et le nombre de ces arguments. On peut même se laisser aller à dire que c'est le symétrique "en sortie" (avec la valeur éventuellement retournée) des paramètres "en entrée".

Cela impose que pour chaque appel de fonction, il faut intercepter les exceptions. Les concepteurs de Java ont rendu cela obligatoire en générant une erreur de compilation si cela n'est pas fait. Le problème est qu'au final, on en revient de facto quasiment à la même forme de code qu'avec la première technique, pour laquelle un appel de fonction est souvent suivi d'un switch... case sur le code d'erreur retourné. Autrement dit, les concepteurs de Java ont pour ainsi dire adopté la position des détracteurs des exceptions (nota - lien vers un wiki, le contenu peut se trouver modifié), et ont décidé de renoncer à l'un de ces avantages majeurs qui était exactement contraire à un principe (pourtant) de base: le traitement local et au plus proche de la source de l'erreur.
Valider une donnée que l'on reçoit du monde extérieur comme première et indispensable étape du traitement de cette donnée par l'application est une méthode qui est généralement suivie par les programmeurs. Sans quoi, il faut partout dans l'application vérifier que la donnée est valide, et courir le risque d'oublier cette vérification sur un cas obscur ou simplement lors d'une modification anodine. On vérifie que la donnée répond à un certain "format", et la fonction chargée de la vérification retourne une structure ou un objet correspond à la donnée. Dans les langages à typage statique, la donnée acquiert du même un coup un type. Ces informations de type permettent d'éliminer les tests "internes" redondants.
Ne pas traiter une erreur rapidement et au contraire déléguer sont traitement à "quelqu'un" d'autre est très similaire à ne pas vérifier les données entrantes: on multiple les gestionnaires pour être sûr d'intercepter toutes les erreurs, mais un jour on ne voit pas un cas obscur ou on fait un faux-pas sur une modification qui parait triviale, et des exceptions non-interceptées commencent à terminer le programme de manière inattendue.

Le mécanisme des exceptions est une technique populaire, et on peut se demander pourquoi Java est le seul langage à revenir ainsi sur ces exceptions. Les programmeurs Java sont-ils simplement médiocres? Cela me fait beaucoup penser au cas de la gestion manuelle de la mémoire. S'il est effectivement possible de gérer manuellement et correctement la mémoire à force de rigueur et de choix judicieux, en revanche dans certains cas la difficulté de cette gestion peut excéder nos capacités. On commet une erreur bête ou subtile, et la fuite mémoire tant redoutée apparaît. La mise sur pied d'une bonne gestion mémoire peut donc consommer énormément d'énergie, à la fois à la conception et à l'entretien.
Les programmeurs ne sont bien sûr pas des gens parfaits. Pour peu qu'un langage devienne populaire et largement utilisé comme Java, il attire des programmeurs de tous horizons et de tout niveau. Il ne faut pas s'attendre à ce que le programmeur moyen soit un programmeur d'élite. Beaucoup de langages prennent bien cela en considération, mais ce faisant ils attirent un éventail encore plus large de programmeurs. C'est une curieuse variante de la loi de Hofstadter: Le programmeur moyen est toujours un peu plus médiocre que ce que l'on pense, même en prenant en compte cette loi.
Il ne faudrait pas en conclure qu'il faut concevoir les langages (ou les logiciels, d'un manière générale) comme si l'utilisateur était complètement débile et incompétent, mais bien qu'il faut prévoir des dispositions particulières autour du langage (tutoriels, documentation, formation, ...) pour que le programmeur (ou l'utilisateur) moyen ait les compétences attendues. On peut donc concevoir un logiciel (ou un langage de programmation) avec un certain utilisateur-type en tête, mais il faut être conscient du décalage potentiel entre celui-ci et l'utilisateur réel, et que ce décalage se concrétise par un effort supplémentaire pour l'utilisateur et le concepteur en termes de formation.
On pourrait aussi arguer que la gestion d'exception dans Java est faible comme le sous-entend à peine l'article Wikipedia français sur le sujet. En particulier, que le "vrai" système de gestion des situations d'erreur, qui généralise les exceptions, est celui-ci de Lisp est un argument fréquent. Pour être honnête, je ne suis pas sûr d'avoir tout compris de son fonctionnement, et j'ai quelques méfiances vis-à-vis de systèmes qui résistent à la compréhension. La cause de mon incompréhension est que ce système plus complexe est encore pire pour ce qui est de suivre le déroulement du programme dans les cas d'erreur.

Les exceptions coupaient également le noeud gordien de "l'anti-motif de la flèche": dans la programmation structuré stricte ("stricturée"), la gestion d'erreur conduit à des imbrications de tests qui peuvent être tellement difficiles à suivre qu'on considère souvent que c'est une exception à la règle de la programmation stricturée. Par exemple, une fonction de copie de fichier en programmation stricturée donne ceci (pseudo-code; désolé, blogger s'obstine à supprimer mes indentations):
CopierFichier(String Src, String Dst)
HSrc=openFile(Src)
if(HSrc!=0)
{
HDst=createFile(Dst)
if(HDst!=0)
{
CopyContent(Src,Dst)
closeFile(HDst)
Error=NO_ERROR
}
else
{
closeFile(HSrc)
Error=ERROR_DST
}
}
else
{
Error=ERROR_SRC
}
return Error

Pour cette petite procédure, vérifier simplement et rapidement que le code est correct - et c'est rappelons-le ce pourquoi on utilise la programmation stricturée et non des GOTO - n'est pas tout à fait évident. Ceux qui ne sont pas d'accord ont tort d'ils n'ont pas vu que la procédure ne refermait pas le fichier source ;-)
Les exceptions permettent, d'une manière discutable il est vrai, d'éviter ces imbrications car les fonctions qui n'interceptent pas les exceptions des fonctions qu'elles appellent se voient terminées. En s'y prennant bien, on peut n'avoir localement qu'un seul chemin d'execution, tandis que dans notre exemple il y en a de multiples suivant les cas de figure, ce qui rend la vérification de notre exemple un peu difficile.
On peut avoir recours à diverses astuces pour éviter ces imbrication; mon exemple à cet égard est un peu forcé. Mais il n'existe pas dans la plupart des langages de mécanisme générique pour "casser" l'execution d'une procédure et passer à son épilogue (la partie un peu avant la fin faisant le nettoyage avant de quitter la fonction). Les seuls moyens à disposition sont de faire un "return" au beau milieu de la procédure ou d'avoir recours à un "goto".

L'examen de ces deux méthodes permet de tirer quelques conclusions.
D'abord que quelque soit la méthode utilisée, il n'y a pas de miracles: il faudra toujours à un moment ou un autre examiner l'erreur et y apporter la réponse ad hoc, ce qui implique la mise en place de structures équivalentes à un switch..case.
Ensuite que faire abstraction du principe qu'il faut vérifier et traiter une erreur le plus tôt possible est dangereux. Ignorer même temporairement une erreur la possibilité une opportunité de création de bogue, menace qui pèsera sur la maintenance ou l'évolution du programme.

La méthode de gestion d'erreur à privilégier est donc pour moi celle des codes d'erreurs. Un langage de programmation peut apporter son assistance en y apportant quelques éléments tirés pour certain de l'expérience avec les exceptions.
Le premier élément est l'utilisation d'une syntaxe spécifique pour l'examen des cas d'erreur, une structure similaire à un switch...case mais qui permettra dans un premier temps de "démêler" valeur de retour et signalement de l'erreur; de plus étant spécialisée, elle sera syntaxiquement plus "légère" que son modèle.
Mais surtout elle permettra la mise au point d'un dispositif de vérification de la vérification des erreurs (sic) par le compilateur, à la manière des "checked exceptions" de Java. Il serait possible de faire en sorte que le compilateur déduise du code les erreurs que peut retourner une procédure dans notre cas, mais demander leur déclaration manuelle est sans doute plus simple (pas seulement pour le compilateur) et meilleur.
Enfin, éviter le "motif de la flèche" demande la mise en place d'une construction permettant la définition d'un bloc de code, simplement pour pouvoir interrompre l'execution de ce bloc en cas d'erreur.