Aller au contenu

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
  • collect et les Collectors (toMap, groupingBy, joining...)
  • Quand une boucle for reste 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
// la lambda reçoit une COPIE de la valeur : la modifier n'aurait pas de sens
int total = mots.stream().mapToInt(String::length).sum();
// une closure FnMut emprunte total en &mut : le borrow checker le permet
let mut total = 0;
mots.iter().for_each(|m| total += m.len());

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 capture this ;
  • 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.

List<String> mots = List.of("java", "rust", "python", "go", "rust", "c++", "kotlin");

List<String> res = mots.stream()
    .filter(m -> m.length() > 2)
    .map(String::toUpperCase)
    .distinct()
    .sorted()
    .limit(3)
    .toList();
// [C++, JAVA, KOTLIN]
let mut res: Vec<String> = mots.iter()
    .filter(|m| m.len() > 2)
    .map(|m| m.to_uppercase())
    .collect();
res.sort();
res.dedup();
res.truncate(3);
res = sorted({m.upper() for m in mots if len(m) > 2})[:3]

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));
  1. Le 2ᵉ argument choisit le type de Map produite. Sans lui, c'est une HashMap.

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 :

IllegalStateException: Duplicate key souris (attempted merging values 25.0 and 25.0)

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 :

Map<Character, Long> parInitiale = mots.stream()
    .collect(Collectors.groupingBy(m -> m.charAt(0), Collectors.counting()));
let mut par_initiale: HashMap<char, usize> = HashMap::new();
for m in &mots {
    *par_initiale.entry(m.chars().next().unwrap()).or_insert(0) += 1;
}
from collections import Counter
par_initiale = Counter(m[0] for m in mots)

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.apply ne déclare aucune exception. Il faudrait un try/catch dans 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 collect imbriqués avec des Map.Entry partout 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]], comme chunks(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
int somme = IntStream.rangeClosed(1, 100)
    .filter(n -> n % 2 == 0)
    .map(n -> n * n)
    .sum();
System.out.println(somme);   // affiche : 171700

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 avec f(x).
  • Les variables locales capturées doivent être effectively final : pas d'équivalent de FnMut sur une variable locale.
  • Classe::methode remplace une lambda qui ne fait qu'appeler une méthode ; Classe::new pour 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 ; toMap plante sur une clé en double sans fonction de fusion.
  • groupingBy + counting / summingX / mapping remplace la plupart des boucles d'agrégation.
  • Exception checked, plusieurs accumulateurs, break : garde une boucle for.