Fiche 9 : Spécificités de Java 8 : lambdas et streams

Vos enseignants de Java sont des personnes résolument modernes. Ils connaissent et maîtrisent le pouvoir de Java 8 (sorti en mars 2014 tout de même). L'objectif de cette fiche est de présenter deux aspects importants de Java 8 : les lambdas (λ) et les streams.

Java et programmation fonctionnelle

L'une des caractéristiques de la programmation fonctionnelle est que les fonctions sont des citoyennes de première classe. Il est ainsi possible d'affecter une variable avec une fonction, de passer une fonction en paramètre d'une autre fonction, de créer des fonctions d'ordre supérieur, d'utiliser un type fonctionnel comme type de retour d'une fonction. Jusqu'ici, il était difficile de le faire en Java. Nous allons voir comment l'introduction des λ avec la version 8 de Java nous simplifie la tâche. Vous allez voir, c'est accessible à n'importe quel codeur lambda...

NB : J'entends déjà les puristes me dire que la programmation fonctionnelle, c'est bien plus que ça (notamment sur la question des effets de bord) et que ce n'est pas avec quelques λ dans un coin que Java devient un langage fonctionnel. Effectivement, Java reste avant tout un langage orienté objet. Les λ apportent simplement un confort d'écriture permettant de simplifier et de clarifier certaines parties du code, rien de plus.

La minute culturelle

D'aucuns pourraient se demander d'où vient l'emploi du terme lambda pour désigner cette construction fonctionnelle. Comme vous le savez certainement si vous avez une once de culture informatique, le terme lambda n'est pas une référence à une danse très populaire vers la fin des années 1980, mais désigne bien la 11ème lettre de l'alphabet grec, par laquelle on dénote des fonctions anonymes en informatique (par exemple en OCaml ou en Python).

L'emploi de cette lettre remonte au développement du λ-calcul par Alonzo Church dans les années 1930. Church utilisait à la base la notation \hat{} x f(x) pour dénoter l'abstraction, ce qui a dérivé en \lambda x f(x).

Fin de la pause culturelle.

Introduction aux λ

Une première approche sans les λ

En introduction, nous avons rappelé qu'il était difficile de faire de la programmation fonctionnelle en Java sans les λ. Difficile mais pas impossible. En fait, vous avez déjà tout ce qu'il vous faut. Illustration (relisez la section consacrée aux classes anonymes si besoin) :

import java.util.Arrays;

interface FonctionAUnArgumentEntier {
    public int f(int x);
}

public class Test1 {
    public static void main(String[] args) {
        int[] tableau = {1, 2, 3, 4, 5};
        FonctionAUnArgumentEntier carre = new FonctionAUnArgumentEntier() {
            public int f(int x) {return x * x;}
        };
        FonctionAUnArgumentEntier cube = new FonctionAUnArgumentEntier() {
            public int f(int x) {return x * x * x;}
        };
        System.out.println(Arrays.toString(tableau));
        System.out.println(Arrays.toString(map(tableau, carre)));
        System.out.println(Arrays.toString(map(tableau, cube)));
    }

    public static int[] map(int[] tab, FonctionAUnArgumentEntier lambda) {
        int[] resultat = new int[tab.length];
        for (int i = 0; i < tab.length; i++) {
            resultat[i] = lambda.f(tab[i]);
        }
        return resultat;
    }
}

Comme vous l'avez compris, le code ci-dessus applique une fonction à un tableau d'entiers, à la manière dont la fonction map le fait dans n'importe quel langage de programmation un minimum fonctionnel (OCaml par exemple, ou encore Python). En l'occurrence, nous définissons ici deux fonctions entières, renvoyant respectivement le carré et le cube de leur argument.

Un premier λ

Tout ceci est très intéressant, mais Java est décidément un langage très verbeux. Est-il réellement utile à chaque fois de réécrire le code d'instanciation de la classe anonyme, ainsi que la déclaration de la signature de la fonction anonyme ? Pas vraiment en fait. Ça tombe bien, Java 8 nous fournit un raccourci d'écriture bien pratique. Plus précisément, le code suivant :

FonctionAUnArgumentEntier carre = new FonctionAUnArgumentEntier() {
    public int f(int x) {return x * x;}
};
FonctionAUnArgumentEntier cube = new FonctionAUnArgumentEntier() {
    public int f(int x) {return x * x * x;}
};

peut être remplacé par :

FonctionAUnArgumentEntier carre = x -> x * x;
FonctionAUnArgumentEntier cube = x -> x * x * x;

Plus compact, non ? Une telle expression est appelée expression λ (comme vous l'aviez déjà deviné).

Bien entendu, techniquement, il n'est pas nécessaire d'instancier une variable particulière avec une expression λ. On peut très bien utiliser une telle expression directement en tant qu'argument d'une fonction par exemple. L'exemple précédent pourrait donc devenir :

import java.util.Arrays;

interface FonctionAUnArgumentEntier {
    public int f(int x);
}

public class Test2 {
    public static void main(String[] args) {
        int[] tableau = {1, 2, 3, 4, 5};
        System.out.println(Arrays.toString(tableau));
        System.out.println(Arrays.toString(map(tableau, x -> x * x)));
        System.out.println(Arrays.toString(map(tableau, x -> x * x * x)));
    }

    public static int[] map(int[] tab, FonctionAUnArgumentEntier lambda) {
        int[] resultat = new int[tab.length];
        for (int i = 0; i < tab.length; i++) {
            resultat[i] = lambda.f(tab[i]);
        }
        return resultat;
    }
}

Syntaxe et expressions fonctionnelles

Les interfaces fonctionnelles

Dans l'exemple précédent, comment le compilateur Java fait-il pour deviner que l'expression x -> x * x est une implémentation concrète de la méthode abstraite public int f(int x) de l'interface FonctionAUnArgumentEntier ?

Il n'y a rien de magique. Ici, le compilateur peut le faire car il n'y a aucune ambiguïté : il n'existe qu'une seule méthode dans l'interface. C'est donc cette méthode qui sera utilisée. Une telle interface ne contenant qu'une seule méthode abstraite sera appelée interface fonctionnelle, et son unique méthode sera appelée méthode fonctionnelle.

Notons que le fait qu'il n'y ait qu'une seule méthode ne garantit pas la bonne utilisation d'une expression λ. Encore faut-il que la signature de la méthode (type de retour, type des arguments...) corresponde au reste du code. Dans l'exemple ci-dessus, l'expression lambda s'attend à ce que la méthode fonctionnelle correspondante renvoie un objet de même type numérique que le type de l'unique argument. C'est cohérent avec la déclaration public int f(int x). De même, ce type est forcément le type entier, comme en témoigne l'application de cette fonction dans la méthode map.

Afin de pouvoir faire vérifier au compilateur qu'une interface donnée est bien fonctionnelle, on peut utiliser l'annotation @FunctionalInterface. Jugez plutôt :

@FunctionalInterface
interface FonctionAUnArgumentEntier {
    public int f(int x);
}

Il existe dans la bibliothèque standard tout un tas d'interfaces fonctionnelles standard. Toutes ces interfaces sont dans le paquetage java.util.function, qui contient par exemple IntSupplier, IntConsumer, Function<T, R>... Jetez un œil à la documentation du paquetage pour avoir une idée de la liste de ces fonctions.

À noter... ou pas...

Si vous avez cliqué sur le même bouton dans la fiche Interfaces, vous savez donc que la version 8 de Java a modifié assez profondément la notion d'interface en ajoutant les notions de « méthodes statiques d'interface » et de « méthode par défaut ». Il faut donc préciser un peu plus la notion d'interface fonctionnelle pour tenir compte du fait qu'une interface puisse contenir des méthodes concrètes.

Retenez simplement, comme nous l'avons dit plus haut, qu'une interface fonctionnelle est simplement une interface ne contenant qu'une seule méthode abstraite.

Syntaxe des expressions λ

La syntaxe formelle d'une expression λ est la suivante :

"(" [arg1] [, arg2] ... ")" -> <expression> | <bloc instructions>

En partie gauche de l'expression λ, on trouve la liste des arguments (éventuellement aucun), séparés par des virgules, et entourés de parenthèses. Si l'expression ne prend qu'un seul, alors l'usage des parenthèses est facultatif.

La partie droite de l'expression λ peut être constituée de deux types d'éléments :

  1. une expression simple ;
  2. un bloc d'instructions (entouré, donc, d'accolades).

Si l'on utilise une expression simple, alors, c'est la valeur de cette expression appliquée sur les arguments qui constitue la valeur de retour de l'expression λ. À partir du moment où l'on utilise un bloc d'instructions pour spécifier le comportement concret de la méthode de calcul, alors l'utilisation d'instructions return est obligatoire pour spécifier la valeur à renvoyer.

Voici quelques exemples d'expressions λ valides :

DoubleSupplier lambda1 = () -> 2 * Math.PI;
BiFunction<Integer, Integer, Integer> lambda2 = (x, y) -> x + y;
Function<Integer, Integer> lambda3 = x -> {if (x < 0) {return -x;} return x;};

Deux applications des λ : forEach et comparateurs

La méthode forEach

Comme vous l'avez compris, les expressions λ apportent un peu de sucre syntaxique à la partie déjà existante du langage. Les concepteurs de Java ont donc naturellement tiré parti de ce sucre syntaxique pour introduire de nouvelles constructions qui sont censées simplifier la dure vie du codeur.

Premier élément que vous risquez de rencontrer : le forEach. Avant Java 8 (mais après Java 5), si vous vouliez parcourir un itérable pour afficher l'ensemble de ses éléments, vous pouviez procéder de la sorte :

for (elem: iterable) {
    System.out.println(elem);
}

Grâce aux λ, vous pouvez simplifier le parcours de la manière suivante :

iterable.forEach(elem -> System.out.println(elem));

Techniquement, la méthode forEach, de l'interface Iterable<T> prend en paramètre un argument de type java.util.function.Consumer<T>, qui est une interface fonctionnelle ne comportant qu'une seule méthode abstraite qui mange des éléments de type T et ne renvoie rien.

De nouveaux comparateurs

Nous avons introduit les comparateurs dans la section Files à priorité de la fiche sur les collections. Si si, rappelez-vous !

Bon, pour rappel, un comparateur est un objet d'une sous-classe réalisant l'interface Comparable<T>. Cette dernière interface ne possède qu'une seule méthode public int compare(T fst, T snd), dont l'objectif est de renvoyer un entier strictement positif si le premier argument est supérieur au second, négatif si le premier argument est strictement inférieur au second, et nul s'ils sont égaux.

Il s'agit donc d'une interface ne contenant qu'une seule méthode abstraite. Ça ne vous rappelle rien ? Si si, c'est exactement une interface fonctionnelle ! On peut donc spécifier un comparateur à l'aide d'une expression λ. Reprenons notre comparateur d'étudiants :

Comparator<Etudiant> comparator = (e1, e2) -> e1.getName().compareToIgnoreCase(e2.getName());

Encore plus court : au lieu de spécifier exactement l'opération de comparaison, on peut également simplement spécifier une clef de comparaison. Spécifier une clef de comparaison revient à définir une opération qui transforme chaque élément à comparer en un élément d'un type comparable. Lorsque l'on a deux éléments à comparer, la relation d'ordre qui est utilisée est l'ordre naturel sur les éléments transformés.

Pas clair ? Reprenons notre comparateur d'étudiant ci-dessus. Pour comparer deux étudiants, on compare simplement leurs noms. En d'autres termes, la comparaison d'étudiants se fait en utilisant leur nom comme clef de comparaison. En Java, on peut utiliser la méthode statique Comparator.comparing. Voici comment cela s'écrit :

Comparator<Etudiant> comparator = Comparator.comparing(e -> e.getName().toLowerCase());

Vous pouvez même facilement renverser l'ordre en utilisant la méthode reversed() de la classe Comparator. Voyez plutôt :

Comparator<Etudiant> comparator = Comparator.comparing(e -> e.getName().toLowerCase());
comparator = comparator.reversed();

Voilà, vous savez à peu près tout le principal sur les λ. Passons à un autre sujet.

Sweet streams are made of this

Si vous connaissez Python, vous savez qu'il est possible de travailler très efficacement sur des structures itérables grâce à l'utilisation des fonctions telles que map, filter, ou des aggrégateurs tels que sum ou max par exemple. Il est également extrêmement facile de créer de telles structures itérables grâce aux générateurs. Jusqu'ici, travailler sur ces flux de données était assez verbeux et fastidieux en Java. Ce n'est plus le cas grâce aux Streams. Voyons à quoi ça ressemble.

Techniquement, un Stream en Java est un objet qui représente une séquence finie ou infinie d'éléments générés séquentiellement ou parallèlement. Pour vous faire une représentation de ce genre d'objets, voici une image emprunte de poésie naïve. Imaginez un tuyau muni d'un bouton. Chaque fois que vous appuyez sur le bouton, un élément sort du tuyau, sauf s'il n'y a plus rien à faire sortir. Là où cela devient intéressant est que si vous êtes un peu plombier, vous pouvez brancher sur n'importe lequel de ces tuyaux un filtre qui transforme le flux d'entrée. Son rôle peut être par exemple de ne laisser passer qu'une partie des éléments, ou alors de transformer les éléments d'entrée, etc. Enfin, vous pouvez connecter sur n'importe quel Stream un aggrégateur, dont le rôle est de produire une valeur à partir du flux passé en entrée.

Voici maintenant quelques tuyaux sur les Streams.

Créer son Stream

Un Stream est un objet de la classe java.util.stream.Stream<T>. Il existe également quelques classes spécialisées dédiées à des types de Stream particuliers : IntStream, LongStream, et DoubleStream.

Un Stream peut être ordonné, auquel cas il renvoie les éléments dans un ordre prédéfini (déterministe) ou non. De même, un Stream peut être séquentiel ou parallèle. Dans un Stream parallèle, plusieurs éléments peuvent être générés et traités simultanément sur plusieurs processeurs. L'ordre de génération des éléments, dans le cas où le Stream est ordonné, n'est alors plus forcément respecté.

Pour paralléliser un Stream, il suffit d'invoquer la méthode parallel() sur n'importe quel Stream.

Il existe plusieurs manières de créer son propre Stream.

À partir d'une collection ou d'un tableau

On peut créer simplement un Stream à partir de n'importe quelle collection en invoquant la méthode stream() qui fait désormais partie de l'interface java.util.Collection. L'objet Stream renvoyé est séquentiel, et renvoie les éléments de manière ordonnée ou non selon le type de la collection concernée (pour une liste, ce sera ordonné ; pour un HashSet ça ne le sera pas).

On peut également créer un Stream à partir d'un tableau ou d'une liste de valeurs à l'aide de la méthode of de la classe Stream. Par exemple :

Stream<String> str = Stream.of("a", "b", "c", "d", "e");

Avec un générateur ou une fonction itérative

Comme nous l'avons dit en préambule de cette partie sur les Stream, l'une des constructions rendant le langage Python extrêmement puissant pour le traitement séquentiel des données est la possibilité d'écrire très rapidement des générateurs. C'est désormais possible en Java, grâce à la méthode generate de l'interface Stream. Cette méthode prend en paramètre un objet de type java.util.function.Supplier<T>. Cette interface fonctionnelle ne comporte qu'une méthode abstraite (par définition), qui ne prend aucun paramètre, et renvoie un élément de type T.

Voici par exemple une instruction qui crée un Stream qui génère des réels aléatoirement (et ne s'arrête jamais).

DoubleStream str = DoubleStream.generate(() -> Math.random());

Ce mécanisme reste tout de même assez limité. L'objet générateur ne prenant aucun paramètre, si l'on voulait créer un générateur plus réaliste, il faudrait pouvoir stocker l'état interne du générateur dans une variable quelconque que l'on modifie à la volée. Pas complètement simple : il faudrait créer un objet mutable qui est modifié à chaque appel de la fonction de génération. L'objectif des Stream étant de simplifier le code, ce n'est peut-être pas comme ça qu'on y arrivera.

Heureusement, il existe également une deuxième manière de créer son propre générateur : utiliser une fonction itérative. Techniquement, cela fonctionne de la même manière que précédemment, sauf que la fonction de génération n'est plus un simple Supplier, mais prend désormais en paramètre la valeur renvoyée précédemment. Cela permet ainsi de créer n'importe quel type de suite simple u_{n+1} = f(u_n). La méthode qui permet de créer de tels Stream s'appelle iterate.

Ainsi, par exemple, le code ci-dessous générera tous les termes de la suite de Syracuse (c'est donc un Stream infini), en commençant par le terme u_0 = 10 (correspondant au premier argument de la méthode iterate).

IntStream str = IntStream.iterate(10, (u) -> {if (u % 2 == 0) return u / 2; return 3 * u + 1;});

Bon, vous allez me dire qu'un flux infini n'est pas très utile dans l'absolu, et vous avez raison. Il serait plus raisonnable de s'arrêter à un moment donné. Il y a deux manières de le faire. Comme nous allons le voir plus bas (si vous lisez jusque là...), la méthode limit permet de ne renvoyer qu'un nombre prédéterminé d'éléments du Stream. En revanche, parfois, ce n'est pas suffisant. Ainsi, pour la suite de Syracuse, on pourrait vouloir renvoyer l'ensemble des termes, jusqu'à atteindre la valeur 1 (valeur à partir de laquelle on entre dans une suite infinie de cycles de taille 3). Ça tombe bien, une surcharge de la méthode iterate introduite dans Java 9 (décidément, nous sommes très modernes) permet de définir une condition d'arrêt. Jugez plutôt...

IntStream str = IntStream.iterate(
    10,
    (u) -> u != 1,
    (u) -> {if (u % 2 == 0) return u / 2; return 3 * u + 1;}
);

Encore un inconvénient : cette méthode ne permet de générer que des suites ne dépendant que du terme précédent. Cependant, cela couvre en pratique une bonne partie des besoins, et même dans le cas contraire, on arrive toujours à peu près à s'en tirer. Par exemple avec la suite de Fibonacci :

class Pair {
    public int x, y;
    public Pair(int x, int y) {
        this.x = x;
        this.y = y;
    }
}

public class Test {
    public static void main(String[] args) {
        Stream<Pair> str = Stream.iterate(new Pair(0, 1), (p) -> new Pair(p.y, p.x + p.y));
    }
}

Des intervalles d'entiers

Bien sûr, la méthode iterate permet de générer facilement des intervalles d'entiers. Mais il existe également un raccourci d'écriture pour cela, la méthode range. Exemple :

IntStream str = IntStream.range(10, 42); // Génère la séquence d'entiers entre 10 inclus et 42 exclu
IntStream str = IntStream.rangeClosed(10, 42); // Génère la séquence d'entiers entre 10 et 42 inclus

Lecture des fichiers

L'une des applications possibles des Stream est la lecture des fichiers. Le nouveau paquetage java.nio.file permet de le faire simplement, et surtout de lire les fichiers sous forme de Stream, ce qui est extrêmement pratique pour le traitement de données.

Voici par exemple un bout de code qui ouvre un fichier, le lit ligne par ligne et le transforme en Stream. Chaque ligne est lue au moment où l'on en a besoin (donc ici, en l'occurrence, au moment où on l'affiche).

import java.util.stream.*;
import java.nio.file.*;
import java.io.*;

public class FileStream {
    public static void main(String[] args) {
        String fileName = "./FileStream.java";
        try (Stream sf = Files.lines(Paths.get(fileName))){
            sf.forEach((l) -> System.out.println(l));
        } catch(IOException e) {
            e.printStackTrace();
        }
    }
}

Filtrer son Stream

Bon. Vous savez désormais comment créer des Streams. Mais l'intérêt est plutôt limité pour le moment. S'il s'agissait simplement de créer des éléments séquentiellement, une simple boucle aurait pu faire l'affaire. L'un des intérêts majeurs des Streams réside dans la notion de filtre. Comme nous l'avons expliqué en préambule, un filtre est simplement un transformateur de Stream. Il prend un Stream en entrée, et renvoie un Stream en sortie, qui va renvoyer séquentiellement des éléments transformés à partir du premier Stream. Voici quelques exemples emblématiques. Il en existe d'autres ; je vous invite donc à consulter la documentation de la classe Stream pour la liste exhaustive.

La méthode filter

La méthode filter de la classe Stream s'applique à un Stream et prend un argument un objet de type Predicate<T>. Ce dernier type est simplement une interface fonctionnelle qui ne comporte qu'une seule méthode abstraite prenant un élément de type T en paramètre et renvoyant un Booléen. Comme vous l'aurez compris, le rôle de cette méthode filter est de ne laisser passer que les éléments vérifiant le prédicat.

Par exemple, si vous ne voulez renvoyer que les éléments pairs de votre Stream :

str = str.filter((n) -> n % 2 == 0);

La méthode map

La méthode map applique simplement une transformation sur chaque élément du Stream d'entrée. Le Stream de sortie renvoie donc exactement le même nombre d'éléments, mais ces éléments sont transformés par la fonction passée en paramètre de map.

Par exemple, si vous voulez prendre le carré des éléments de votre Stream :

str = str.map((n) -> n * n);

La méthode limit

La méthode limit sert simplement à limiter le nombre d'éléments renvoyés. Techniquement, le Stream se tarira dès que n éléments auront été renvoyés (n étant l'entier passé en paramètre du filtre). Cette méthode est particulièrement utile pour des flux infinis, afin d'éviter de créer un programme qui ne se termine jamais.

Terminer son Stream

Tous ces Streams sont bien jolis, mais fort peu intéressants pour le moment. Le fait est que nous avons uniquement créé des objets permettant de générer des éléments à la volée, et de les filtrer éventuellement, mais jusqu'ici, nous n'avons rien fait de ces éléments. Techniquement, nous ne savons même pas comment les récupérer. C'est là qu'interviennent les terminateurs (éléments terminaux), qui, comme leur nom l'indique, n'ont rien à voir avec un robot aux muscles surdimensionnés devenu gouverneur de Californie. Vous allez comprendre.

image

Ci-dessus un terminateur qui n'a rien à voir avec le propos de cette fiche de synthèse. [Source: colinedwards99 CC BY 2.0, via Wikimedia Commons]

Les terminateurs sont des fonctions qui mangent des Stream en entrée (donc se raccordent à un tuyau) et en font quelque chose (produire une valeur agrégée, afficher les valeurs du Stream d'entrée...), mais sans recréer de Stream en sortie. On ne peut donc pas leur brancher de nouveaux filtres en sortie.

Nous allons ici présenter les terminateurs les plus courants. Encore une fois, nous vous invitons à consulter la documentation pour une vue plus exhaustive.

Le terminateur forEach

Nous avons déjà rencontré la méthode forEach un peu plus haut. Elle fonctionne sur des itérables, mais elle fonctionne également de la même manière sur des Streams. Si vous voulez par exemple afficher un Stream, rien de plus simple :

str.forEach(elem -> System.out.println(elem));

Le terminateur reduce

L'agrégation d'un certain nombre de valeurs en une seule est une opération extrêmement courante sur les collections d'éléments, les flux, les itérables, etc. Dans la terminologie fonctionnelle, cela s'appelle en général le repliage (opération fold). L'idée est simple : on utilise un objet d'un type quelconque, qui s'appelle l'accumulateur. Cet objet est initialisé à une certaine valeur. Ensuite, on parcourt le flux d'éléments, et à chaque élément rencontré, cet élément est ajouté dans l'accumulateur à l'aide d'une certaine fonction d'accumulation. Rien compris ? Nous allons voir quelques exemples. Dans le langage des Streams, le repliage se fait grâce à la méthode reduce.

La minute culturelle

Ceux qui ont déjà des connaissances dans le domaine des bases de données non relationnelles ont pu reconnaître dans la terminologie les deux opérations classiques d'un paradigme très connu : map-reduce. Concrètement, ce modèle de programmation parallèle est dédié au traitement de données extrêmement volumineuses, et s'appuie sur des nœuds de calcul de deux types. Devinez lesquels : les nœuds map qui effectuent des traitements individuels sur une liste d'éléments), et les nœuds reduce, dédiés à l'agrégation de valeurs.

Fin de la minute culturelle.

Voici un code qui calcule le produit des entiers contenus dans le Stream.

int produit = str.reduce(0, (x, y) -> x * y);

Dans ce code, le premier argument de la fonction spécifie la valeur initiale de l'accumulateur. Le second argument spécifie ce qu'il faut faire pour combiner la valeur actuelle de l'accumulateur avec l'élément rencontré dans le Stream.

Dans cet exemple, c'était simple. Le type de l'accumulateur était le même que le type des éléments rencontrés and le Stream (entier, en l'occurrence). Mais rien n'empêche d'utiliser un accumulateur d'un autre type. Regardez l'exemple suivant, qui prend en paramètre un flux de chaînes de caractères, et calcule la somme de tous les nombres valides rencontrés dans le flux.

int somme = str.reduce(0,
    (n, s) -> {
        try {
            int i = Integer.parseInt(s);
            return n + i;
        } catch (NumberFormatException e) {
            return n;
        }
    },
    (n1, n2) -> n1 + n2
);

Une petite explication s'impose. Comme tout-à-l'heure, le premier argument correspond à la valeur initiale de l'accumulateur. Le deuxième argument correspond à la fonction qui analyse le mot en cours, détermine s'il s'agit d'un entier valide, et ajoute la valeur de cet entier à la valeur courante de l'accumulateur. Quant au troisième argument, il s'agit d'une fonction qui combine la valeur de deux accumulateurs. Rappelez-vous : un flux peut être séquentiel, mais peut également être parallèle, auquel cas, il y a plusieurs accumulateurs calculés en parallèle. Cette dernière fonction sert à combiner ces différentes valeurs calculées en parallèle.

Les terminateurs numériques

On peut à peu près tout faire avec reduce pour ce qui est de l'agrégation de valeurs. Néanmoins, les sous-classes numériques de Stream (IntStream, DoubleStream,...) comportent certains aggrégateurs classiques : sum, min, max... Consultez la documentation pour une vue plus exhaustive !

Le terminateur collect

On peut potentiellement tout faire avec les accumulateurs. Cependant, tout n'est pas extrêmement simple... Prenons un exemple, vous allez voir.

J'ai à ma disposition un fichier dont j'aimerais bien compter le nombre de mots. Plus précisément, j'aimerais bien un programme qui me dresse une liste de mots, et m'indique, pour chaque mot, son nombre d'occurrences. Rien de bien compliqué a priori. Il suffit de créer une HashMap dont les clefs sont les mots, les valeurs sont les nombres d'occurrences. Il suffit ensuite de parcourir le fichier mot par mot, et pour chaque mot rencontré, d'augmenter son nombre d'occurrences dans la HashMap (et de l'ajouter s'il n'existe pas). Un accumulateur doit pouvoir faire ça.

import java.util.*;
import java.util.concurrent.*;
import java.util.stream.*;
import java.nio.file.*;
import java.io.*;

public class ReadFile {
    public static void main(String[] args) {
    String fileName = args[0];
    try (Stream<String> lines = Files.lines(Paths.get(fileName))){
        (
         lines
         .flatMap(line -> Stream.of(line.split("\\s+"))) // Ça c'est pour casser les lignes en mots
         .reduce(new ConcurrentHashMap<String, Integer>(), // On accumule tout dans une HashMap
             (h, s) -> {
                 if (h.containsKey(s)) {
                     h.put(s, h.get(s) + 1);
                 } else {
                     h.put(s, 1);
                 }
                 return h;
             },
             (h1, h2) -> {
                 for (String s: h1.keySet()) {
                     if (h2.containsKey(s)) {
                         h2.put(s, h1.get(s) + h2.get(s));
                     } else {
                         h2.put(s, h1.get(s));
                     }
                 }
                 return h2;
             }
             ) // Ici, on récupère une HashMap
         .entrySet().stream() // On retransforme en Stream (tant qu'à faire...)
         )
            .sorted((e1, e2) -> e1.getValue().compareTo(e2.getValue())) // On trie
            .forEach((w) -> System.out.println(w)); // On affiche
        } catch(IOException e) {
            e.printStackTrace();
        }
    }
}

Après, on n'ira pas me dire que les Streams ne vous simplifient pas la vie ! Bon, si on avait utilisé une simple boucle de parcours, on aurait pu diviser le nombre de lignes par 3 et écrire un code que tout le monde aurait compris. Pour info, le même code, en Python, aurait donné à peu près ça (bon, ne regardez pas dans les détails ; certaines parties sont assez moches) :

import sys, re
from collections import defaultdict
from functools import reduce

with open(sys.argv[1], 'r') as file:
    for word, count in sorted(
            reduce(lambda d, w: [d.__setitem__(w, d.__getitem__(w) + 1), d][1],
                   (word for line in file for word in re.split('\\s+', line)),
                   defaultdict(int)).items(),
            key=lambda e: e[1]):
        print(word, ": ", count)

Alors même si en théorie on peut tout faire avec la méthode reduce, cette méthode n'est pas adaptée pour tout. Dans toutes les situations pour lesquelles on veut créer une nouvelle collection à partir des éléments (éventuellement transformés) d'un Stream, utiliser une réduction n'est pas extrêmement adapté car cela nécessite des effets de bord peu compatibles avec l'utilisation d'une telle construction fonctionnelle. Dans toutes ces situations, il existe un terminateur adapté, la méthode collect.

La méthode collect prend en paramètre un objet de type java.util.stream.Collector. Cette méthode collect fait en gros la même chose qu'une réduction, mais accumule tout dans un objet mutable, qui est modifié au fur et à mesure du parcours de la Stream. Le Collector sert simplement à spécifier la manière dont cette accumulation est réalisée. Cette spécification se fait d'une manière très similaire aux arguments de la méthode reduce. Voici l'exemple ci-dessus réécrit :

import java.util.*;
import java.util.stream.*;
import java.nio.file.*;
import java.io.*;
import java.util.function.*;

class MonCompteurDeMots implements Collector<String, HashMap<String, Integer>, HashMap<String, Integer>> {
    @Override public Supplier<HashMap<String, Integer>> supplier() {
        return () -> new HashMap<String, Integer>();
    }

    @Override public BiConsumer<HashMap<String, Integer>, String> accumulator() {
        return (h, s) -> {
            if (h.containsKey(s)) {
                h.put(s, h.get(s) + 1);
            } else {
                h.put(s, 1);
            }
        };
    }

    @Override public BinaryOperator<HashMap<String, Integer>> combiner() {
        return (h1, h2) -> {
            for (String s: h1.keySet()) {
                if (h2.containsKey(s)) {
                    h2.put(s, h1.get(s) + h2.get(s));
                } else {
                    h2.put(s, h1.get(s));
                }
            }
            return h2;
        };
    }

    @Override public Function<HashMap<String, Integer>, HashMap<String, Integer>> finisher() {
        return h -> h;
    }

    @Override public Set<Collector.Characteristics> characteristics() {
        return new HashSet<Collector.Characteristics>();
    }
}

public class ReadFile3 {
    public static void main(String[] args) {
        String fileName = args[0];
        try (Stream<String> lines = Files.lines(Paths.get(fileName))){
            (
             lines
             .flatMap(line -> Stream.of(line.split("\\s+")))
             .parallel()
             .collect(new MonCompteurDeMots())
             .entrySet().stream()
             )
                .sorted((e1, e2) -> e1.getValue().compareTo(e2.getValue()))
                .forEach((w) -> System.out.println(w));
        } catch(IOException e) {
            e.printStackTrace();
        }
    }
}

Bon, c'est franchement plus simple, non ? Non ?

Non, pas vraiment, je vous l'accorde. En fait, ce qui va vraiment nous simplifier la tâche est que la bibliothèque standard Java contient déjà pas mal de Collectors. En fait, la plupart des Collectors dont vous pourriez avoir besoin. Regardons s'il y en a un qui contient ce que l'on cherche. Il y en a un, c'est : Collectors.groupingBy. Ce collecteur réalise un regroupement, à la manière d'un GROUP BY SQL. C'est exactement ce que l'on cherche : on veut effectuer un regroupement par mot. L'opérateur va donc nous créer un dictionnaire, dont les clefs sont les mots et les valeurs sont des listes. Il suffira de compter le nombre d'éléments dans chaque liste et le tour sera joué. Illustration ci-dessous.

import java.util.*;
import java.util.concurrent.*;
import java.util.stream.*;
import java.nio.file.*;
import java.io.*;

public class ReadFile2 {
    public static void main(String[] args) {
        String fileName = args[0];
        try (Stream lines = Files.lines(Paths.get(fileName))){
            (
             lines
             .flatMap(line -> Stream.of(line.split("\\s+")))
             .parallel()
             .collect(Collectors.groupingBy((w) -> w))
             .entrySet().stream().map(e -> new AbstractMap.SimpleEntry(e.getKey(), e.getValue().size()))
             )
            .sorted((e1, e2) -> e1.getValue().compareTo(e2.getValue()))
            .forEach((w) -> System.out.println(w));
        } catch(IOException e) {
            e.printStackTrace();
        }
    }
}

Il existe un grand nombre d'autres collecteurs. RTFJ (J = Javadoc) pour plus d'information sur ces collecteurs.

Le mot de la fin

Voilà, vous savez maintenant le principal sur les deux évolutions de Java 8 dont tout le monde parle. Si ces évolutions ne révolutionnent probablement pas le langage en lui-même, il est tout de même intéressant d'en avoir entendu parler. Vous trouverez de plus en plus de code Java qui intègre ces éléments, et si vous voulez être à même de le comprendre, ce sera plus simple d'en avoir entendu parler un peu avant.

Et même si personne dans votre entourage ne connaît les λ ou les Streams, vous pourrez toujours briller à la machine à café.

image