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 :
- une expression simple ;
- 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.
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é.