Con l'introduzione di Java 8, il linguaggio ha subito una delle evoluzioni piu significative della sua storia: l'aggiunta dell'API Stream. Questa funzionalita consente di elaborare sequenze di elementi in modo dichiarativo, avvicinando Java al paradigma della programmazione funzionale. Gli Stream non sono strutture dati: non memorizzano elementi, ma li elaborano su richiesta, permettendo pipeline di operazioni efficienti, leggibili e facilmente parallelizzabili.
Cosa è uno Stream
Uno Stream in Java e una sequenza di elementi che supporta operazioni sequenziali e parallele. A differenza delle collezioni, uno Stream non contiene dati propri: e una vista computazionale su una sorgente di dati (un array, una lista, un file, un generatore infinito, ecc.). Le operazioni su uno Stream sono lazy, ovvero vengono eseguite solo quando e necessario produrre un risultato finale.
L'interfaccia centrale e java.util.stream.Stream<T>, ma esistono varianti primitive come
IntStream, LongStream e DoubleStream, che evitano il boxing/unboxing
automatico e migliorano le prestazioni quando si lavora con tipi primitivi.
Creare uno Stream
Esistono diversi modi per creare uno Stream in Java. Il piu comune e a partire da una collezione tramite il
metodo stream(), ma e possibile crearne da array, valori statici, range numerici o generatori.
import java.util.Arrays;
import java.util.List;
import java.util.stream.Stream;
import java.util.stream.IntStream;
public class StreamCreation {
public static void main(String[] args) {
// Creazione da una lista
List<String> names = List.of("Alice", "Bob", "Carlo", "Diana");
Stream<String> streamFromList = names.stream();
// Creazione da valori statici
Stream<String> streamFromValues = Stream.of("Uno", "Due", "Tre");
// Creazione da un array
String[] array = {"Java", "Python", "Go"};
Stream<String> streamFromArray = Arrays.stream(array);
// Creazione di un range di interi
IntStream range = IntStream.rangeClosed(1, 10);
// Creazione di uno stream infinito con iterate
Stream<Integer> infiniteStream = Stream.iterate(0, n -> n + 2);
// Creazione di uno stream con generate
Stream<Double> randomStream = Stream.generate(Math::random);
}
}
Gli Stream infiniti come quelli prodotti da Stream.iterate e Stream.generate devono
essere limitati tramite operazioni come limit(), altrimenti l'elaborazione non terminerebbe mai.
Operazioni intermedie e terminali
Le operazioni su uno Stream si dividono in due categorie: intermedie e terminali. Le operazioni intermedie restituiscono un nuovo Stream e sono lazy: vengono accumulate senza essere eseguite fino a quando non si incontra un'operazione terminale. Le operazioni terminali consumano lo Stream e producono un risultato (o un effetto collaterale).
Questa separazione permette all'implementazione di ottimizzare l'intera pipeline, ad esempio cortocircuitando l'elaborazione non appena il risultato e determinato.
Filtrare e trasformare: filter e map
Le operazioni filter e map sono le più utilizzate. filter accetta un
predicato e lascia passare solo gli elementi che lo soddisfano. map trasforma ogni elemento
applicando una funzione, producendo uno Stream del tipo risultante.
import java.util.List;
import java.util.stream.Collectors;
public class FilterAndMap {
public static void main(String[] args) {
List<String> cities = List.of("Roma", "Milano", "Napoli", "Torino", "Firenze", "Bologna");
// Filtra le citta con nome piu lungo di 5 caratteri e le converte in maiuscolo
List<String> result = cities.stream()
.filter(city -> city.length() > 5)
.map(String::toUpperCase)
.collect(Collectors.toList());
System.out.println(result);
// Output: [MILANO, NAPOLI, TORINO, FIRENZE, BOLOGNA]
}
}
flatMap: appiattire stream annidati
Quando ogni elemento di uno Stream produce a sua volta uno Stream, flatMap consente di
"appiattire" il risultato in un unico Stream. E particolarmente utile quando si lavora con liste di liste
o con strutture annidate.
import java.util.List;
import java.util.stream.Collectors;
public class FlatMapExample {
public static void main(String[] args) {
List<List<Integer>> matrix = List.of(
List.of(1, 2, 3),
List.of(4, 5, 6),
List.of(7, 8, 9)
);
// Appiattisce la matrice in un singolo stream di interi
List<Integer> flatList = matrix.stream()
.flatMap(row -> row.stream())
.collect(Collectors.toList());
System.out.println(flatList);
// Output: [1, 2, 3, 4, 5, 6, 7, 8, 9]
}
}
Ordinamento e deduplicazione
Lo Stream API fornisce sorted() per ordinare gli elementi secondo l'ordine naturale o tramite
un comparatore personalizzato, e distinct() per rimuovere i duplicati basandosi sul metodo
equals().
import java.util.List;
import java.util.stream.Collectors;
public class SortAndDistinct {
public static void main(String[] args) {
List<Integer> numbers = List.of(5, 3, 8, 1, 3, 9, 5, 2, 8);
// Rimuove i duplicati e ordina in modo crescente
List<Integer> sortedUnique = numbers.stream()
.distinct()
.sorted()
.collect(Collectors.toList());
System.out.println(sortedUnique);
// Output: [1, 2, 3, 5, 8, 9]
List<String> words = List.of("banana", "mela", "arancia", "kiwi");
// Ordina per lunghezza del nome, poi alfabeticamente
List<String> sortedWords = words.stream()
.sorted((a, b) -> {
int lengthCompare = Integer.compare(a.length(), b.length());
return lengthCompare != 0 ? lengthCompare : a.compareTo(b);
})
.collect(Collectors.toList());
System.out.println(sortedWords);
// Output: [kiwi, mela, banana, arancia]
}
}
Riduzione: reduce e collect
L'operazione reduce combina gli elementi di uno Stream in un unico valore applicando
ripetutamente una funzione binaria. E la forma piu generale di aggregazione. Il metodo collect
e invece un'operazione terminale mutabile che accumula gli elementi in una struttura dati, ed e usato
comunemente con la classe Collectors.
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
public class ReduceAndCollect {
public static void main(String[] args) {
List<Integer> values = List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
// Somma di tutti gli elementi con reduce
Optional<Integer> sum = values.stream()
.reduce((a, b) -> a + b);
sum.ifPresent(s -> System.out.println("Somma: " + s));
// Output: Somma: 55
// Somma con valore identita (non restituisce Optional)
int sumWithIdentity = values.stream()
.reduce(0, Integer::sum);
System.out.println("Somma con identita: " + sumWithIdentity);
// Output: Somma con identita: 55
// Raccoglie in una stringa separata da virgole
String joined = values.stream()
.map(String::valueOf)
.collect(Collectors.joining(", "));
System.out.println("Stringa: " + joined);
// Output: Stringa: 1, 2, 3, 4, 5, 6, 7, 8, 9, 10
}
}
Collectors avanzati: groupingBy e partitioningBy
La classe Collectors offre strumenti potenti per raggruppare e suddividere i dati.
groupingBy suddivide gli elementi in gruppi in base a una funzione di classificazione,
restituendo una Map. partitioningBy e un caso speciale che divide lo
Stream in due gruppi: quelli che soddisfano un predicato e quelli che non lo soddisfano.
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public class GroupingExample {
record Person(String name, String department, int salary) {}
public static void main(String[] args) {
List<Person> employees = List.of(
new Person("Alice", "Engineering", 4200),
new Person("Bob", "Marketing", 3100),
new Person("Carlo", "Engineering", 3800),
new Person("Diana", "HR", 2900),
new Person("Elena", "Marketing", 3500),
new Person("Franco", "HR", 3000)
);
// Raggruppa i dipendenti per reparto
Map<String, List<Person>> byDepartment = employees.stream()
.collect(Collectors.groupingBy(Person::department));
byDepartment.forEach((dept, people) -> {
System.out.println(dept + ": " + people.stream()
.map(Person::name)
.collect(Collectors.joining(", ")));
});
System.out.println();
// Calcola lo stipendio medio per reparto
Map<String, Double> avgSalaryByDept = employees.stream()
.collect(Collectors.groupingBy(
Person::department,
Collectors.averagingInt(Person::salary)
));
avgSalaryByDept.forEach((dept, avg) ->
System.out.printf("%s: %.2f%n", dept, avg));
System.out.println();
// Divide i dipendenti in base allo stipendio (sopra/sotto 3500)
Map<Boolean, List<Person>> partitioned = employees.stream()
.collect(Collectors.partitioningBy(p -> p.salary() >= 3500));
System.out.println("Stipendio >= 3500: " + partitioned.get(true).stream()
.map(Person::name).collect(Collectors.joining(", ")));
System.out.println("Stipendio < 3500: " + partitioned.get(false).stream()
.map(Person::name).collect(Collectors.joining(", ")));
}
}
Operazioni di ricerca e corrispondenza
Lo Stream API include operazioni terminali per verificare condizioni sugli elementi o trovarne uno specifico. Queste operazioni sono short-circuit: si interrompono non appena il risultato e determinato, rendendo l'elaborazione efficiente anche su Stream di grandi dimensioni.
import java.util.List;
import java.util.Optional;
public class MatchAndFind {
public static void main(String[] args) {
List<Integer> numbers = List.of(2, 4, 6, 7, 8, 10);
// Verifica se almeno un elemento e dispari
boolean anyOdd = numbers.stream().anyMatch(n -> n % 2 != 0);
System.out.println("Almeno uno dispari: " + anyOdd); // true
// Verifica se tutti gli elementi sono positivi
boolean allPositive = numbers.stream().allMatch(n -> n > 0);
System.out.println("Tutti positivi: " + allPositive); // true
// Verifica che nessun elemento sia negativo
boolean noneNegative = numbers.stream().noneMatch(n -> n < 0);
System.out.println("Nessun negativo: " + noneNegative); // true
// Trova il primo elemento maggiore di 5
Optional<Integer> firstOverFive = numbers.stream()
.filter(n -> n > 5)
.findFirst();
firstOverFive.ifPresent(n -> System.out.println("Primo > 5: " + n)); // 6
// Conta gli elementi pari
long evenCount = numbers.stream().filter(n -> n % 2 == 0).count();
System.out.println("Numeri pari: " + evenCount); // 5
}
}
Stream con i tipi primitivi
Per evitare il boxing dei tipi primitivi, Java fornisce Stream specializzati: IntStream,
LongStream e DoubleStream. Questi espongono metodi statistici aggiuntivi come
sum(), average(), min(), max() e summaryStatistics().
import java.util.IntSummaryStatistics;
import java.util.stream.IntStream;
public class PrimitiveStreams {
public static void main(String[] args) {
// Calcola la somma dei primi 100 numeri naturali
int sum = IntStream.rangeClosed(1, 100).sum();
System.out.println("Somma 1..100: " + sum); // 5050
// Statistiche complete su un range
IntSummaryStatistics stats = IntStream.of(3, 7, 1, 15, 9, 4)
.summaryStatistics();
System.out.println("Min: " + stats.getMin());
System.out.println("Max: " + stats.getMax());
System.out.println("Media: " + stats.getAverage());
System.out.println("Somma: " + stats.getSum());
System.out.println("Conteggio: " + stats.getCount());
// Conversione da Stream a IntStream tramite mapToInt
java.util.List<String> words = java.util.List.of("ciao", "mondo", "java", "stream");
int totalLength = words.stream()
.mapToInt(String::length)
.sum();
System.out.println("Lunghezza totale: " + totalLength);
}
}
Stream paralleli
Una delle caratteristiche piu potenti dell'API Stream e la possibilita di eseguire operazioni in parallelo
senza modifiche sostanziali al codice. Basta invocare parallelStream() invece di
stream(), oppure chiamare parallel() su uno Stream esistente. Internamente,
Java utilizza il framework Fork/Join e il pool di thread comune.
È importante usare gli Stream paralleli con attenzione: sono vantaggiosi solo per dataset di grandi dimensioni e operazioni computazionalmente intensive. Per dataset piccoli, l'overhead di coordinazione dei thread supera il guadagno. Inoltre, le operazioni devono essere prive di effetti collaterali e l'ordine dei risultati potrebbe differire dallo Stream sequenziale.
import java.util.List;
import java.util.stream.LongStream;
public class ParallelStreamExample {
public static void main(String[] args) {
// Calcola la somma dei quadrati con stream parallelo
long result = LongStream.rangeClosed(1, 10_000_000L)
.parallel()
.map(n -> n * n)
.sum();
System.out.println("Somma dei quadrati: " + result);
// Confronto tra stream sequenziale e parallelo (misurazione semplice)
List<Integer> bigList = java.util.stream.IntStream
.rangeClosed(1, 5_000_000)
.boxed()
.collect(java.util.stream.Collectors.toList());
long startSeq = System.currentTimeMillis();
long countSeq = bigList.stream()
.filter(n -> n % 2 == 0)
.count();
long timeSeq = System.currentTimeMillis() - startSeq;
long startPar = System.currentTimeMillis();
long countPar = bigList.parallelStream()
.filter(n -> n % 2 == 0)
.count();
long timePar = System.currentTimeMillis() - startPar;
System.out.println("Sequenziale: " + countSeq + " elementi in " + timeSeq + " ms");
System.out.println("Parallelo: " + countPar + " elementi in " + timePar + " ms");
}
}
Uso di Optional con gli Stream
Optional<T> e strettamente integrato con lo Stream API. Molte operazioni terminali
come findFirst(), findAny(), min(), max() e
reduce() restituiscono un Optional per gestire in modo sicuro l'assenza
di un valore. A partire da Java 9, Optional espone anche il metodo stream(),
che converte un Optional in uno Stream di zero o un elemento.
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
public class OptionalWithStreams {
record Product(String name, double price) {}
public static void main(String[] args) {
List<Product> catalog = List.of(
new Product("Tastiera", 79.99),
new Product("Monitor", 349.00),
new Product("Mouse", 39.99),
new Product("Webcam", 89.00)
);
// Trova il prodotto piu costoso
Optional<Product> mostExpensive = catalog.stream()
.max((a, b) -> Double.compare(a.price(), b.price()));
mostExpensive.ifPresent(p ->
System.out.printf("Piu costoso: %s (%.2f EUR)%n", p.name(), p.price()));
// Usa Optional come sorgente in una pipeline (Java 9+)
List<Optional<String>> optionals = List.of(
Optional.of("Alpha"),
Optional.empty(),
Optional.of("Beta"),
Optional.empty(),
Optional.of("Gamma")
);
// Estrae solo i valori presenti tramite flatMap su Optional
List<String> presentValues = optionals.stream()
.flatMap(Optional::stream)
.collect(Collectors.toList());
System.out.println("Valori presenti: " + presentValues);
// Output: Valori presenti: [Alpha, Beta, Gamma]
}
}
Creare un Collector personalizzato
Quando i Collectors predefiniti non sono sufficienti, e possibile implementare un
Collector personalizzato tramite l'interfaccia Collector<T, A, R>,
dove T e il tipo degli elementi in input, A e il tipo del contenitore
di accumulo intermedio e R e il tipo del risultato finale.
import java.util.EnumSet;
import java.util.Set;
import java.util.function.BiConsumer;
import java.util.function.BinaryOperator;
import java.util.function.Function;
import java.util.function.Supplier;
import java.util.stream.Collector;
import java.util.stream.Stream;
// Collector personalizzato che raccoglie gli elementi in una stringa con delimitatori
public class CustomCollectorExample {
static class WrappedStringCollector implements Collector<String, StringBuilder, String> {
private final String prefix;
private final String delimiter;
private final String suffix;
WrappedStringCollector(String prefix, String delimiter, String suffix) {
this.prefix = prefix;
this.delimiter = delimiter;
this.suffix = suffix;
}
@Override
public Supplier<StringBuilder> supplier() {
// Crea il contenitore di accumulo iniziale
return () -> new StringBuilder(prefix);
}
@Override
public BiConsumer<StringBuilder, String> accumulator() {
// Aggiunge ogni elemento al contenitore
return (sb, element) -> {
if (sb.length() > prefix.length()) sb.append(delimiter);
sb.append(element);
};
}
@Override
public BinaryOperator<StringBuilder> combiner() {
// Unisce due contenitori parziali (usato in parallelo)
return (sb1, sb2) -> sb1.append(delimiter).append(sb2);
}
@Override
public Function<StringBuilder, String> finisher() {
// Trasforma il contenitore nel risultato finale
return sb -> sb.append(suffix).toString();
}
@Override
public Set<Characteristics> characteristics() {
return EnumSet.noneOf(Characteristics.class);
}
}
public static void main(String[] args) {
String result = Stream.of("Java", "Stream", "API")
.collect(new WrappedStringCollector("[ ", " | ", " ]"));
System.out.println(result);
// Output: [ Java | Stream | API ]
}
}
Pipeline complessa: un esempio pratico
Un caso d'uso realistico integra piu operazioni in una singola pipeline fluente. L'esempio seguente elabora un catalogo di ordini e-commerce, estraendo statistiche aggregate per categoria di prodotto.
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public class EcommercePipeline {
record OrderItem(String category, String product, int quantity, double unitPrice) {
double total() { return quantity * unitPrice; }
}
public static void main(String[] args) {
List<OrderItem> orders = List.of(
new OrderItem("Elettronica", "Laptop", 1, 999.00),
new OrderItem("Elettronica", "Smartphone", 2, 499.00),
new OrderItem("Libri", "Clean Code", 3, 34.90),
new OrderItem("Libri", "Effective Java", 2, 42.00),
new OrderItem("Elettronica", "Cuffie", 4, 89.00),
new OrderItem("Casa", "Lampada", 2, 59.00),
new OrderItem("Casa", "Tappeto", 1, 120.00),
new OrderItem("Libri", "Design Patterns", 1, 55.00)
);
// Calcola il fatturato totale per categoria, ordinato dal maggiore al minore
Map<String, Double> revenueByCategory = orders.stream()
.collect(Collectors.groupingBy(
OrderItem::category,
Collectors.summingDouble(OrderItem::total)
));
System.out.println("Fatturato per categoria:");
revenueByCategory.entrySet().stream()
.sorted(Map.Entry.<String, Double>comparingByValue().reversed())
.forEach(e -> System.out.printf(" %-15s %.2f EUR%n", e.getKey(), e.getValue()));
System.out.println();
// Trova il prodotto piu venduto per quantita in ogni categoria
Map<String, String> topProductByCategory = orders.stream()
.collect(Collectors.groupingBy(
OrderItem::category,
Collectors.collectingAndThen(
Collectors.maxBy(
java.util.Comparator.comparingInt(OrderItem::quantity)
),
opt -> opt.map(OrderItem::product).orElse("N/A")
)
));
System.out.println("Prodotto piu venduto per categoria:");
topProductByCategory.forEach((cat, prod) ->
System.out.printf(" %-15s %s%n", cat, prod));
}
}
Lazy evaluation e ottimizzazioni
La valutazione lazy degli Stream ha implicazioni importanti sulle prestazioni. Quando si concatenano
operazioni intermedie e terminali, il motore Stream ottimizza la pipeline per ridurre al minimo
il numero di passaggi sugli elementi. In particolare, le operazioni short-circuit come
findFirst(), limit() e anyMatch() interrompono l'elaborazione
non appena la condizione e soddisfatta.
import java.util.Optional;
import java.util.stream.Stream;
public class LazyEvaluationDemo {
static int processStep(String label, int value) {
// Stampa ogni volta che un elemento viene elaborato
System.out.println(" [" + label + "] elaborazione di: " + value);
return value;
}
public static void main(String[] args) {
System.out.println("Pipeline con findFirst (lazy):");
// Solo gli elementi necessari vengono elaborati
Optional<Integer> found = Stream.iterate(1, n -> n + 1)
.limit(1000)
.filter(n -> {
processStep("filter", n);
return n % 7 == 0;
})
.map(n -> {
processStep("map", n);
return n * n;
})
.findFirst();
found.ifPresent(v -> System.out.println("Trovato: " + v));
// Elabora solo i primi 7 elementi, non tutti e 1000
}
}
Considerazioni finali
L'API Stream di Java rappresenta un cambio di paradigma nella scrittura del codice di elaborazione dati. Consente di esprimere trasformazioni complesse in modo dichiarativo e conciso, migliorando la leggibilita e riducendo i bug legati alla gestione manuale dei cicli. La separazione tra operazioni intermedie e terminali, combinata con la valutazione lazy, permette ottimizzazioni automatiche che sarebbero difficili da implementare manualmente.
È tuttavia importante conoscerne i limiti: uno Stream non e riutilizzabile dopo l'invocazione di un'operazione terminale; la parallelizzazione non e sempre vantaggiosa e richiede attenzione alla sicurezza dei thread; le operazioni con effetti collaterali sono da evitare all'interno delle lambda. Con una buona comprensione di questi principi, lo Stream API diventa uno degli strumenti piu espressivi ed efficaci a disposizione del programmatore Java.