Opérations intermédiaires des streams Java
Transformez les streams Java paresseusement avec filter, map, flatMap, sorted, distinct, peek, limit et skip.
Une opération intermédiaire prend un stream et retourne un autre stream. Elle enregistre ce qui devrait arriver à chaque élément lorsque le pipeline s'exécutera ; elle n'exécute rien par elle-même. Vous les enchaînez ; la chaîne reste froide jusqu'à ce qu'un terminal tire le premier élément. Cette paresse est ce qui fait qu'un pipeline de 30 lignes coûte moins que ses parties, ce qui rend les sources infinies gérables, et ce qui fait que le choix de l'opération est davantage une question de clarté que d'éviter du travail — les intermédiaires adjacents fusionnent en un seul passage.
Ce chapitre est une visite de chaque intermédiaire que vous écrirez. Chaque entrée a la même forme : ce qu'elle fait, quel est le type de son callback, si elle est sans état ou avec état, et les un ou deux pièges qui déterminent si le pipeline est correct.
filter — garder ce qui correspond
Supprime les éléments qui échouent à un Predicate<T> :
List<Integer> evens = nums.stream()
.filter(n -> n % 2 == 0)
.toList();Sans état, paresseux, préserve l'ordre. Le prédicat doit être sans effets de bord — s'il mute quoi que ce soit de visible, les pipelines parallèles vous surprendront et même les séquentiels deviennent difficiles à lire.
filter ne change pas le type d'élément. Pour à la fois garder un sous-ensemble et changer le type, utilisez filter puis map, ou mapMulti (Java 16+) pour le cas rare où une entrée devient zéro ou une sortie de type différent.
map — transformer chaque élément
Applique une Function<T, R> à chaque élément, produisant un stream de R :
List<Integer> lengths = words.stream()
.map(String::length)
.toList();Sans état, paresseux, préserve l'ordre, un pour un. Utilisez les spécialisations primitives lorsque le résultat est numérique :
mapToInt,mapToLong,mapToDouble→ stream primitif (pas de boxing,sum()disponible).mapToObjsur un stream primitif → retour àStream<R>.
int totalLength = words.stream().mapToInt(String::length).sum();flatMap — remplacer chaque élément par un stream d'autres éléments
Une Function<T, Stream<R>> qui « décompresse » chaque élément en plusieurs sorties (ou aucune, ou une) :
List<List<String>> grouped = List.of(List.of("a", "b"), List.of("c"));
List<String> flat = grouped.stream()
.flatMap(List::stream)
.toList(); // [a, b, c]Le modèle mental : « chaque élément devient un sous-stream, et flatMap les concatène. » C'est ainsi que vous passez d'un stream de conteneurs (Stream<List<T>>) à un stream de contenus (Stream<T>), comment vous développez chaque texte en ses mots, et comment vous transformez un stream de Optional<T> en un stream de valeurs présentes (via Optional::stream).
Il existe aussi des spécialisations primitives — flatMapToInt, flatMapToLong, flatMapToDouble — pour une expansion vers un stream primitif.
Une confusion courante : map(s -> s.split(" ")) donne Stream<String[]> — un stream de tableaux, pas un stream plat de mots. Pour aplatir, flatMap(s -> Arrays.stream(s.split(" "))).
mapMulti — pousser zéro, un ou plusieurs éléments par entrée
mapMulti (Java 16+) est un flatMap plus efficace pour les cas où chaque entrée produit un petit nombre variable de sorties et où construire un Stream par élément est excessif :
people.stream()
.<String>mapMulti((p, downstream) -> {
if (p.age() >= 18) downstream.accept(p.name());
if (p.email() != null) downstream.accept(p.email());
})
.forEach(System.out::println);Utilisez flatMap lorsque vous avez naturellement un stream/une liste à émettre ; utilisez mapMulti lorsque vous construiriez sinon un tiny stream d'un ou deux éléments par entrée juste pour satisfaire la signature de flatMap.
distinct — supprimer les doublons
Supprime les éléments égaux en utilisant equals / hashCode :
List<String> unique = words.stream().distinct().toList();Avec état — pour savoir si un élément est un doublon, distinct doit mémoriser ceux qu'il a déjà émis. Sur un stream ordonné, il garde la première occurrence. Sur un stream non ordonné, la JVM peut être plus intelligente pour le travail parallèle. Sur un stream infini, vous ne voulez presque jamais distinct sans un limit en amont.
sorted — ordonner les éléments
Deux formes — ordre naturel et un Comparator<T> :
List<String> az = words.stream().sorted().toList();
List<String> byLen = words.stream().sorted(Comparator.comparingInt(String::length)).toList();Avec état et bloquant au terminal : sorted doit mettre en mémoire tampon chaque élément avant de pouvoir en émettre un. Cela en fait l'intermédiaire le plus coûteux, à utiliser délibérément. Le placer avant un limit(n) ne sauvegarde pas de travail — la JVM doit quand même voir chaque entrée pour savoir quels n conserver. (Pour un pipeline « top N », préférez une PriorityQueue bornée ou Collectors.toList() puis subList après un sorted, selon N par rapport au total.)
De plus : n'appelez pas sorted sur un stream d'une source infinie — il ne retourne jamais.
peek — observer sans modifier
Un Consumer<T> qui se déclenche pour chaque élément tiré. Retourne le stream inchangé :
words.stream()
.peek(s -> System.out.println("seen: " + s))
.filter(s -> s.length() > 3)
.toList();Pour le débogage uniquement. peek s'exécute paresseusement et exactement une fois par élément tiré, donc c'est une fenêtre utile sur la paresse et le court-circuit :
Stream.iterate(1, n -> n + 1)
.peek(n -> System.out.println("considered " + n))
.filter(n -> n > 100)
.findFirst(); // pulls 1..101 -- peek fires 101 times, then stopsNe mettez pas de logique réelle dans un peek. La JVM est autorisée à fusionner, réordonner ou ignorer les appels peek dans certaines conditions sur des streams non modifiés, et sur des streams parallèles l'ordre est indéfini.
limit(n) — garder au plus n éléments
Arrête le pipeline après que n éléments l'ont traversé :
List<Integer> firstFive = Stream.iterate(1, i -> i + 1).limit(5).toList();Avec état (il compte) et court-circuitant (en aval s'arrête une fois que n est atteint). Sur un stream ordonné, il garde les premiers n. Sur un stream parallèle non ordonné, il garde quelconques n — l'ordre n'est pas garanti, et un limit parallèle sur un stream ordonné paie pour l'ordonnancement. Si vous ne vous souciez pas des n obtenus, stream.unordered().limit(n) est plus rapide en parallèle.
Le schéma standard pour dompter toute source infinie : chaque Stream.iterate / Stream.generate se termine soit par un limit, soit par un iterate à 3 arguments borné, soit par un terminal court-circuitant comme findFirst.
skip(n) — ignorer les premiers n
Le complément de limit. Ignore les premiers n éléments, puis émet le reste :
List<Integer> rest = nums.stream().skip(2).toList(); // drops nums[0], nums[1]Avec état (il compte à rebours). Sur un stream ordonné, le sens est exact ; sur un stream parallèle ordonné, il paie un coût d'ordonnancement. Avec limit, il donne un accès « paginé » :
list.stream().skip(page * pageSize).limit(pageSize).toList();Cela fonctionne, mais pour un grand skip sur une List, c'est toujours O(skip + limit). Un list.subList(...) direct est moins coûteux si vous avez la List sous la main.
takeWhile / dropWhile — fenêtrage basé sur un préfixe
Deux intermédiaires court-circuitants (Java 9+) qui agissent sur un préfixe du stream :
// take elements while predicate holds, stop at the first miss
List<Integer> small = Stream.of(1, 2, 3, 10, 4, 5)
.takeWhile(n -> n < 5)
.toList(); // [1, 2, 3]
// drop elements while predicate holds, then emit the rest
List<Integer> rest = Stream.of(1, 2, 3, 10, 4, 5)
.dropWhile(n -> n < 5)
.toList(); // [10, 4, 5]Ce ne sont pas des filter. filter teste chaque élément. takeWhile s'arrête au premier échec (y compris ceux qui passeraient filter plus tard). Sur un stream trié, ils permettent d'exprimer « tout jusqu'au seuil » à moindre coût.
boxed / asLongStream / asDoubleStream — naviguer entre les mondes primitifs
Les streams primitifs ont quelques intermédiaires propres pour revenir dans le monde des objets :
IntStream.range(0, 5).boxed().toList(); // Stream<Integer> [0, 1, 2, 3, 4]
IntStream.range(0, 3).asLongStream().sum(); // 0L + 1L + 2L
IntStream.range(0, 3).asDoubleStream().average();boxed est le pont du primitif vers Stream<Integer/Long/Double>. L'inverse est mapToInt/mapToLong/mapToDouble.
Sans état vs. avec état — pourquoi cela compte
| Sans état | Avec état |
|---|---|
filter | distinct |
map / mapToX | sorted |
flatMap / mapMulti | limit |
peek | skip |
boxed / asLongStream / asDoubleStream | takeWhile / dropWhile |
Les intermédiaires avec état doivent se souvenir de quelque chose entre les éléments. sorted doit tout mettre en mémoire tampon. distinct doit se souvenir de chaque élément émis. limit et skip ont besoin d'un compteur. Cela les rend plus coûteux (surtout en parallèle) et vaut la peine d'être utilisé délibérément.
L'ordre compte — fusionner, filtrer tôt, transformer tard
Parce que les intermédiaires adjacents fusionnent en un seul passage élément par élément, l'ordre dans lequel vous les écrivez détermine la quantité de travail effectuée par le pipeline :
// Good: filter first, then the expensive map runs only on survivors.
people.stream()
.filter(p -> p.age() >= 18)
.map(this::expensiveLookup)
.toList();
// Bad: every element pays for the map, then most are thrown away.
people.stream()
.map(this::expensiveLookup)
.filter(r -> r.score() > 0.5)
.toList();La règle générale : filtrer tôt, transformer tard, trier une fois, distinct une fois. La JVM ne réordonne pas vos intermédiaires — c'est vous qui le faites.
Un exemple pratique : le vocabulaire complet en un seul pipeline
Le programme ci-dessous construit un stream à partir d'une petite liste, parcourt chaque opération que nous avons couverte, affiche le résultat de chacune, et prouve la paresse/court-circuit avec peek plus un iterate infini.
Ce qu'il faut retenir de l'exécution :
filteretmapsont les chevaux de bataille ; les autres intermédiaires un pour un (mapToInt,mapToObj,boxed) sont les conversions de monnaie bon marché entre les streams d'objets et primitifs.flatMapetmapMultipermettent à une entrée de devenir plusieurs sorties. La formeStream.of("a b") -> Arrays.stream(split(...))est le schéma canonique de « tokenisation » ;mapMultiest le choix moins coûteux lorsque vous construiriez sinon un tiny stream par élément.distinctetsortedsont avec état —distinctdevait mémoriser chaquePersonprécédemment émise pour supprimer le doublon « Alice », etsorteddevait mettre toute l'entrée en mémoire tampon. C'est pourquoi les deux sont placés délibérément, généralement une fois, et généralement tard.peeks'est déclenché une fois par élément tiré sur l'iterateinfini — il y avait exactement autant de lignes « considered N » que d'éléments quefindFirstdevait examiner. Sans court-circuit, ce pipeline ne se terminerait jamais.- Les deux blocs de comptage de
lookupà la fin ont rendu la règle d'ordre concrète. Filtrer d'abord a exécuté la transformation coûteuse sur bien moins d'éléments que mapper d'abord. Ce compromis est le vôtre à définir.
Et ensuite
Les intermédiaires enregistrent la forme du travail ; rien ne s'exécute jusqu'à ce qu'un terminal tire. Le prochain chapitre, Opérations terminales des streams Java, est le vocabulaire complet des terminaux — forEach, count, min/max, findFirst/findAny, anyMatch/allMatch/noneMatch, reduce, toArray, toList, et la passerelle vers le chapitre suivant — collect.