Java 8 : petit exercice pour s'échauffer le neurone à lambda

A une semaine de Devoxx France 2014 qui risque d'être riche en sessions sur Java 8 et la programmation fonctionnelle, je vous propose un petit exercice pour vous dérouiller le neurone à lambdas.

Le but du jeu est d'écrire une fonction permettant de concaténer un certain nombre de listes, passées en paramètre sous forme de var-arg :

public <T> List<T> concatLists(List<T>... lists);

Evidemment, on essaiera d'utiliser le plus possible de nouvelles fonctionnalités de Java 8 - le but est de s'amuser et de tordre un peu Java, et pas forcément de respecter les bonnes pratiques industrielles.

Allez hop, en route !

Version 1 : foreach

Tout d'abord, comment écrirait-on cette méthode en Java "traditionnel" (Java <=7) ?
A vue de nez, on bouclerait sur les listes à concaténer, et on déverserait le contenu de chaque liste dans une liste résultat initialement vide :

public <T> List<T> concatLists1(List<T>... lists) {
    ArrayList<T> result = new ArrayList<>();
    for (List<T> list : lists) {
        result.addAll(list);
    }
    return result;
}

Facile.

Mais, dites-moi, ce pattern "je boucle sur une collection, et j'applique une fonction à chaque boucle", ça ne vous rappelle rien ? Si, c'est un Fold ! Et en Java 8, cette opération est disponible sur les Streams, sous le nom de reduce.

Version 2 : Fold à la rescousse

Recette du Fold de Papy Lambda, pour 4 personnes :

  • Un ensemble d'éléments sur lesquels itérer : le var-arg en entrée de la méthode
  • Une valeur initiale pour l'accumulateur (identité) : une liste vide
  • Une opération à appliquer à chaque étape

Pour ce dernier point, reduce() attend en paramètre un java.util.function.BinaryOperator<T>. Cette drôle d'interface n'est en réalité qu'un synonyme pour java.util.function.BiFunction<T,T,T>, qui représente une fonction acceptant deux paramètres de type T (l'accumulateur et l'élément en cours d'itération), et renvoyant un résultat du même type T (la nouvelle valeurde l'accumulateur).

C'est sous cette forme que nous devrons exprimer notre opération de réduction, basée sur List.addAll() comme précédemment.

Voyons comment ça se présente :

public <T> List<T> concatLists2(List<T>... lists) {
    return Stream.of(lists).reduce(new ArrayList<>(), new BinaryOperator<List<T>>() {
        @Override
        public List<T> apply(List<T> list1, List<T> list2) {
            list1.addAll(list2);
            return list1;
        }
    });
}

C'est beau, non ?

Bon d'accord, ça fonctionne, c'est plus "fonctionnel", mais c'est tout de même moins lisible que la simple boucle - la faute au BinaryOperator implémenté sous forme de classe anonyme, et aux Generics qui s'invitent à tous les étages.

Peut-on faire mieux ?

Version 3 : Viva la lambda-da

BinaryOperator étant une interface fonctionnelle (@FunctionalInterface), il est possible de l'exprimer sous la forme d'une expression lambda.

Adieu donc toute la tuyauterie des classes anonymes ! Seule la substantifique moelle de l'algorithme est conservée :

public <T> List<T> concatLists3(List<T>... lists) {
    return Stream.of(lists).reduce(new ArrayList<>(), (list1, list2) -> {
        list1.addAll(list2);
        return list1;
    });
}

Le code ainsi obtenu est même plus court que notre boucle for-each initiale !

Mais on a dit qu'on était là pour utiliser un maximum de nouveautés de Java 8 sur ce petit exercice. Que peut-on rajouter ? Les références de méthodes ?

Challenge accepted.

Version 4 : Pour bien faire la référence

Java 8 propose maintenant une forme de "duck typing"[1] : partout où une interface fonctionnelle est attendue en paramètre d'une méthode, il est possible de fournir une référence vers une méthode existante proposant une signature compatible.

Par exemple, la méthode Iterable.forEach() prend en paramètre un java.util.function.Consumer<T>, qui définit une méthode acceptant un unique paramètre de type T et ne renvoyant rien.
La méthode System.out.println() correspond justement à cette définition ; on peut donc l'assimiler à un Consumer, et donc l'utiliser comme ceci - notez l'utilisation du double deux-points (::) :

Arrays.asList(1,2,3).forEach(System.out::println);

Dans notre cas, reduce() attend en paramètre un BinaryOperator, qui définit une méthode acceptant deux paramètres de même type, et renvoyant un résultat également du même type.

Malheureusement, la méthode List.addAll() ne correspond pas à cette définition, car elle renvoie un booléen. Mais nous pouvons écrire une méthode statique possédant la bonne signature, encapsulant l'appel à List.addAll() :

public static <E> List<E> concatLists(List<E> list1, List<E> list2) {
    list1.addAll(list2); // Ignore return value
    return list1;
}

Et l'utiliser ainsi dans le fold :

public List<Integer> concatLists4(List<Integer>... lists) {
    return Stream.of(lists).reduce(new ArrayList<>(), MyClass::concatLists);
}

Victoire ! Un one-liner !

Enfin presque - il faut tout de même écrire une méthode qui se conforme à l'interface BinaryOperator, ce qui ne fait que déplacer le problème.
Au final, cette solution semble moins satisfaisante qu'une simple lambda...

Mais... c'est sans compter sur le Fluentizer©® 3000 !

Version 5 : Fluentizer 3000 fait étinceler vos interfaces

En fait, ce qui nous empêche de passer directement une référence vers List.addAll(), c'est que cette méthode renvoie un booléen au lieu de renvoyer une autre liste. Vilaine, vilaine méthode. Et c'est d'autant plus ballot que de toute façon on ignore le booléen qu'elle renvoie !

Pourrait-on imaginer un système qui transformerait une méthode de signature (T,T) -> R (autrement dit, une BiFunction<T,T,R>) en méthode compatible avec l'interface fonctionnelle BinaryOperator (T,T) -> T ?

Allez hop, sous vos yeux ébahis :

public static <T> BinaryOperator<T> fluentize(BiFunction<T, T, ?> op) {
    return (t1, t2) -> {
        op.apply(t1, t2); // drop result
        return t1;
    };
}

Oui, c'est moche, mais ça permet maintenant d'écrire ceci :

public List<T> concatLists5(List<T>... lists) {
    return Stream.of(lists).reduce(new ArrayList<>(), fluentize(List::addAll));
}

N'est-ce pas le bonheur suprême ?

Conclusion

Bon, on va arrêter là, on a déjà suffisamment tordu Java 8 pour aujourd'hui.

Mais ce petit exercice nous a déjà permis de réviser quelques-unes des nouveautés les plus intéressantes de Java 8, histoire de s'échauffer un peu le muscle fonctionnel. Maintenant, il n'y a plus qu'à pratiquer !

A la semaine prochaine à Devoxx !

Note

[1] Rien à voir avec la catégorisation des palmipèdes, la preuve sur Wiki(palmi)pedia


Commentaires

1. Le mercredi 9 avril 2014, 08:57 par ybonnel

Moi j'aurais plutôt fait :

   public static <T> List<T> concatLists(List<T>... lists) {
       return Stream.of(lists).flatMap(List::stream).collect(Collectors.toList());
   }
2. Le mercredi 9 avril 2014, 11:06 par dgageot

Je suis d'accord avec Yan. flatMap() est une autre bonne façon de faire.

Ajouter un commentaire

Le code HTML est affiché comme du texte et les adresses web sont automatiquement transformées.