Operazioni Terminali degli Stream Java
Attiva la valutazione degli stream Java con operazioni terminali: collect, forEach, reduce, count, min, max, anyMatch.
Un'operazione terminale è ciò che fa eseguire effettivamente una pipeline di stream. Le operazioni intermedie (filter, map, sorted, …) registrano solo il lavoro e rimangono lazy; una terminale tira gli elementi attraverso la pipeline, valuta l'intera catena e produce un risultato (o un effetto collaterale). Ogni pipeline termina con esattamente un'operazione terminale — invocala, e lo stream viene consumato; richiama un'altra terminale sullo stesso stream e otterrai IllegalStateException.
Questo capitolo copre ogni terminale che scriverai, quando ognuno cortocircuita, e i casi limite dello stream vuoto che silenziosamente mettono in difficoltà le persone.
Le terminali si presentano in tre forme. Gli aggregatori restituiscono un singolo valore (count, sum, min, max, reduce). I cercatori cercano un elemento e si fermano (findFirst, findAny, anyMatch, allMatch, noneMatch). I costruttori materializzano lo stream in un contenitore (toList, toArray, collect, forEach per gli effetti collaterali). Questo capitolo percorre ogni terminale che scriverai al di fuori di collect, che è abbastanza ampio da richiedere un capitolo a sé nel prossimo.
forEach / forEachOrdered — effetti collaterali
La terminale più semplice. Esegue un Consumer<T> per ogni elemento, non restituisce nulla:
names.stream().forEach(System.out::println);L'ordine non è garantito — su uno stream sequenziale di solito lo è; su uno stream parallelo non lo è. Se hai bisogno dell'ordine della sorgente anche in parallelo, usa forEachOrdered:
names.parallelStream().forEachOrdered(System.out::println);forEach è per gli effetti collaterali che vuoi genuinamente — logging, mutare un sink, chiamare un'API non-stream. Non è il modo corretto per costruire una collezione (quello è toList / collect) o accumulare un valore (quello è reduce). Un forEach che muta una lista esterna è un code smell anche quando funziona, perché rinuncia a tutto ciò che rendeva la pipeline dichiarativa in primo luogo.
count — quanti elementi
Restituisce un long:
long adults = people.stream().filter(p -> p.age() >= 18).count();count cortocircuita su sorgenti di dimensione nota dove la JVM può calcolare la risposta dalla dimensione della sorgente (quindi IntStream.range(0, 1_000_000).count() restituisce 1000000 senza iterare). Su uno stream con un filter o flatMap attivo, deve percorrere ogni elemento.
Un trabocchetto comune: stream.count() su una catena .peek(...) potrebbe non eseguire il peek se la JVM può dedurre il conteggio dalla sorgente, perché non c'è differenza di comportamento osservabile. Non usare peek per "vedere quanti sono stati filtrati" — usa mapToInt(x -> 1).sum() o ristruttura.
min / max — elementi estremi
Entrambi prendono un Comparator<T> e restituiscono Optional<T> (perché lo stream potrebbe essere vuoto):
Optional<Person> oldest = people.stream().max(Comparator.comparingInt(Person::age));
Optional<String> shortest = words.stream().min(Comparator.comparingInt(String::length));Le specializzazioni primitive sono più semplici — IntStream.max() restituisce OptionalInt, nessun comparator necessario:
OptionalInt highest = nums.stream().mapToInt(Integer::intValue).max();
int hi = highest.orElse(Integer.MIN_VALUE);min/max cortocircuitano solo su sorgenti delimitate. Su uno stream infinito, max non termina mai.
findFirst / findAny — ottieni un elemento
Entrambi restituiscono Optional<T>, entrambi cortocircuitano. La differenza sta in ciò che promettono su quale elemento ottieni:
Optional<Person> first = people.stream().filter(p -> p.age() >= 30).findFirst();
Optional<Person> any = people.stream().filter(p -> p.age() >= 30).findAny();findFirstrestituisce il primo elemento nell'ordine di incontro. Su uno stream sequenziale è letteralmente il primo. Su uno stream parallelo costa di più rispetto afindAnyperché la JVM deve coordinarsi.findAnyrestituisce qualche elemento corrispondente — il primo che trova qualsiasi worker. In parallelo è più economico. In sequenziale, entrambi restituiscono la stessa cosa.
Usa findAny quando quale corrispondenza ottieni non ha davvero importanza (è un controllo di esistenza singolo che necessita del valore, non solo un boolean). Usa findFirst quando intendi "il primo".
anyMatch / allMatch / noneMatch — quantificatori di esistenza
Prendono un Predicate<T> e restituiscono boolean. Tutti e tre cortocircuitano:
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);anyMatchsi ferma non appena uno passa.allMatchsi ferma non appena uno fallisce.noneMatchè!anyMatch(p)— si ferma al primo passaggio e restituiscefalse.
Semantica dello stream vuoto (la regola che inganna tutti almeno una volta): anyMatch su un insieme vuoto è false. allMatch e noneMatch su un insieme vuoto sono entrambi true — per vuota verità, perché non ci sono controprove. Può essere esattamente ciò che vuoi o esattamente ciò che non vuoi, a seconda della domanda. Se "vuoto" è una possibilità che vale la pena gestire, controlla prima isEmpty (o count() == 0).
reduce — riduzione a un singolo valore
L'aggregatore più generale. Tre overload, ciascuno per una forma leggermente diversa:
reduce(identity, accumulator) a due argomenti — riduzione con un valore iniziale, restituisce T (nessun Optional, perché l'identità è la risposta per uno stream vuoto):
int sum = nums.stream().reduce(0, Integer::sum);
String all = words.stream().reduce(\"\", String::concat);reduce(accumulator) a un argomento — nessuna identità; restituisce Optional<T> per il caso dello stream vuoto:
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) a tre argomenti — usato quando l'accumulatore produce un tipo diverso dagli elementi (e richiesto in parallelo). Il combiner unisce due risultati parziali:
int totalLength = words.stream()
.reduce(0,
(acc, w) -> acc + w.length(), // BiFunction<Integer, String, Integer>
Integer::sum); // BinaryOperator<Integer>Tre regole per reduce che impediscono alla pipeline di andare silenziosamente storta:
- L'accumulatore deve essere associativo:
f(f(a, b), c) == f(a, f(b, c)). Somme e concatenazione di stringhe soddisfano questo; la sottrazione no. - L'identità deve essere una vera identità:
f(id, x) == xper tutti glix.0per+,1per*,\"\"perconcat. - L'accumulatore e il combiner devono essere senza stato e privi di effetti collaterali.
Viola una qualsiasi di queste e una pipeline sequenziale darà comunque la risposta corretta la maggior parte delle volte — una parallela ti sorprenderà. (Questo è lo stesso contratto su cui si basano Collectors.reducing e reduce parallelo.)
sum / average — aggregatori primitivi
Solo su stream primitivi. sum restituisce il primitivo; average restituisce un OptionalDouble:
int total = IntStream.rangeClosed(1, 100).sum();
OptionalDouble avg = nums.stream().mapToInt(Integer::intValue).average();
double mean = avg.orElse(0.0);Per riepiloghi numerici più ricchi — count, sum, min, max, average in un solo passaggio — vedi IntSummaryStatistics:
IntSummaryStatistics stats = nums.stream().mapToInt(Integer::intValue).summaryStatistics();
System.out.println(stats); // {count=N, sum=..., min=..., average=..., max=...}Questo è un passaggio solo, un'allocazione, e molto più economico che calcolarli separatamente.
toArray e toList — materializzazione
Due terminali abbreviate "dammi tutto":
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+) è il modo moderno per materializzare uno stream in una List ed è la scelta giusta nel 95% dei casi. È non modificabile e può contenere null; se hai bisogno di una lista mutabile, di un'implementazione specifica, o di un Set/Map, ricadi su collect(Collectors.toCollection(ArrayList::new)) o i suoi affini nel prossimo capitolo.
toArray(T[]::new) è l'unico modo per ottenere un array tipizzato da uno stream di oggetti — la forma IntFunction<T[]> fornisce al runtime il tipo componente dell'array.
iterator e spliterator — vie d'uscita
Uno stream può essere trasformato in un Iterator<T> o Spliterator<T> per passarlo a codice che ne aspetta uno:
for (Iterator<String> it = stream.iterator(); it.hasNext(); ) {
use(it.next());
}Entrambi sono terminali — consumano lo stream. Esistono per l'interoperabilità, non per "voglio un ciclo for"; se vuoi un ciclo, usane uno senza creare prima uno stream.
Cortocircuito vs. consumo — la tabella di sicurezza
| Terminale | Cortocircuita su sorgente infinita? |
|---|---|
findFirst / findAny | sì |
anyMatch / allMatch / noneMatch | sì |
limit(n) (intermedia) poi qualsiasi cosa | sì |
forEach / forEachOrdered | no — consuma tutto |
count | no — consuma tutto |
min / max | no — consuma tutto |
reduce | no — consuma tutto |
sum / average / summaryStatistics | no — consuma tutto |
toList / toArray / collect | no — consuma tutto |
Il pattern è chiaro: qualsiasi terminale che ha bisogno di considerare ogni elemento per produrre la sua risposta non cortocircuita, e abbinarla a una sorgente infinita senza un limit a monte blocca la JVM. Cercatori e quantificatori sono le uniche terminali "sicure su fonti infinite".
Un esempio concreto: ogni forma di terminale su una pipeline
Il programma seguente costruisce un piccolo stream, richiama ogni terminale che abbiamo trattato e mostra le risposte per stream vuoto per i tre matcher e per min / findFirst / reduce.
Cosa trarre dall'esecuzione:
- Le terminali di "ricerca" —
findFirst,findAny,anyMatch,allMatch,noneMatch— e le terminali di "consumo totale" —count,min/max,reduce,sum,toList— dividono il capitolo in modo netto. Le terminali di ricerca cortocircuitano; quelle di consumo totale no. Abbina il secondo gruppo a una sorgente infinita solo dietro unlimit. allMatchsu uno stream vuoto ha restituitotrue. Lo ha fatto anchenoneMatch. Questa è la verità vacua — è la risposta standard, ed è il motivo più comune per cui il codice di produzione "supera erroneamente" un caso limite con input vuoto. Se il vuoto è significativo, controllalo prima.- I tre overload di
reducecoprono tre pattern. A due argomenti con una vera identità restituisceT. A un argomento restituisceOptional<T>perché non c'è identità. A tre argomenti consente al tipo dell'accumulatore di differire dal tipo degli elementi — ed è la forma che è effettivamente sicura in parallelo, perché il combiner dice alla JVM come unire i risultati parziali. summaryStatistics()ha fatto in un passaggio ciò che chiamaremin,max,sum,averageecountseparatamente avrebbe fatto in cinque. Su qualsiasi stream numerico non banale, preferiscilo.toList()ha restituito una lista non modificabile. Questo è il valore predefinito di Java 16+ e quasi sempre quello che vuoi; il prossimo capitolo mostra la formaCollectors.toCollection(...)quando hai bisogno di una lista mutabile, di un'implementazione specifica, o di unSet/Map.
Cosa c'è dopo
collect è l'unica terminale che abbiamo rimandato — e il gateway verso metà dell'API. Il prossimo capitolo, Java Stream Collectors, percorre la cassetta degli strumenti Collectors: toList/toSet/toMap, groupingBy, partitioningBy, joining, counting, summingInt, averagingDouble, mapping, reducing, e il pattern downstream che li compone.