Repenser la propagation des exceptions avec Java 8
La sortie de Java 8 est prévu pour le 18 mars prochain ; les plus curieux d’entre nous ont déjà pris un peu de temps pour se familiariser avec quelques unes de ses nouveautés et en particulier avec les lambda et l’API java.util.stream
.
L’objet de cet article est d’étudier en quoi l’utilisation de cette API va nous amener à repenser la manière dont nous manipulons les exceptions Java.
Des lambda ! Des lambda partout ! Des lambda sans exceptions !
Considérons le contexte suivant :
– une méthode, doSomething
, prenant en entrée un Integer
et retournant un résultat du même type
– une liste d’entiers que nous souhaitons transformer à l’aide de l’API Stream
en utilisant la méthode décrite plus haut
Pour cela rien de plus simple :
public Integer public Integer doSomething(Integer value) { // do something ... } public void test() { List<Integer> myList = Arrays.asList(1, 2, 3); List<Integer> result = myList.stream(). map(this::doSomething). collect(Collectors.toList()); }
Supposons maintenant que la signature de la méthode doSomething
déclare retourner une exception checked.
public Integer doSomething(Integer value) throws IOException { // do something }
Le compilateur nous averti à présent d’une erreur : doSomething
retourne une exception qui n’est pas contenue dans la lambda transmise à la méthode Stream.map
. Et effectivement, map
prend en paramètre une instance de Function
laquelle définie la méthode apply
de la manière suivante :
R apply(T t);
Function
n’a donc pas été conçue pour remonter des exceptions checked. Il y a sans doute une bonne raison à cela : le projet lambda a donné lieu à de nombreux débats portant sur la place des exceptions en Java. Le sujet a même fait l’objet d’un article écrit par Brian Goertz : Exception Transparency in Java.
Pour résumé son propos : le traitement des exceptions checked est une des faiblesse du langage parce qu’il impose aux concepteurs d’API à choisir entre deux extrêmes : prévoir la possibilité de retourner des exceptions et imposer ainsi aux utilisateurs d’encadrer leurs appels dans des blocs try / catch
parfois inutiles, comme c’est le cas avec Callable
, ou suivre la voie inverse comme l’illustrent Runnable
et maintenant Function
.
Les échanges entre les membres du projet lambda montrent qu’ils ont essayé pendant un temps de trouver une solution alternative, mais en vain. Leur décision s’est donc finalement portée sur une implémentation « à la Runnable » mais je ne suis pas parvenu à trouver de documents exposant les raisons qui les ont poussé à faire ce choix.
Quoiqu’il en soit, et dans ces conditions, comment transmettre l’information indiquant qu’une exception a été levée durant l’exécution d’un stream ? Trois solutions au moins s’offrent à nous.
Une première approche naïve.
En l’état, le plus simple serait de catcher notre exception lors de l’appel à doSomething
:
List<Integer> result = myList.stream(). map(value -> { try { return doSomething(value); } catch (IOException e) { e.printStackTrace(); return -1; } }). collect(Collectors.toList());
Pour juger de l’intérêt de cette approche, ainsi que de celles qui suivront, je propose de prendre en compte quatre critères :
- propagation : l’exception d’origine peut-elle être remontée le long de la pile d’appel ?
- disjonction : la valeur d’échec peut-elle interférer avec l’ensemble des résultats valides ?
- visibilité : la présence éventuelle d’une exception est-elle clairement visible par le développeur ?
- indépendance : un échec provoqué p
ar le traitement d’un élément d’une collection peut-il entraîner l’échec du traitement de l’ensemble des éléments de cette collection ?
Avec cette solution le contenu même de l’exception est perdu au cours du traitement et la valeur d’échec peut interférer avec un résultat valide (-1, dans cet exemple, pourrait tout à fait être une valeur correcte). En revanche le compilateur averti clairement le développeur de la présence éventuelle d’une exception à l’intérieur de map
et cette éventualité n’empêchera pas le traitement de tous les éléments de myList
.
Un classique : la cape d’invisibilité appliquée aux exceptions.
Il est possible de rendre une exception « invisible » en l’enveloppant à l’intérieur d’une exception de type runtime.
public Integer doSomething(Integer value) { try { // do something } catch(IOException e) { throw new RuntimeException(e); } }
Cette manipulation est d’ailleurs tellement courante que Guava propose une classe utilitaire, Throwables
, facilitant cette opération. Nous pouvons maintenant traiter l’exception à un niveau plus élevé que précédemment :
public void test() { ArrayList<Integer> myList = Arrays.asList(1, 2, 3); try { List<Integer> result = myList.stream(). map(this::doSomething). collect(Collectors.toList()); } catch(RuntimeException e) { // handle exception } }
Cette solution fonctionne mais elle nous limite en cela qu’une exception remontée lors de la manipulation d’un élément de notre stream empêchera le traitement des éléments restants. Or nous pourrions considérer que ces traitements sont tous indépendants les uns des autres et que ce comportement n’est donc pas approprié.
Un collègue, Hugo Wood, a proposé une solution pour résoudre ce problème. Elle est disponible sur GitHub et consiste à encapsuler le traitement des élément du stream dans des Supplier
puis à collecter le tout à l’aide d’un Collector
particulier.
Cette solution reste toutefois dangereuse : en rendant silencieuses nos exceptions nous pouvons être certain qu’il arrivera régulièrement aux membres de notre équipe d’oublier de les traiter. Et quand bien même, il nous faudrait aussi séparer le bon grain de l’ivraie, à savoir d’une part les exceptions runtime qui doivent redescendre tout en bas de la pile d’appel pour signaler les erreurs graves (salut à toi, NullPointerException
) et de l’autre les exceptions checked que nous devrions contenir.
Autant de symptômes indiquant selon moi que quelque chose ne va pas avec cette approche. Ceci dit il n’y a pas que du négatif : les exceptions peuvent être propagées le long de la pile d’appel, il n’y a pas d’interférence entre un échec et l’ensemble des résultat valides et, grâce à l’implémentation de Hugo, les différents éléments d’un stream peuvent être traités indépendamment les uns des autres.
Un nouveau type d’exception.
On dira ce que l’on veut de Scala mais sa gestion des exceptions checked a le mérite d’être agréable à utiliser. Scala propose en effet deux classes, Success
et Failure
, héritant toutes deux de la classe abstraite Try
. Un Try
représente une tentative pour réaliser une opération et obtenir un résultat (en cela sa sémantique n’est pas différente de celle du mot clef java try
). Si cette tentative est un succès il s’agit d’une instance de Success
. Dans le cas contraire, il s’agit d’une instance de Failure
.
Pour la petite histoire : Try
est en réalité une contribution de Twitter et c’est seulement après plusieurs années d’utilisation en interne que ce pattern a été intégré à l’API Scala.
L’avantage principal de cette solution est qu’elle nous permet de ne plus être confronté au dilemme que nous avons évoqué plus haut : nous pouvons à présent laisser aux développeurs la décision de transmettre ou non des exceptions checked simplement en rendant générique les types de retour des méthodes de nos interfaces. Dans ce contexte, la présence d’une exception n’est en aucun cas dissimulée : tout comme Optional
a été conçue pour alerter le développeur sur la présence éventuelle d’une référence null (la fameuse erreur à un milliard de dollars), Try
nous alerte sur la possible présence d’une exception.
L’autre avantage est que la réception d’une exception ne rompt plus brutalement le flux d’exécution de notre programme. Son traitement s’intègre plus naturellement dans notre code et nous réduisons ainsi l’empreinte du code purement technique.
À ma connaissance, le seul type présent dans Java 8 qui se rapproche un peu du concept de Try
est Optional
, mais il s’agit d’une classe final dont il
n’existe qu’une seule instance empty
(c’est pour s’en assurer que le constructeur de Optional
a été rendu privé). Impossible donc de conserver les exceptions levées avec ce moyen.
Nous allons devoir implémenter nous même cette structure si nous souhaitons l’utiliser… une implémentation de Try
pourrait ressembler à ceci :
Rien de très inattendu : nous avons là une classe abstraite Try
et deux implémentations Success
et Failure
.
ThrowingFunction
est une interface représentant une fonction qui, contrairement à l’interface standard Function
, peut retourner une exception. Try.of
a pour rôle de convertir une instance de ThrowingFunction<I, O>
en une instance de Function<I, Try<O>>
. C’est en quelque sorte le pont qui réconcilie java.util.stream
avec les exceptions.
public static <I, O> Function<I, Try<O>> of(ThrowingFunction<I, O> function) { return a -> { try { O result = function.apply(a); return new Success<>(result); } catch (RuntimeException e) { throw e; // we don't want to wrap runtime exceptions } catch (Exception e) { return new Failure<>(e); } }; }
Voici un exemple d’utilisation : la transformation d’une liste d’entiers à l’aide de doSomething
et le partitionnement des résultats en fonction du succès de cette opération. Le partitionnement est effectué à l’aide de la méthode Try.groupingBySuccess
laquelle est simplement une méthode utilitaire retournant le Collector approprié.
public Integer doSomething(Integer value) throws IOException { // do something } public void test() { List<Integer> myList = Arrays.asList(1, 2, 3); Map<Type, List<Try<Double>>> result = myList.stream(). map(Try.of(this::doSomething)). map(trry -> trry.map(i -> Math.PI * i)). collect(Try.groupingBySuccess()); if(result.containsKey(Type.FAILURE)) { // do something with failure } if(result.containsKey(Type.SUCCESS)) { // do something with success } }
Ici, peu importe que les éléments calculés soient de type Success
ou Failure
, notre stream continuera d’être exécuté jusqu’à ce que tous les éléments de myList
aient été indépendamment traités. Cette solution est donc conforme aux quatre critères que nous avons listé. Voici d’ailleurs un récapitulatif des trois solutions que nous venons de voir :
propagation | disjonction | visibilité | indépendance | |
solution naïve | non | non | oui | oui |
exceptions runtime | oui | oui | non | oui |
success / failure | oui | oui | oui | oui |
Il existe pourtant des situations qui requiert de pouvoir cesser le processus de traitement dès qu’un échec a été détecté. Bien sûr, il aurait été idéal de pouvoir transmettre à notre stream un prédicat lui indiquant quand il doit interrompre son traitement, mais pour cela la seule méthode proposée par l’API est la méthode limit(int n)
qui limite le nombre de résultats retournés.
Pour contourner ce problème je me suis inspiré de la solution implémentée par Hugo et je l’ai adapté en ajoutant une méthode statique Try.lazyOf
afin de convertir une instance de ThrowingFunction<I, O>
en une instance de Function<I, Supplier<Try<O>>>
. Par la suite les suppliers sont collectés dans une implémentation dédiée de Collector, TryCollector
, et résolus tant que les résultats sont de type Success.
public void test() { Try<List<Integer>> result = myList.stream(). map(Try.lazyOf(this::doSomething)). collect(Try.collect()); if (result.isSuccess()) { // process success } else { // process failure } }
Conclusion
Bien sûr, l’idéal serait de pouvoir intégrer toutes ces fonctionnalités directement dans l’API java.util.stream
. La bonne nouvelle c’est que c’est possible : grâce aux méthodes virtuelles d’extension nous avons désormais la possibilité d’ajouter des méthodes ayant un comportement par défaut dans n’importe quelle interface tout en conservant une compatibilité ascendante. Il n’est donc pas impossible qu’un jour ou l’autre Oracle décide d’intégrer un dispositif similaire à son API. En attendant, le code source de cette implémentation de Try
est disponible ici.