Introduzione agli Stream Java
Un'introduzione alla Java Stream API per elaborare sequenze di elementi con operazioni in stile funzionale.
Uno stream è una pipeline che trasporta gli elementi di una sorgente attraverso una sequenza di operazioni e produce un risultato. Non è una struttura dati — non memorizza nulla. È una ricetta dichiarativa per elaborare dati, valutata in modo lazy ed eseguita una sola volta. Gli Stream sono arrivati in Java 8 insieme alle lambda, e i due sono stati progettati per integrarsi: ogni operazione sugli stream accetta una funzione, e il linguaggio ti ha fornito un modo pulito per scriverne una.
La forma che scriverai centinaia di volte:
double avgAdultAge = people.stream()
.filter(p -> p.age() >= 18)
.mapToInt(Person::age)
.average()
.orElse(0.0);Tre cose da notare. La pipeline si legge dall'alto verso il basso come passaggi che descrivono cosa vuoi, non come iterare. Ogni passaggio accetta una funzione — un Predicate, un ToIntFunction — esattamente il vocabolario impostato dai capitoli precedenti. E il risultato esce da una singola operazione terminale; non c'è ciclo, nessun accumulatore, nessun continue anticipato.
La forma della pipeline: sorgente → intermedia → terminale
Ogni pipeline di stream ha tre parti:
- Una sorgente. Da dove provengono gli elementi. Di solito una collection (
coll.stream()), a volte un letterale (Stream.of(\"a\", \"b\")), un array (Arrays.stream(arr)), un intervalloIntStream(IntStream.range(0, 100)), una sorgente I/O (Files.lines(path)), o un generatore (Stream.iterate,Stream.generate). Il prossimo capitolo è dedicato a tutte queste sorgenti. - Zero o più operazioni intermedie. Ciascuna restituisce un altro stream, quindi si concatenano. Quelle comuni:
filter,map,flatMap,distinct,sorted,limit,skip,peek. Sono lazy — chiamarefilternon verifica ancora nulla; registra semplicemente il predicato. - Esattamente un'operazione terminale. Attiva la pipeline. Esempi:
forEach,collect,toList,count,sum,min,max,reduce,findFirst,anyMatch. Il terminale produce un valore (o un effetto collaterale perforEach) e consuma lo stream — non puoi riutilizzarlo.
list.stream() // SOURCE
.filter(...) // intermediate
.map(...) // intermediate
.sorted() // intermediate
.toList(); // TERMINAL — runs the pipelineSenza il terminale, non accade nulla. Uno stream che costruisci e non termini mai è peso morto — nessun lavoro viene svolto, nessun effetto collaterale si attiva, le lambda non vengono eseguite.
Lazy per design
Le operazioni intermedie sono lazy perché la JVM non sa quali elementi ti servono davvero finché il terminale non li richiede. Questo abilita due importanti ottimizzazioni:
Fusion. Le operazioni intermedie adiacenti vengono eseguite insieme in un unico passaggio, non un passaggio per operazione. stream.filter(p).map(f) non costruisce una lista filtrata intermedia e poi la mappa; testa un elemento, e se supera il test, lo mappa, tutto in un unico passaggio.
Short-circuiting. Un terminale come findFirst, anyMatch, o limit(n) ferma la pipeline non appena ha la sua risposta. Combinato con la laziness, questo significa che puoi eseguire una pipeline "trova il primo quadrato pari maggiore di 100" su uno stream infinito e ottenere una risposta in microsecondi:
int answer = Stream.iterate(1, n -> n + 1) // 1, 2, 3, 4, ...
.map(n -> n * n) // 1, 4, 9, 16, ...
.filter(n -> n % 2 == 0 && n > 100) // first match wins
.findFirst()
.orElseThrow();
// answer = 144Stream.iterate(1, n -> n + 1) è infinito, ma findFirst ha richiesto elementi solo finché uno corrispondeva. La pipeline ha testato 12 quadrati (1, 4, 9, ..., 144) e si è fermata.
Uso singolo, come un Iterator
Un Stream può essere attraversato una volta sola. Il terminale lo consuma, e dopo di ciò l'oggetto stream è chiuso; chiamare un altro terminale su di esso lancia IllegalStateException:
Stream<String> s = list.stream();
long c1 = s.count(); // ok
long c2 = s.count(); // throws IllegalStateException — stream has already been operated uponSe hai bisogno di elaborare gli stessi dati due volte, costruisci lo stream due volte:
long c1 = list.stream().count();
long c2 = list.stream().count();Questo corrisponde al modo in cui funziona Iterator. L'oggetto stream è il cursore in movimento, non i dati. I dati sono la sorgente — ricrearlo è gratuito.
Stream vs collection — lavori diversi
| Aspetto | Collection | Stream |
|---|---|---|
| Memorizza dati? | Sì | No |
| Riutilizzabile? | Sì | No (un solo terminale) |
| Eager o lazy? | Eager | Lazy fino al terminale |
| Modifica la sorgente? | Sì (es. list.add) | No — le pipeline sono in sola lettura |
| Itera esplicitamente? | Spesso (for, iterator()) | No — la pipeline guida l'iterazione |
| Modello di costo | Bookkeeping per elemento | Un passaggio attraverso la sorgente |
Una collection è un contenitore; uno stream è un calcolo su un contenitore (o un'altra sorgente). Si completano a vicenda: recuperi da una collection, esegui una pipeline di stream, e raccogli di nuovo in una collection (di solito diversa).
Tre piccoli esempi che scriverai sempre
Contare gli elementi che corrispondono a un predicato:
long adults = people.stream().filter(p -> p.age() >= 18).count();Costruire una lista di valori trasformati:
List<String> names = people.stream().map(Person::name).toList();Ridurre a un singolo valore:
int totalAge = people.stream().mapToInt(Person::age).sum();Questi tre pattern — count, map-to-list, reduce-to-scalar — coprono la maggior parte degli usi dell'API. Il resto della parte è un tour delle operazioni che riempiono il come per ciascuno.
Tre cose che gli stream non sono
- Non un sostituto dei cicli
forin generale. Un ciclo che costruisce qualcosa con un flusso di controllo non banale, che ha bisogno dibreakcon effetti collaterali, o che muta diverse variabili, è ancora più chiaro come ciclo. Gli stream brillano quando il lavoro è una pipeline di operazioni pure. - Non un vantaggio prestazionale su dati piccoli. Una pipeline di stream alloca alcuni piccoli oggetti; un ciclo su 10 elementi la supererà. I vantaggi arrivano dalla chiarezza su qualsiasi dato e dal parallelismo su dati grandi.
- Non un sostituto di
Iterator/Iterablequando altro codice li richiede. Uno stream produce valori; se hai bisogno di intervallare il consumo (unformigliorato, unaListrestituita da un metodo), usa primatoList().
Sequenziale per default, parallelo su richiesta
Ogni stream che scriverai in questo capitolo è sequenziale — gli elementi fluiscono attraverso la pipeline uno alla volta, in ordine. Esiste anche coll.parallelStream() (e stream.parallel()) che pianifica la pipeline attraverso il ForkJoinPool comune per il lavoro multi-core. Gli stream paralleli sono trattati in un capitolo successivo — fanno alcune assunzioni sulla pipeline (deve essere associativa, stateless, priva di effetti collaterali) che le pipeline "intro" di questo capitolo soddisfano naturalmente, quindi l'aggiornamento è di solito una modifica di un singolo token.
Un esempio completo: una pipeline completa, laziness e la regola dell'uso singolo
Il programma seguente costruisce un piccolo elenco di record Person, esegue la forma canonica della pipeline (filter → map → sorted → collect), dimostra la laziness con peek, dimostra il short-circuiting su uno Stream.iterate infinito, e mostra l'IllegalStateException che si ottiene riutilizzando uno stream.
Cosa ricavare dall'esecuzione:
- La pipeline canonica a quattro passi —
stream→filter→map→toList— ha prodotto un elenco ordinato di nomi adulti senza cicli espliciti, nessuna collection temporanea e nessuna gestione della nullabilità. peekha stampato una volta per ogni elemento estratto.findFirstha estratto elementi finché uno soddisfacevan*n > 50(che accade an = 8, quadrato64) e poi si è fermato. Questo è la laziness e lo short-circuiting che lavorano insieme: le operazioni upstream hanno fatto esattamente il lavoro necessario e niente di più.- La pipeline "primo quadrato pari oltre 100" ha operato su una sorgente infinita. Senza short-circuiting sarebbe un ciclo infinito; con esso la pipeline ha testato 12 valori e prodotto
144. - Il secondo
s.count()ha lanciatoIllegalStateException. Gli stream sono a uso singolo; se hai bisogno di un secondo passaggio, costruisci un nuovo stream dalla sorgente. - La pipeline "senza terminale" alla fine ha stampato nulla dall'interno del suo
peek. Senza un terminale, le operazioni intermedie non vengono eseguite — lo stream è solo una ricetta che nessuno ha chiesto di eseguire.
Cosa c'è dopo
Conosci la forma della pipeline, la divisione sorgente/intermedia/terminale, il contratto di laziness e la regola dell'uso singolo. Il prossimo capitolo, Creare Stream Java, è il catalogo delle sorgenti — Collection.stream(), Stream.of, Arrays.stream, IntStream.range, Stream.iterate, Stream.generate, Files.lines, String.chars(), Stream.empty, e l'API Stream.Builder. Con il capitolo sulle sorgenti completato avrai tutto ciò che ti serve per iniziare, e il resto della parte riempirà le operazioni intermedie e terminali.