Il y a 2 ans -
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
ouMaybe
dans certains langages) : soit un T, soit rien. Prenons par exemple une méthode qui fait une division, où T sera unInteger
:Optional<Integer> divideBy(Integer dividend, Integer divisor) { return divisor == 0 ? Optional.empty() : Optional.of(dividend / divisor); }
Try<T>
(nécessite Vavr en Java) : soit unT
, soit une erreur (exception). Par exemple, avecT
=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 unT
, soit unU
. Par exemple, avecT
=String
etU
=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.
Commentaire
2 réponses pour " Les exceptions, mauvaise solution pour la gestion des erreurs dans une application "
Published by Ludovic , Il y a 2 ans
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 ?
Published by Christophe LS , Il y a 2 ans
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)