Opérations terminales des Streams Java
Déclenchez l'évaluation d'un stream Java avec les opérations terminales : collect, forEach, reduce, count, min, max, anyMatch.
Une opération terminale est ce qui fait réellement s'exécuter un pipeline de stream. Les opérations intermédiaires (filter, map, sorted, …) ne font qu'enregistrer le travail et restent paresseuses ; une opération terminale tire les éléments au travers du pipeline, évalue l'ensemble et produit un résultat (ou un effet de bord). Chaque pipeline se termine par exactement une opération terminale — appelez-la, et le stream est consommé ; appelez une autre opération terminale sur le même stream et vous obtiendrez une IllegalStateException.
Ce chapitre couvre toutes les opérations terminales que vous utiliserez, les cas où chacune court-circuite, et les cas limites avec des streams vides qui font trébucher les gens discrètement.
Les opérations terminales se présentent sous trois formes. Les agrégateurs renvoient une valeur unique (count, sum, min, max, reduce). Les chercheurs recherchent un élément et s'arrêtent (findFirst, findAny, anyMatch, allMatch, noneMatch). Les constructeurs matérialisent le stream dans un conteneur (toList, toArray, collect, forEach pour les effets de bord). Ce chapitre parcourt toutes les opérations terminales que vous utiliserez en dehors de collect, qui est suffisamment importante pour nécessiter son propre chapitre.
forEach / forEachOrdered — effets de bord
L'opération terminale la plus simple. Exécute un Consumer<T> pour chaque élément, ne retourne rien :
names.stream().forEach(System.out::println);L'ordre n'est pas garanti — sur un stream séquentiel, il l'est généralement ; sur un stream parallèle, il ne l'est pas. Si vous avez besoin de l'ordre source même en parallèle, utilisez forEachOrdered :
names.parallelStream().forEachOrdered(System.out::println);forEach est réservé aux effets de bord que vous voulez réellement — journalisation, mutation d'un récepteur, appel d'une API non-stream. Ce n'est pas la bonne façon de construire une collection (c'est toList / collect) ou d'accumuler une valeur (c'est reduce). Un forEach qui mute une liste externe est un code smell même lorsqu'il fonctionne, car il abandonne tout ce qui rendait le pipeline déclaratif.
count — nombre d'éléments
Retourne un long :
long adults = people.stream().filter(p -> p.age() >= 18).count();count court-circuite sur les sources de taille connue où la JVM peut calculer la réponse à partir de la taille de la source (ainsi IntStream.range(0, 1_000_000).count() retourne 1000000 sans itérer). Sur un stream avec un filter ou flatMap actif, il doit parcourir chaque élément.
Un piège courant : stream.count() sur une chaîne .peek(...) peut ne pas exécuter le peek si la JVM peut prouver le count à partir de la source, car il n'y a pas de différence de comportement observable. N'utilisez pas peek pour "voir combien ont été filtrés" — utilisez mapToInt(x -> 1).sum() ou restructurez.
min / max — éléments extrêmes
Les deux prennent un Comparator<T> et retournent Optional<T> (parce que le stream peut être vide) :
Optional<Person> oldest = people.stream().max(Comparator.comparingInt(Person::age));
Optional<String> shortest = words.stream().min(Comparator.comparingInt(String::length));Les spécialisations primitives sont plus simples — IntStream.max() retourne OptionalInt, sans comparateur :
OptionalInt highest = nums.stream().mapToInt(Integer::intValue).max();
int hi = highest.orElse(Integer.MIN_VALUE);min/max ne court-circuitent que sur les sources bornées. Sur un stream infini, max ne termine jamais.
findFirst / findAny — obtenir un élément
Les deux retournent Optional<T> et court-circuitent. La différence porte sur quel élément ils promettent de retourner :
Optional<Person> first = people.stream().filter(p -> p.age() >= 30).findFirst();
Optional<Person> any = people.stream().filter(p -> p.age() >= 30).findAny();findFirstretourne le premier élément dans l'ordre de rencontre. Sur un stream séquentiel, c'est littéralement le premier. Sur un stream parallèle, cela coûte plus cher quefindAnycar la JVM doit se coordonner.findAnyretourne un élément correspondant — le premier que trouve n'importe quel worker. En parallèle, c'est moins coûteux. En séquentiel, les deux retournent la même chose.
Utilisez findAny quand quel résultat vous obtenez n'a vraiment pas d'importance (c'est une simple vérification d'existence qui nécessite la valeur, pas seulement un boolean). Utilisez findFirst quand vous voulez dire "le premier".
anyMatch / allMatch / noneMatch — quantificateurs d'existence
Prennent un Predicate<T> et retournent boolean. Les trois court-circuitent :
boolean hasAdult = people.stream().anyMatch(p -> p.age() >= 18);
boolean allAdult = people.stream().allMatch(p -> p.age() >= 18);
boolean noChildren = people.stream().noneMatch(p -> p.age() < 13);anyMatchs'arrête dès qu'un élément passe.allMatchs'arrête dès qu'un élément échoue.noneMatchest!anyMatch(p)— s'arrête au premier succès et retournefalse.
Sémantique des streams vides (la règle qui piège tout le monde une fois) : anyMatch sur un stream vide est false. allMatch et noneMatch sur un stream vide sont tous les deux true — par vacuité, car il n'y a pas de contre-exemples. Cela peut être exactement ce que vous voulez ou exactement ce que vous ne voulez pas, selon la question. Si "vide" est une possibilité à traiter, vérifiez d'abord isEmpty (ou count() == 0).
reduce — réduire à une valeur unique
L'agrégateur le plus général. Trois surcharges, chacune pour une forme légèrement différente :
reduce(identity, accumulator) à deux arguments — plie avec une valeur de départ, retourne T (pas d'Optional, car l'identité est la réponse pour un stream vide) :
int sum = nums.stream().reduce(0, Integer::sum);
String all = words.stream().reduce(\"\", String::concat);reduce(accumulator) à un argument — pas d'identité ; retourne Optional<T> pour le cas du stream vide :
Optional<Integer> sum = nums.stream().reduce(Integer::sum);
Optional<String> longest = words.stream()
.reduce((a, b) -> a.length() >= b.length() ? a : b);reduce(identity, accumulator, combiner) à trois arguments — utilisé quand l'accumulateur produit un type différent des éléments (et requis en parallèle). Le combiner fusionne deux résultats partiels :
int totalLength = words.stream()
.reduce(0,
(acc, w) -> acc + w.length(), // BiFunction<Integer, String, Integer>
Integer::sum); // BinaryOperator<Integer>Trois règles pour reduce qui empêchent le pipeline de donner des résultats erronés :
- L'accumulateur doit être associatif :
f(f(a, b), c) == f(a, f(b, c)). Les sommes et la concaténation de chaînes satisfont cela ; la soustraction ne le fait pas. - L'identité doit être une vraie identité :
f(id, x) == xpour toutx.0pour+,1pour*,\"\"pourconcat. - L'accumulateur et le combiner doivent être sans état et sans effets de bord.
Violez l'une de ces règles et un pipeline séquentiel donnera généralement la bonne réponse — un pipeline parallèle vous surprendra. (C'est le même contrat sur lequel s'appuient Collectors.reducing et le reduce parallèle.)
sum / average — agrégateurs primitifs
Uniquement sur les streams primitifs. sum retourne le primitif ; average retourne un OptionalDouble :
int total = IntStream.rangeClosed(1, 100).sum();
OptionalDouble avg = nums.stream().mapToInt(Integer::intValue).average();
double mean = avg.orElse(0.0);Pour des résumés numériques plus riches — count, sum, min, max, average en un seul passage — voir IntSummaryStatistics :
IntSummaryStatistics stats = nums.stream().mapToInt(Integer::intValue).summaryStatistics();
System.out.println(stats); // {count=N, sum=..., min=..., average=..., max=...}C'est un seul passage, une seule allocation, et bien moins coûteux que de les calculer séparément.
toArray et toList — matérialiser
Deux opérations terminales raccourcies "donnez-moi tout" :
Object[] anyArr = stream.toArray(); // Object[]
String[] strArr = stream.toArray(String[]::new); // typed via constructor ref
List<String> immutable = stream.toList(); // Java 16+, unmodifiablestream.toList() (Java 16+) est la façon moderne de matérialiser un stream en List et constitue le bon choix dans 95% des cas. Elle est non modifiable et peut contenir des null ; si vous avez besoin d'une liste mutable, d'une implémentation spécifique, ou d'un Set/Map, utilisez collect(Collectors.toCollection(ArrayList::new)) ou ses équivalents dans le prochain chapitre.
toArray(T[]::new) est le seul moyen d'obtenir un tableau typé à partir d'un stream d'objets — la forme IntFunction<T[]> donne au runtime le type de composant du tableau.
iterator et spliterator — portes de sortie
Un stream peut être converti en Iterator<T> ou Spliterator<T> pour être transmis à du code qui en attend un :
for (Iterator<String> it = stream.iterator(); it.hasNext(); ) {
use(it.next());
}Ce sont toutes deux des opérations terminales — elles consomment le stream. Elles existent pour l'interopérabilité, pas pour "je veux une boucle for" ; si vous voulez une boucle, utilisez-en une sans créer de stream au préalable.
Court-circuitage vs consommation — table de sécurité
| Opération terminale | Court-circuite sur source infinie ? |
|---|---|
findFirst / findAny | oui |
anyMatch / allMatch / noneMatch | oui |
limit(n) (intermédiaire) puis quoi que ce soit | oui |
forEach / forEachOrdered | non — consomme tout |
count | non — consomme tout |
min / max | non — consomme tout |
reduce | non — consomme tout |
sum / average / summaryStatistics | non — consomme tout |
toList / toArray / collect | non — consomme tout |
Le schéma est clair : toute opération terminale qui doit considérer chaque élément pour produire sa réponse ne court-circuite pas, et l'associer à une source infinie sans un limit en amont bloque la JVM. Les chercheurs et les quantificateurs sont les seules opérations terminales "sûres sur les sources infinies".
Exemple concret : toutes les formes d'opérations terminales sur un même pipeline
Le programme ci-dessous construit un petit stream, appelle chaque opération terminale que nous avons couverte, et montre les réponses pour un stream vide avec les trois matchers ainsi qu'avec min / findFirst / reduce.
Ce qu'il faut retenir de l'exécution :
- Les opérations terminales de "recherche" —
findFirst,findAny,anyMatch,allMatch,noneMatch— et les opérations terminales de "consommation totale" —count,min/max,reduce,sum,toList— divisent clairement le chapitre. Les opérations de recherche court-circuitent ; les opérations de consommation totale ne le font pas. N'associez le second groupe à une source infinie qu'après unlimit. allMatchsur un stream vide a retournétrue. Tout commenoneMatch. C'est la vérité vacuouse — c'est la réponse standard, et c'est la raison la plus courante pour laquelle le code de production "passe incorrectement" un cas limite avec une entrée vide. Si vide est significatif, vérifiez-le d'abord.- Les trois surcharges de
reducecouvrent trois schémas. Deux arguments avec une vraie identité retournentT. Un argument retourneOptional<T>car il n'y a pas d'identité. Trois arguments permettent au type de l'accumulateur de différer du type de l'élément — et c'est la forme qui est réellement sûre en parallèle, car le combiner indique à la JVM comment fusionner les résultats partiels. summaryStatistics()a accompli en un seul passage ce qu'auraient fait séparémentmin,max,sum,averageetcounten cinq. Sur tout stream numérique non trivial, préférez-le.toList()a retourné une liste non modifiable. C'est le comportement par défaut de Java 16+ et c'est presque toujours ce que vous souhaitez ; le prochain chapitre présente la formeCollectors.toCollection(...)pour les cas où vous avez besoin d'une liste mutable, d'une implémentation spécifique, ou d'unSet/Map.
Et ensuite
collect est la seule opération terminale que nous avons différée — et la porte d'entrée vers la moitié de l'API. Le prochain chapitre, Java Stream Collectors, parcourt la boîte à outils Collectors : toList/toSet/toMap, groupingBy, partitioningBy, joining, counting, summingInt, averagingDouble, mapping, reducing, et le schéma downstream qui les compose.