Published by

Il y a 2 mois -

Temps de lecture 6 minutes

Les exceptions, mauvaise solution pour la gestion des erreurs dans une application

En programmation, les exceptions représentent des anomalies auxquelles il est possible de répondre par un traitement spécifique qui sera déclenché à la détection. L’ensemble constitue un système de gestion des exceptions.

Dans cet article, nous prendrons pour référence les exceptions telles que l’on peut les trouver dans le langage Java, mais les points mentionnés restent valides pour de nombreux autres langages utilisant le concept d’exception.

Nous commencerons par étudier les différents problèmes apparaissant lorsque l’on utilise les exceptions, puis nous verrons une manière différente de gérer les erreurs dans une application.

Problèmes des exceptions

L’utilisation d’exceptions s’accompagne de différents problèmes qui nuisent à la compréhension du code.

Les exceptions complexifient le code

La présence d’exceptions et de traitements associés casse le flux d’exécution normal du code : on cesse de dérouler les instructions du bloc de code courant et on « saute » ailleurs. Cela introduit d’autres flux d’exécution cachés, ce qui :

  • rend plus difficile les raisonnements sur la logique d’exécution du programme ;
  • augmente le risque de laisser le programme ou les données qu’il manipule dans un état incohérent.

Ces problèmes sont les mêmes que ceux apparaissant avec l’utilisation de « Goto », que la plupart des développeurs déteste pour cette (bonne) raison.

De plus, l’introduction de flux d’exécutions cachés a pour implication l’apparition de points de sortie supplémentaires dans les fonctions ou méthodes. La multiplication de points de sortie complexifie une méthode et rend donc plus difficile sa compréhension.

Côté traitement des exceptions, on a généralement des blocs de type try...catch, avec éventuellement une clause finally pour l’exécution de code inconditionnelle :

try {
	riskyOperation1();
	riskyOperation2();
} catch (Exception1 e) {
	specificProcessing1();
} catch (Exception2 e) {
	specificProcessing2();
} catch (Exception e) {
	generalErrorHandling();
} finally {
	clean();
}

(Exemple inspiré du chapitre sur les exceptions de J.M. Doudoux)

L’apparition de ces structures try-catch-finally un peu partout complexifie encore un peu plus le code en nuisant à sa lisibilité. Il s’agit pourtant simplement de structures de contrôle déguisées. C’est la raison pour laquelle des langages comme Go ont (initialement) abandonné les exceptions.

Les exceptions sont souvent inadaptées

Initialement, les exceptions n’ont pas été conçues pour gérer un flux d’exécution normal, mais uniquement pour traiter des situations que le programme ne sait pas gérer. Pourtant, il n’est pas rare de voir les exceptions utilisées dans des cas communs et facilement gérables comme les erreurs de parsing, les timeout, les entrées invalides…

De plus, les exceptions ne conviennent pas bien en environnement hautement parallèle, car elles sont plus utiles (et prévues pour) du mono-thread, pour revenir en arrière dans la pile d’appel et remonter à l’erreur. Par exemple, avec un modèle acteur ou bien en fork-join, il n’y a pas de chemin de retour en arrière évident.

Checked vs unchecked exceptions

Pour les langages statiquement typés, il existe deux types d’exceptions : les checked (vérifiées à la compilation) et les unchecked. On peut parfois entendre que l’un de ces deux types résout les problèmes amenés par l’autre ; à titre personnel, je pense que les deux apportent leur lot de problèmes non-négligeables.

Les unchecked exceptions :

  • Rendent invisibles les erreurs et rien ne force le développeur à les gérer ;
  • Sont des Goto invisibles ;
  • Si elles ne sont pas attrapées (catch) immédiatement, c’est la garantie d’anomalies surprises plus tard.

Concernant les checked exceptions, c’est-à-dire les exceptions vérifiées à la compilation :

  • Si on décide de traiter le problème plus haut (méthode appelante), alors les signatures de toutes les méthodes intermédiaires sont modifiées pour l’indiquer. Si on doit changer l’erreur ou en ajouter une, il faut à nouveau modifier toutes les signatures ;
  • Si on attrape (catch), le code devient lourd. En particulier dans une lambda dont l’un des aspects intéressant est la concision.

Solution ?

Le paradigme fonctionnel apporte l’élégante solution qui consiste à considérer les erreurs comme faisant partie des résultats possibles d’une opération et donc à ce titre de l’intégrer naturellement dans le type de retour.

Java (avec l’excellente bibliothèque Vavr), Scala, Haskell et bien d’autres langages disposent de types permettant d’indiquer clairement toutes les sorties possibles d’une méthode/fonction grâce à un type adapté suivant le besoin :

  • Optional<T> (inclus de base dans Java 8+, aussi appelé Option ou Maybe dans certains langages) : soit un T, soit rien. Prenons par exemple une méthode qui fait une division, où T sera un Integer :

    Optional<Integer> divideBy(Integer dividend, Integer divisor) {
    	return divisor == 0
    		? Optional.empty()
    		: Optional.of(dividend / divisor);
    }
  • Try<T> (nécessite Vavr en Java) : soit un T, soit une erreur (exception). Par exemple, avec T = Integer :
    Try<Integer> divideByWithDetails(Integer dividend, Integer divisor) {
    	return divisor == 0
    		? Try.failure(new DivisionBy0Exception())
    		: Try.success(dividend / divisor);
    }
  • Either<T, U> (nécessite Vavr en Java): soit un T, soit un U. Par exemple, avec T = String et U = Integer :
    Either<String, Integer> divideBy0WithMessage(Integer dividend, Integer divisor) {
    	return divisor == 0
    		? Either.left("Cannot divide by 0")
    		: Either.right(dividend / divisor);
    }

Ainsi, les résultats possibles suite à l’exécution de la fonction sont clairement véhiculés via le système de type : soit c’est un succès (de type Integer pour les exemples précédents), soit c’est un échec qui sera modélisé par un type dédié.

De plus, ces structures permettent également d’enchainer les opérations qui peuvent échouer : si un appel dans la chaine retourne une erreur, par exemple Optional.empty sur un Optional, alors toute la chaine retourne Optional.empty.

Exemple :

divideBy(a, b)
	.map(this::someFurtherProcessing)
	.map(this::otherOperation)
	.map(this::finalOperation)
	.orElse(DEFAULT_VALUE);

Avec ce traitement, on peut échouer à la ligne 1, 2, 3 ou 4 et tout de même terminer avec une valeur qui peut être utilisée dans la suite de notre traitement. Tout ça sans artifice lourd de type try…catch.

Je précise que cela fonctionne tant que someFurtherProcessing, otherOperation et finalOperation ne jettent pas d’unchecked exception. Si c’est le cas, alors il vaudrait mieux revoir ces méthodes et utiliser pour leur signature les types dédiés mentionnés plus haut, puis les chainer avec flatMapà la place de map.

Conclusion

L’apparition de systèmes de gestion des exceptions part d’une intention louable (permettre de gérer les évènements perturbant l’exécution normale d’un programme). Malheureusement, ce système comporte des inconvénients non négligeables et est souvent utilisé à tort. Les types dédiés à la gestion des retours alternatifs massivement utilisés en programmation fonctionnelle, tels que Optional, Try ou Either apportent une réponse élégante et adaptée en utilisant le système de type du langage pour spécifier les retours d’erreurs.

Published by

Publié par Bastien Bonnet

Bastien est un développeur disposant de 8 ans d'expérience. Il est passionné par le développement de logiciel de qualité (code clair, facile à maintenir, robuste face aux régression). Agiliste convaincu, il s'inscrit parfaitement dans le mouvement du software craftsmanship. Il est convaincu et investi dans le partage de connaissance pour améliorer le niveau technique et les compétences de son équipe.

Commentaire

2 réponses pour " Les exceptions, mauvaise solution pour la gestion des erreurs dans une application "

  1. Published by , Il y a 1 mois

    Je ne suis qu’à moitié d’accord avec cette analyse. L’exception apporte une chose importante, c’est de localiser précisément le lieu et la cause du problème grâce à la Stacktrace, d’autant qu’on peut les composer avec le caused by.

    Quand on utilise des monades qui sont « flatmappées », on perd toute information sur la localisation première du problème. Cela arrive souvent avec les Optionals qui sont mappés, filtrés et remontés et quand on obtient un absent, on ne sait pas d’où il vient. Pour débugger, ce n’est pas très pratique.

    Il faudrait un mécanisme qui fasse les deux, une sorte de Monad Validation mixée avec un Try.

    Qu’en pensez-vous ?

  2. Published by , Il y a 1 mois

    Pratique le Try;
    Mais comment coder la formule toute simple « (a/b)/(c/d) » en programmation fonctionnelle à partir de Try divideByWithDetails(Integer dividend, Integer divisor) ?
    (qui reste très simple à coder avec les exceptions)

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Nous recrutons

Être un Sapient, c'est faire partie d'un groupe de passionnés ; C'est l'opportunité de travailler et de partager avec des pairs parmi les plus talentueux.