Patterns optionels

De nombreuses choses ont été introduites en Java 8 qui changent la façon de concevoir les applications et les API. Les lambdas bien sûr, l’API Stream également. Si tu es un habitué de ce blog, cher et précieux lecteur, tu es déjà au courant.

Un autre élément a été introduit, la classe finale Optional, qui change également la façon de faire les choses, dans le sens d’une plus grande fluidité du code applicatif que l’on écrit. L’objet de cet article est de détailler le concept d’optional, et de montrer les patterns disponibles pour utiliser des optionals efficacement et élégamment. Les optionals peuvent notamment être utilisés très efficacement avec les streams, ce que nous allons voir.

Qu’est-ce qu’un Optional ?

Ce concept n’est pas spécialement nouveau, et existe déjà dans d’autres langages. Cette classe a été introduite pour modéliser le fait qu’une méthode peut très bien ne pas pouvoir retourner de valeur. Il se trouve que le plus souvent le choix qui est fait dans ce cas consiste à prendre une valeur par défaut. Par exemple, la méthode map.get(key) retourne null dans le cas où la clé que l’on passe en paramètre ne se trouve dans la table de hachage. Ce qui est un pis aller. En fait, si la clé se trouve dans la table, associée à la valeur null, cette méthode retournera aussi null. On voit donc que cette solution n’est sans doute pas la meilleure, et en tout cas, qu’elle ne permet pas de savoir si oui ou non la clé passée en paramètre est présente dans la table.

Ce serait une erreur de penser que la classe Optional n’est là que pour ça : gérer des cas limites. D’autant que ces cas limites, on les gère déjà, depuis près de 20 ans que Java existe, sans optionals… Mais la façon dont cette classe a été écrite autorise en fait de nouveaux patterns, qui, alliés aux patterns de l’API Stream, deviennent particulièrement simples.

Optional est nécessaire

En fait ce concept de « résultat qui n’existe pas » est nécessaire dans de nombreux cas applicatifs, et depuis longtemps. Voyons un exemple dans le cas de l’API Stream. Prenons le simple calcul d’un max. L’API Stream expose une méthode max(), l’étude de ce cas n’est donc pas optionelle (elle).

Écrivons le pattern d’utilisation de la méthode Stream.max().

Stream<Integer> stream =
   Stream.of(1, 2, 3, 4) ;
stream.max(Comparator.naturalOrder()) ;

Dans ce cas, pas de souci, le résultat doit être 4.

Le cas pathologique est en fait celui du calcul d’un max sur un stream qui se trouve être vide. Quel valeur retourner alors ?

Si l’on veut comprendre l’étendue du problème, il faut prolonger un peu le cas d’utilisation. Supposons que l’on ait deux streams, et que l’on effectue le calcul suivant.

Stream<Integer> stream1 =
   Stream.of(1, 2) ;
int max1 = stream1.max(Comparator.naturalOrder()) ;

Stream<Integer> stream2 =
   Stream.of(3, 4) ;
int max2 = stream2.max(Comparator.naturalOrder()) ;

Á l’évidence, on doit avoir la propriété suivante :

int max = Integer.max(max1, max2) ;

Stream<Integer> stream3 =
   Stream.concat(stream1, stream2) ;
int max3 = stream3.max() ;

assert max == max3

Si l’on fusionne deux streams, le max de ce stream doit aussi être le max des deux max. Et là, la malédiction va commencer à frapper.

La méthode max() peut-elle retourner null ? En fait la question est la suivante : souhaite-t-on vraiment ajouter des valeurs nulles dans nos traitements, dont on ne saura pas quoi faire, qu’il va falloir gérer, et dont il faudra surtout se protéger. Á l’évidence la réponse est dans la question : non.

Supposons que max() ne puisse retourner qu’un entier, ou d’une façon générale, le type sur lequel le stream est construit. Quelle valeur va-t-on choisir dans le cas où le stream sur lequel on calcule le max() est vide ?

La première réponse qui nous vient à l’esprit sera probablement 0. Examinons l’exemple suivant.

int max1 =
   Stream.empty().max() ; // suppose max1 is 0
int max2 = 
   Stream.of(-1, -2, -3).max() ; // max2 is -1

int max = Integer.max(max1, max2) ; // thus 0

Stream stream3 = Stream.concat(stream1, stream2) ;
int max3 = stream3.max() ; // stream3 is NOT empty!
                           // max3 is -1

assert max == max3 ; // nope, max is 0

Pas de chance, la propriété « max du max » ne fonctionne plus, notre méthode max() ne marche donc pas. Dire que la méthode max() retourne un entier mène à un joli bug.

Pourquoi cela ? Parce que la valeur par défaut que nous devons choisir comme retour pour max(), c’est-à-dire le max de l’ensemble vide, doit aussi être l’élément neutre de l’opération max. Et le problème, précisément, c’est que max n’admet pas d’élément neutre.

Optional à la rescousse

Le max de l’ensemble vide n’est pas défini et choisir une valeur mène à des incohérences de calcul si l’on n’y prend pas garde. Trouver une solution correcte à ce problème est important, car le max n’est pas la seule opération à ne pas avoir d’élément neutre. Le min est dans le même cas, mais aussi le calcul de la valeur moyenne…

Le choix qui a été fait est de dire que la méthode max() retourne en fait une instance d’Optional, nouveau concept introduit en Java 8. Au passage, min() retourne également un optional, de même que la méthode average(), pour les mêmes raisons.

Le type Optional a été introduit pour gérer le fait qu’une valeur puisse ne pas exister, ce qui est différent de dire qu’elle est nulle.

Optional : premiers patterns

Quand on examine la classe Optional, on se rend compte que l’on a deux familles de patterns pour l’utiliser.

Dans la première famille, une instance d’Optional est vue comme une instance d’un type enveloppe (Long, Double, etc…), dans laquelle il se peut qu’il n’y ait rien.

On a donc un premier couple de méthodes isPresent() et get() pour gérer ça.

Optional<Integer> opt = ... ;
if (opt.isPresent()) {
   int value = opt.get() ; // there is a value
} else {
   // decide what to do
}

On a ensuite deux variantes de ce pattern. La première qui propose une valeur par défaut applicative, que l’on peut écrire de deux façons.

Optional<Person> opt = ... ;
// 1st way, decide of a default value
Person p1 = opt.orElse(Person.DEFAULT_PERSON) ;
// 2nd way, the same, lazily built
Person p2 =
   opt.orElseGet(() -> Person.DEFAULT_PERSON) ;

Cette première variante utilise la méthode orElseGet(), qui prend un Supplier en paramètre. Cette méthode agit donc comme un constructeur lazy, qui ne construira l’objet à retourner que si l’on en a vraiment besoin. Joli pattern, donc la simplicité d’écriture repose sur l’utilisation des lambdas.

Et la seconde variante, jette une exception, construite à la demande.

Optional<Person> opt = ... ;
// lazy construction of the exception
Person p1 = opt.orElseThrow(
   PersonNonExistentException::new) ;

Optional : seconds patterns

Première version

Mais l’on peut aussi voir une instance d’Optional différemment, et ouvrir des patterns beaucoup plus intéressants.

Tout d’abord la classe Optional porte des méthodes analogues à celles de Stream : map(), filter(), et ifPresent().

Construisons un exemple sur une classe NewMath, qui, au lieu de jeter des exceptions ou retourner NaN, retourne des Optional lorsque l’on demande un calcul qui ne peut pas être fait. Cet exemple est en fait tiré de l’excellent livre de Cay Horstman [1].

public class NewMath {

   public static Optional<Double> sqrt(Double d) {
      return d > 0 ?
         Optional.of(Math.sqrt(d)) ;
         Optional.empty() ;
   }

   public static Optional<Double> inv(Double d) {
      return d != 0 ?
         Optional.of(1/d) ;
         Optional.empty() ;
   }
}

Cette classe est construite sur une idée simple. Plutôt que de multiplier les comportements de retour (exceptions, valeurs particulières), on retourne systématiquement un optional, qui sera vide si l’on ne peut pas déterminer de valeur de retour. La différence peut paraître minime, mais comme nous allons le voir dans la suite, elle est fondamentale.

Supposons que l’on veuille traiter une liste de double avec les méthodes de ce cette classe.

double [] doubles = ... ; // an array of doubles
List<Double> result = new ArrayList<>() ;
doubles
   .stream()
   .forEach(
      d -> NewMath
             .sqrt(d)
             .ifPresent(
                result::add
             )
   ) ;

Á la fin de l’opération, tous les doubles qui auront pu être traités auront généré une valeur dans la liste result. Les autres valeurs ont été retirées du flux, en silence.

Utiliser le concept d’optional permet ici de s’affranchir de l’utilisation de if-then-else. Le code est plus simple à lire, à écrire également (avec un peu d’habitude). Il sera également plus performant.

La question se pose alors du chaînage des opérations. Ici l’on calcule la racine carrée, comment peut-on écrire l’inverse de la racine carrée ?

Une méthode spéciale de la classe Optional nous permet de faire exactement ce que l’on veut : flatMap(). Cette méthode prend un optional en paramètre, et retourne également un optional. Elle a été faite précisément pour chaîner les appels. Notre code devient donc le suivant.

double [] doubles = ... ; // an array of doubles
List<Double> result = new ArrayList<>() ;
doubles
   .stream()
   .forEach(
      d -> NewMath
             .inv(d)
             .flatMap(NewMath::sqrt)
             .ifPresent(
                result::add
             )
   ) ;

Deuxième version

Ce code est intéressant, il fait ce que l’on veut, mais il a tout de même un inconvénient. La méthode ifPresent() prend en argument une liste externe. Si l’on veut paralléliser ce traitement (ce qui ne devrait pas poser de problème), il va nous falloir utiliser une structure concurrente. On aimerait bien que l’API le fasse pour nous.

Il nous faudrait pour cela utiliser un collector, et donc utiliser un mapping plutôt qu’un for-each.

Pour cela, nous allons transformer notre optional en stream. Notre optional peut être vide, ne pas porter de valeur, nous allons le transformer en un stream un peu particulier, qui ne pourra comporter qu’une valeur, ou aucune valeur.

Regardons la fonction utilisée dans notre traitement précédent.

Function<Double, Optional<Double>> f =
   d -> NewMath.inv(d)
           .flatMap(NewMath::sqrt) ;

On peut en fait mapper cet Optional en un Optional<Stream>. Cet optional conserve la même sémantique que le précédent : il se peut qu’il soit vide. Mais comme il s’agit d’un optional, on peut invoquer la méthode orElse() dessus, et retourner un stream vide. Cela donne le code suivant.

Function<Double, Stream<Double>> invSqrt =
   d -> NewMath.inv(d)
         .flatMap(NewMath::sqrt)
         .map(Stream::of)
         .orElseGet(Stream::empty) ;

Cette fonction manipule des optionals dans sa mécanique interne, mais ne les expose pas. Elle retourne un stream, vide si le double traité est invalide (ici négatif ou nul), ou contenant la valeur calculée.

Il n’y a plus qu’à utiliser cette fonction pour écrire notre traitement.

List<Double> doubles = Arrays.asList(-1d, 0d, 1d) ;

doubles.stream().parallel()
      .flatMap(invSqrt)
      .collect(Collectors.toList()) ;

Le résultat de ce traitement est bien celui attendu, les deux valeurs invalides ont été retirées du résultat, silencieusement.

[1]

Et comme on ne fait plus appel à une structure externe pour stocker notre résultat, on peut utiliser un stream parallèle sans problème, de même que toutes les fonctionnalités de l’API Stream.

Conclusion

Cette deuxième façon d’utiliser les optionals est sans aucun doute beaucoup plus intéressante que la première, qui consiste à regarder s’il y a une valeur et à l’en extraire. Ici on écrit les choses de façons fluides, en traitant l’intégralité des données sans erreur ni exception, et en retirant les données que l’on ne peut pas traiter du flux entrant sans if-then-else. Un nouveau pattern Java 8, exploitant les optionals, les stream, et les méthodes flat-map.

Références

[1] Java SE 8 for the really impatient, Cay Horstman, Addison Wesley.

2 réflexions au sujet de « Patterns optionels »

Les commentaires sont fermés.