Java Stream API
Nel mondo dello sviluppo Java, saper manipolare collezioni di dati in modo chiaro ed efficiente è una competenza fondamentale. Array e liste restano strumenti essenziali, ma quando le applicazioni richiedono filtri, trasformazioni, ordinamenti e aggregazioni, scrivere ogni operazione con cicli e variabili temporanee può rendere il codice più verboso del necessario. La Stream API, introdotta in Java 8, offre un approccio alternativo: descrivere le operazioni da eseguire sui dati attraverso una pipeline di elaborazione.
Gli stream non sono collezioni e non memorizzano autonomamente tutti gli elementi elaborati. Sono sequenze di elementi sulle quali è possibile applicare operazioni intermedie e terminali. Le prime, come filter(), map() e sorted(), definiscono le trasformazioni; le seconde, come collect(), count() e forEach(), avviano l'elaborazione e producono un risultato o un effetto.
Questo modello consente spesso di scrivere codice più leggibile, soprattutto quando le operazioni sui dati possono essere espresse in modo dichiarativo. Non significa che i cicli tradizionali siano diventati inutili, né che gli stream siano sempre più veloci: la scelta dipende dal problema, dalla complessità dell'operazione e dalle caratteristiche dei dati.
Vediamo come utilizzare la Stream API attraverso esempi concreti, partendo dai filtri più semplici fino ad arrivare al raggruppamento, all'appiattimento di collezioni annidate e all'elaborazione parallela.
Filtrare, trasformare e ordinare una collezione
Immaginiamo di avere una lista di utenti e di voler ottenere i nomi degli utenti attivi, convertiti in maiuscolo e ordinati alfabeticamente. Con uno stream possiamo concatenare le operazioni necessarie senza creare manualmente una nuova lista a ogni passaggio.
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
class User {
private final String name;
private final boolean active;
User(String name, boolean active) {
this.name = name;
this.active = active;
}
public String getName() {
return name;
}
public boolean isActive() {
return active;
}
}
public class StreamExample {
public static void main(String[] args) {
List<User> users = Arrays.asList(
new User("Alice", true),
new User("Bob", false),
new User("Charlie", true)
);
List<String> activeNames = users.stream()
.filter(User::isActive)
.map(User::getName)
.map(String::toUpperCase)
.sorted()
.collect(Collectors.toList());
System.out.println(activeNames);
}
}Il risultato è:
[ALICE, CHARLIE]Analizziamo la pipeline:
- stream() crea uno stream a partire dagli elementi della lista.
- filter(User::isActive) conserva soltanto gli utenti per i quali isActive() restituisce true.
- map(User::getName) trasforma ogni utente nel suo nome.
- map(String::toUpperCase) converte ogni nome in maiuscolo.
- sorted() ordina alfabeticamente gli elementi secondo l'ordinamento naturale delle stringhe.
- collect(Collectors.toList()) raccoglie i risultati in una nuova lista.
La pipeline descrive quindi il risultato desiderato senza specificare manualmente come iterare sulla lista e aggiungere ogni elemento alla raccolta finale.
È importante notare che filter() e map() non modificano direttamente la lista originale. Inoltre, gli stream non sono riutilizzabili dopo un'operazione terminale: per eseguire un'altra elaborazione, occorre creare un nuovo stream.
Calcolare aggregazioni con gli stream
La Stream API non serve soltanto a filtrare e trasformare oggetti. Permette anche di calcolare somme, conteggi, valori minimi e massimi attraverso operazioni terminali dedicate.
Supponiamo di voler sommare tutti i numeri pari presenti in una lista:
import java.util.Arrays;
import java.util.List;
public class SumExample {
public static void main(String[] args) {
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6);
int sumEven = numbers.stream()
.filter(n -> n % 2 == 0)
.mapToInt(Integer::intValue)
.sum();
System.out.println(sumEven);
}
}L'output è:
12L'operazione filter() elimina logicamente gli elementi dispari dalla sequenza elaborata. Successivamente, mapToInt() converte gli oggetti Integer in valori primitivi int, creando uno stream specializzato sul quale è disponibile il metodo sum().
Questa conversione è utile perché permette di utilizzare le operazioni numeriche di IntStream. Non garantisce però, da sola, un miglioramento misurabile delle prestazioni in ogni scenario: il vantaggio dipende dal contesto e dall'elaborazione complessiva.
Per contare gli utenti con un nome più lungo di tre caratteri possiamo invece utilizzare count():
long count = users.stream()
.filter(u -> u.getName().length() > 3)
.count();
System.out.println(count);Il risultato è 2, perché sia Alice sia Charlie soddisfano la condizione.
Il metodo count() restituisce un valore di tipo long, anche quando il numero di elementi è piccolo. Questa scelta permette di rappresentare conteggi più grandi del limite di un int.
Raggruppare gli elementi con Collectors
Quando si lavora con dati strutturati, spesso non basta filtrare gli elementi: occorre suddividerli in gruppi in base a una proprietà comune.
Supponiamo di voler separare gli utenti attivi da quelli inattivi. Il collector groupingBy() consente di effettuare questa operazione direttamente durante la raccolta dei risultati.
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
Map<Boolean, List<User>> grouped = users.stream()
.collect(Collectors.groupingBy(User::isActive));
grouped.forEach((active, list) -> {
String names = list.stream()
.map(User::getName)
.collect(Collectors.joining(", "));
System.out.println(active + ": " + names);
});L'output è equivalente a:
true: Alice, Charlie
false: BobL'ordine di stampa dei due gruppi non è garantito, perché il collector predefinito groupingBy() restituisce una mappa la cui implementazione non promette un ordine specifico delle chiavi.
Il collector groupingBy() è particolarmente versatile. Può essere combinato con altri collector per contare gli elementi di ogni gruppo, sommare valori numerici o effettuare aggregazioni su più livelli.
Per esempio, se vogliamo contare quanti utenti sono attivi e quanti inattivi, possiamo usare counting() come collector secondario:
Map<Boolean, Long> counts = users.stream()
.collect(Collectors.groupingBy(
User::isActive,
Collectors.counting()
));
counts.forEach((active, count) ->
System.out.println(active + ": " + count)
);Il risultato contiene un conteggio per ciascun valore booleano presente nei dati. Questo approccio diventa particolarmente utile quando si elaborano ordini, transazioni, prodotti o eventi e occorre aggregarli per categoria, stato o altra proprietà.
Appiattire collezioni annidate con flatMap()
Un altro caso comune consiste nel lavorare con strutture dati annidate, come una lista che contiene altre liste. Se vogliamo ottenere tutti i numeri in una singola sequenza, possiamo utilizzare flatMap().
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
List<List<Integer>> listOfLists = Arrays.asList(
Arrays.asList(1, 2),
Arrays.asList(3, 4),
Arrays.asList(5)
);
List<Integer> flat = listOfLists.stream()
.flatMap(List::stream)
.sorted()
.collect(Collectors.toList());
System.out.println(flat);Il risultato è:
[1, 2, 3, 4, 5]La differenza tra map() e flatMap() è importante. Se utilizziamo map(List::stream), otteniamo uno stream di stream, cioè una sequenza i cui elementi sono a loro volta stream. flatMap() trasforma ogni lista in uno stream e combina le sequenze risultanti in un'unica sequenza di elementi.
Questa operazione è utile, per esempio, quando ogni ordine contiene più prodotti e vogliamo ottenere un'unica sequenza di tutti i prodotti, oppure quando un insieme di documenti contiene più righe e vogliamo elaborarle come un flusso unico.
Operazioni intermedie e terminali: quando viene eseguito il codice
Una caratteristica centrale della Stream API è la valutazione differita, detta lazy evaluation. Le operazioni intermedie definiscono le trasformazioni da eseguire, ma normalmente non elaborano subito tutti gli elementi. L'elaborazione parte quando viene invocata un'operazione terminale.
Consideriamo questo esempio:
List<String> names = Arrays.asList("Alice", "Bob", "Charlie");
names.stream()
.filter(name -> {
System.out.println("Controllo: " + name);
return name.length() > 3;
})
.map(String::toUpperCase)
.forEach(System.out::println);Le operazioni vengono eseguite quando viene chiamato forEach(). Per ogni elemento, lo stream applica le trasformazioni necessarie prima di passare all'elemento successivo, salvo le differenze introdotte dalle specifiche delle singole operazioni.
Questa modalità consente alla Stream API di evitare alcuni passaggi intermedi non necessari e, in determinate situazioni, di interrompere l'elaborazione anticipatamente. Per esempio, findFirst() e anyMatch() possono terminare senza esaminare tutti gli elementi, quando il risultato è già determinabile.
Non tutte le operazioni, però, possono essere eseguite senza considerare l'intera sequenza. sorted(), per esempio, normalmente deve raccogliere gli elementi da ordinare prima di poter produrre il risultato ordinato.
È quindi più corretto considerare gli stream un modello di composizione delle operazioni che permette ottimizzazioni, non una garanzia universale di maggiore velocità.
Stream paralleli: quando convengono davvero
Java permette di elaborare uno stream in parallelo utilizzando parallelStream() oppure parallel(). La pipeline può così suddividere il lavoro tra più thread, utilizzando le risorse disponibili.
Riprendiamo l'esempio della somma dei numeri pari:
int sum = numbers.parallelStream()
.filter(n -> n % 2 == 0)
.mapToInt(Integer::intValue)
.sum();
System.out.println(sum);Per questa operazione, il risultato è ancora 12. La differenza è che Java può suddividere il lavoro e combinare i risultati parziali.
Il parallelismo non garantisce però prestazioni migliori. Per collezioni piccole, il costo di suddivisione del lavoro, coordinamento dei thread e combinazione dei risultati può superare i benefici. Anche operazioni che accedono a risorse condivise, effettuano chiamate di rete o dipendono dall'ordine degli elementi richiedono particolare attenzione.
Uno stream parallelo funziona meglio quando il lavoro è sufficientemente consistente, le operazioni sono indipendenti e la riduzione dei risultati può essere eseguita correttamente. Inoltre, le funzioni applicate agli elementi dovrebbero evitare effetti collaterali condivisi, come modificare una stessa lista da più thread.
La scelta tra stream sequenziali e paralleli dovrebbe quindi basarsi su misurazioni effettuate con dati rappresentativi, non sull'idea che utilizzare più core sia automaticamente più veloce.
Quando scegliere gli stream e quando preferire i cicli tradizionali
Gli stream sono particolarmente efficaci quando occorre esprimere una sequenza chiara di filtri, trasformazioni e aggregazioni. In questi casi, la pipeline mette in evidenza l'obiettivo dell'elaborazione e riduce la necessità di variabili temporanee.
I cicli tradizionali rimangono però una scelta valida. Possono risultare più leggibili quando l'algoritmo richiede un controllo dettagliato del flusso, interruzioni complesse, aggiornamenti ripetuti dello stato o una logica fortemente imperativa.
Anche l'uso degli stream dovrebbe seguire un principio semplice: scegliere la soluzione più chiara per chi dovrà leggere e mantenere il codice. Una pipeline molto lunga, con numerose trasformazioni e condizioni annidate, può essere meno comprensibile di un metodo suddiviso in passaggi più piccoli.
È inoltre importante evitare di modificare direttamente lo stato condiviso all'interno di map(), filter() o altre operazioni intermedie. Le funzioni dovrebbero preferibilmente essere prive di effetti collaterali, soprattutto quando la pipeline può essere elaborata in parallelo.
Conclusione
La Java Stream API offre un modo potente e flessibile per lavorare con collezioni di dati. Attraverso filter(), map(), flatMap(), sorted() e i collector della classe Collectors, è possibile comporre elaborazioni articolate in pipeline che descrivono con chiarezza il risultato desiderato.
Il suo valore non consiste nel sostituire ogni ciclo for o nel rendere automaticamente più veloce qualsiasi algoritmo. Consiste nel fornire un modello dichiarativo che, quando adatto al problema, rende il codice più leggibile e semplifica operazioni ricorrenti come filtraggio, trasformazione, raggruppamento e riduzione.
Per padroneggiare gli stream, conviene partire dalle operazioni fondamentali, capire la differenza tra operazioni intermedie e terminali e sperimentare i collector su dati realistici. Solo in seguito ha senso affrontare pipeline più complesse e valutare l'elaborazione parallela.
La regola pratica è semplice: usa gli stream per descrivere trasformazioni di dati in modo chiaro, misura le prestazioni quando contano e scegli i cicli tradizionali quando rendono l'algoritmo più facile da capire. È così che si scrive Java moderno senza trasformare la programmazione funzionale in un esercizio di stile fine a sé stesso.