Lambdas et streams¶
Les lambdas sont les closures de Java, et les streams sont ses itérateurs : filter, map, collect... Tu retrouveras beaucoup de Rust ici, avec quelques différences de fond. Spring en utilise partout, dès qu'il faut transformer une liste d'entités en liste de DTO.
Dans ce chapitre
- Les interfaces fonctionnelles (
Function,Supplier,Predicate...) : le « type » d'une lambda - La syntaxe des lambdas et la capture effectively final, comparée aux closures Rust
- Les références de méthodes (
String::length,this::m,Point::new) - Les streams : paresseux et à usage unique, comme les itérateurs Rust
collectet lesCollectors(toMap,groupingBy,joining...)- Quand une boucle
forreste le meilleur choix
Les interfaces fonctionnelles¶
Java n'a pas de type fonction comme fn(i32) -> i32 en Rust. Une lambda est toujours l'implémentation d'une interface qui n'a qu'une seule méthode abstraite, appelée interface fonctionnelle. Le paquet java.util.function fournit les plus courantes :
| Interface | Méthode | Forme | Équivalent Rust |
|---|---|---|---|
Function<T, R> |
apply |
T → R |
Fn(T) -> R |
BiFunction<T, U, R> |
apply |
(T, U) → R |
Fn(T, U) -> R |
Supplier<T> |
get |
() → T |
Fn() -> T |
Consumer<T> |
accept |
T → void |
Fn(T) |
Predicate<T> |
test |
T → boolean |
Fn(&T) -> bool |
Runnable |
run |
() → void |
Fn() |
UnaryOperator<T> |
apply |
T → T |
Fn(T) -> T |
BinaryOperator<T> |
apply |
(T, T) → T |
Fn(T, T) -> T |
Function<String, Integer> longueur = s -> s.length();
BiFunction<Integer, Integer, Integer> somme = (a, b) -> a + b;
Supplier<List<String>> fabrique = () -> new ArrayList<>();
Consumer<String> afficher = s -> System.out.println("» " + s);
Predicate<String> estVide = s -> s.isEmpty();
Runnable tache = () -> System.out.println("je tourne");
longueur.apply("bonjour"); // 7 : on appelle la méthode, pas longueur("bonjour")
somme.apply(2, 3); // 5
afficher.accept("salut"); // affiche : » salut
estVide.negate().test(""); // false
tache.run(); // affiche : je tourne
Ces interfaces ont des méthodes de composition : f.andThen(g) (applique f puis g), f.compose(g) (l'inverse), p.negate(), p.and(q), p.or(q).
Il existe aussi des versions spécialisées pour les primitifs (IntPredicate, ToIntFunction<T>, IntUnaryOperator...) qui évitent le boxing vu dans Génériques et collections.
Tes propres interfaces fonctionnelles¶
N'importe quelle interface à une seule méthode abstraite convient (Comparator, Runnable, Callable...). L'annotation @FunctionalInterface est facultative, mais le compilateur vérifie alors qu'il n'y a bien qu'une méthode abstraite :
@FunctionalInterface
interface Operation {
int appliquer(int a, int b);
}
Operation mult = (a, b) -> a * b;
mult.appliquer(6, 7); // 42
La syntaxe des lambdas¶
x -> x * 2 // un paramètre : pas de parenthèses
(a, b) -> a + b // plusieurs paramètres
() -> 42 // aucun paramètre
(String s) -> s.length() // types explicites (rarement utile)
s -> { // corps en bloc : return obligatoire
String t = s.trim();
return t.length();
}
C'est |x| x * 2 en Rust et lambda x: x * 2 en Python, mais sans la limite d'une seule expression de Python.
La capture : effectively final¶
Une lambda peut lire les variables locales qui l'entourent, à condition qu'elles ne soient jamais réaffectées (effectively final : final de fait, même sans le mot-clé).
int seuil = 4;
mots.removeIf(m -> m.length() <= seuil); // ok : seuil ne change pas
int total = 0;
mots.forEach(m -> total += m.length());
// error: local variables referenced from a lambda expression
// must be final or effectively final
Java capture les variables locales par valeur (comme un move de copies), donc il interdit de les réaffecter pour éviter toute confusion. En revanche :
- les champs (
this.compteur) restent modifiables, car la lambda capturethis; - un objet référencé par une variable effectively final reste modifiable :
mots.forEach(m -> journal.add(m))compile. Ça marche, mais un effet de bord caché dans une lambda est rarement une bonne idée.
Le contournement à éviter
Tu croiseras peut-être int[] compteur = {0}; puis compteur[0]++ dans une lambda. Ça compile (le tableau ne change pas, son contenu si), mais c'est un signe qu'il faut plutôt une réduction (sum, count, reduce) ou une boucle.
Sous le capot
Une lambda n'est pas une classe anonyme : javac la compile en méthode privée de ta classe et l'instruction invokedynamic fabrique, à la première exécution, un petit objet qui implémente l'interface et appelle cette méthode.
Les références de méthodes¶
Quand une lambda ne fait qu'appeler une méthode existante, on peut écrire directement la référence avec :: :
| Forme | Exemple | Lambda équivalente |
|---|---|---|
| Méthode statique | Integer::parseInt |
s -> Integer.parseInt(s) |
| Méthode d'un objet précis | System.out::println, this::decorer |
x -> System.out.println(x) |
| Méthode d'instance du type | String::length |
s -> s.length() |
| Constructeur | Point::new, ArrayList::new |
(x, y) -> new Point(x, y) |
La troisième forme est la plus surprenante : l'objet sur lequel on appelle la méthode devient le premier paramètre. Ainsi String::startsWith peut servir de BiFunction<String, String, Boolean> : (s, prefixe) -> s.startsWith(prefixe).
record Point(int x, int y) {}
BiFunction<Integer, Integer, Point> creer = Point::new;
creer.apply(1, 2); // Point[x=1, y=2]
List<String> toutDecorer(List<String> l) {
return l.stream().map(this::decorer).toList(); // méthode de l'objet courant
}
Les streams : les itérateurs de Java¶
Un Stream<T> est une chaîne de traitements sur une suite d'éléments : une source (liste.stream()), des opérations intermédiaires (filter, map...), et une opération terminale (toList, count...) qui déclenche le calcul. Exactement le modèle des itérateurs Rust.
Paresseux et à usage unique¶
Comme un itérateur Rust, un stream ne fait rien tant qu'aucune opération terminale n'est appelée, et ne traite que les éléments nécessaires :
Stream<String> s = mots.stream()
.filter(m -> { System.out.println(" filtre " + m); return m.startsWith("r"); });
System.out.println("rien encore");
System.out.println(s.findFirst());
// affiche : rien encore
// filtre java
// filtre rust
// Optional[rust]
Et un stream se consomme : le réutiliser lève une exception (en Rust, c'est le compilateur qui t'arrête, car l'itérateur a été déplacé).
Stream<String> st = mots.stream();
st.count();
st.count(); // IllegalStateException: stream has already been operated upon or closed
Un stream ne modifie jamais sa source : il produit un nouveau résultat.
Les opérations courantes¶
| Java | Rôle | Rust |
|---|---|---|
filter(p) |
garde les éléments qui passent p |
filter |
map(f) |
transforme chaque élément | map |
flatMap(f) |
f renvoie un stream, on aplatit |
flat_map |
sorted() / sorted(cmp) |
trie (avec un Comparator) |
sort sur le Vec |
distinct() |
supprime les doublons (equals) |
dedup (après tri) |
limit(n) / skip(n) |
les n premiers / saute n |
take / skip |
toList() |
collecte en liste immuable | collect::<Vec<_>>() |
count(), min(cmp), max(cmp) |
compte, extrêmes | count, min_by, max_by |
anyMatch, allMatch, noneMatch |
tests booléens | any, all |
findFirst() |
premier élément, en Optional |
next() |
reduce(init, op) |
réduction | fold |
forEach(c) |
effet de bord sur chaque élément | for_each |
List<List<Integer>> imbriquee = List.of(List.of(1, 2), List.of(3), List.of());
imbriquee.stream().flatMap(List::stream).toList(); // [1, 2, 3]
Stream.of(1, 2, 3, 4).reduce(0, Integer::sum); // 10
mots.stream().anyMatch(m -> m.equals("go")); // true
Les streams de primitifs : IntStream¶
Pour éviter le boxing, il existe IntStream, LongStream et DoubleStream, avec des opérations numériques en plus :
IntStream.range(0, 5) // ≈ 0..5 en Rust, range(0, 5) en Python
IntStream.rangeClosed(1, 5).sum(); // 15 (≈ (1..=5).sum())
mots.stream().mapToInt(String::length).sum(); // 29
mots.stream().mapToInt(String::length).max(); // OptionalInt[6]
mots.stream().mapToInt(String::length).average(); // OptionalDouble[4.142857142857143]
IntStream.range(0, 5).boxed().toList(); // [0, 1, 2, 3, 4] : retour aux objets
collect et les Collectors¶
toList() couvre le cas simple. Pour tout le reste, collect prend un collector de la classe Collectors (on fait souvent import static java.util.stream.Collectors.*;).
record Commande(String client, String produit, int quantite, double prix) {
double total() { return quantite * prix; }
}
List<Commande> commandes = List.of(
new Commande("Ada", "clavier", 1, 80.0),
new Commande("Bob", "souris", 2, 25.0),
new Commande("Ada", "écran", 1, 200.0),
new Commande("Carl", "souris", 1, 25.0),
new Commande("Bob", "clavier", 1, 80.0));
// nombre de commandes par client
commandes.stream()
.collect(Collectors.groupingBy(Commande::client, Collectors.counting()));
// {Bob=2, Carl=1, Ada=2} (HashMap : ordre quelconque)
// chiffre d'affaires par client, trié par client
commandes.stream()
.collect(Collectors.groupingBy(Commande::client, TreeMap::new, // (1)!
Collectors.summingDouble(Commande::total)));
// {Ada=280.0, Bob=130.0, Carl=25.0}
// produits par client
commandes.stream()
.collect(Collectors.groupingBy(Commande::client, TreeMap::new,
Collectors.mapping(Commande::produit, Collectors.toList())));
// {Ada=[clavier, écran], Bob=[souris, clavier], Carl=[souris]}
// concaténer
commandes.stream().map(Commande::client).distinct().sorted()
.collect(Collectors.joining(", ", "[", "]")); // "[Ada, Bob, Carl]"
// produit -> prix, en gardant le premier en cas de doublon
commandes.stream()
.collect(Collectors.toMap(Commande::produit, Commande::prix, (a, b) -> a));
- Le 2ᵉ argument choisit le type de
Mapproduite. Sans lui, c'est uneHashMap.
Piège : toMap et les clés en double
Sans 3ᵉ argument pour fusionner, toMap lève une exception dès qu'une clé revient :
Piège : toList() est immuable
stream.toList() (Java 16+) renvoie une liste immuable : add lève UnsupportedOperationException. L'ancien collect(Collectors.toList()) renvoie une liste modifiable (en pratique une ArrayList). Dans du code récent, préfère toList() et copie dans une ArrayList si tu dois modifier.
L'équivalent du groupingBy + counting dans les autres langages :
Quand préférer une boucle¶
Les streams ne remplacent pas toutes les boucles. Reste sur un for quand :
- le corps lève une exception checked.
chemins.stream().map(p -> Files.readString(p))ne compile pas (unreported exception IOException) :Function.applyne déclare aucune exception. Il faudrait untry/catchdans la lambda, et la boucle est plus lisible ; - tu mets à jour plusieurs variables à la fois, ou tu sors de la boucle sur une condition compliquée (
break,return) ; - tu as besoin de l'index ;
- la chaîne devient illisible : trois
collectimbriqués avec desMap.Entrypartout sont pires qu'une boucle de six lignes.
Et n'utilise forEach que pour un effet de bord final (afficher, envoyer) : remplir une liste externe dans un forEach est un collect mal écrit.
Pour aller plus loin
parallelStream()répartit le travail sur plusieurs threads. Ce n'est rentable que pour de gros volumes de calcul pur, et dangereux si les lambdas ont des effets de bord (voir Threads et concurrence).- Java 24 a ajouté les gatherers pour les opérations intermédiaires sur mesure :
Stream.of(1, 2, 3, 4, 5).gather(Gatherers.windowFixed(2)).toList()donne[[1, 2], [3, 4], [5]], commechunks(2)en Rust.
Exercices¶
1. À partir de List.of("Spring", "java", "Rust", "jvm", "Python", "go", "JAVA"), produis la chaîne des mots d'au moins 4 lettres, en minuscules, sans doublon, triés par longueur puis par ordre alphabétique, séparés par ", ".
Solution
String res = mots.stream()
.map(String::toLowerCase)
.filter(m -> m.length() >= 4)
.distinct()
.sorted(Comparator.comparingInt(String::length)
.thenComparing(Comparator.naturalOrder()))
.collect(Collectors.joining(", "));
System.out.println(res); // affiche : java, rust, python, spring
map avant distinct : sinon "java" et "JAVA" ne sont pas des doublons.
2. Calcule la somme des carrés des nombres pairs de 1 à 100, sans boxing.
Solution
3. Avec la liste commandes du chapitre, calcule le chiffre d'affaires par produit (trié par produit), puis le nom du client qui a le plus dépensé.
Solution
Map<String, Double> caParProduit = commandes.stream()
.collect(Collectors.groupingBy(Commande::produit, TreeMap::new,
Collectors.summingDouble(Commande::total)));
System.out.println(caParProduit);
// affiche : {clavier=160.0, souris=75.0, écran=200.0}
String meilleur = commandes.stream()
.collect(Collectors.groupingBy(Commande::client,
Collectors.summingDouble(Commande::total)))
.entrySet().stream() // on repart des paires (client, total)
.max(Map.Entry.comparingByValue())
.map(Map.Entry::getKey)
.orElse("personne");
System.out.println(meilleur); // affiche : Ada
écran est après souris : TreeMap compare les String caractère par caractère, et é vient après toutes les lettres ASCII.
4. Réécris cette boucle avec un stream.
Map<Integer, List<String>> parLongueur = new TreeMap<>();
for (String m : liste) {
if (!m.isBlank()) {
parLongueur.computeIfAbsent(m.length(), k -> new ArrayList<>())
.add(m.toLowerCase());
}
}
Solution
Map<Integer, List<String>> parLongueur = liste.stream()
.filter(m -> !m.isBlank())
.collect(Collectors.groupingBy(String::length, TreeMap::new,
Collectors.mapping(String::toLowerCase, Collectors.toList())));
// avec liste = List.of("Java", " ", "Rust", "Go", "C", "Kotlin", "") :
// {1=[c], 2=[go], 4=[java, rust], 6=[kotlin]}
Les deux versions se valent ici : choisis celle que tu relis le plus facilement.
À retenir¶
- Une lambda implémente une interface fonctionnelle (une seule méthode abstraite) :
Function,Supplier,Consumer,Predicate,Runnable... - On l'appelle par sa méthode (
f.apply(x),s.get(),p.test(x)), pas avecf(x). - Les variables locales capturées doivent être effectively final : pas d'équivalent de
FnMutsur une variable locale. Classe::methoderemplace une lambda qui ne fait qu'appeler une méthode ;Classe::newpour un constructeur.- Un stream est paresseux et à usage unique, comme un itérateur Rust ; rien ne se passe sans opération terminale.
toList()est immuable ;toMapplante sur une clé en double sans fonction de fusion.groupingBy+counting/summingX/mappingremplace la plupart des boucles d'agrégation.- Exception checked, plusieurs accumulateurs,
break: garde une bouclefor.