Stream API in Java

Edoardo Midali
Edoardo Midali

Le Stream API, introdotte in Java 8, rappresentano una delle innovazioni più significative del linguaggio, portando paradigmi di programmazione funzionale e permettendo elaborazione dichiarativa delle collezioni. Questo approccio trasforma il modo di processare i dati, passando da iterazioni imperative a pipeline funzionali eleganti e spesso più efficienti.

Concetti Fondamentali

Uno Stream rappresenta una sequenza di elementi che supporta operazioni aggregate sequenziali e parallele. Non è una struttura dati ma piuttosto una vista su una sorgente di dati che permette di applicare operazioni in modo lazy e componibile.

Caratteristiche degli Stream

Non Storage: Gli stream non memorizzano elementi, ma li processano dalla sorgente originale.

Functional Nature: Le operazioni sugli stream non modificano la sorgente ma restituiscono nuovi stream.

Lazy Evaluation: Le operazioni intermediate vengono eseguite solo quando richiesta un’operazione terminal.

Possibly Unbounded: Gli stream possono rappresentare sequenze infinite di dati.

Consumable: Ogni stream può essere “consumato” una sola volta.

List<String> nomi = Arrays.asList("Alice", "Bob", "Charlie", "David", "Elena");

// Approccio imperativo tradizionale
List<String> nomiLunghi = new ArrayList<>();
for (String nome : nomi) {
    if (nome.length() > 4) {
        nomiLunghi.add(nome.toUpperCase());
    }
}
Collections.sort(nomiLunghi);

// Approccio funzionale con Stream
List<String> nomiLunghiStream = nomi.stream()
    .filter(nome -> nome.length() > 4)
    .map(String::toUpperCase)
    .sorted()
    .collect(Collectors.toList());

Pipeline di Elaborazione

Un stream pipeline consiste di una sorgente, zero o più operazioni intermediate, e un’operazione terminal. La pipeline viene eseguita solo quando l’operazione terminal viene invocata.

// Esempio di pipeline completa
OptionalDouble mediaEta = persone.stream()           // Sorgente
    .filter(p -> p.getEta() >= 18)                  // Intermediate: filtro
    .mapToInt(Persona::getEta)                      // Intermediate: trasformazione
    .average();                                     // Terminal: riduzione

// Nessuna operazione viene eseguita fino a average()

Operazioni Intermediate

Le operazioni intermediate trasformano uno stream in un altro stream, permettendo di creare pipeline complesse. Sono lazy, meaning vengono eseguite solo quando necessario per produrre il risultato finale.

Filtering e Selezione

filter(): Seleziona elementi che soddisfano un predicato.

distinct(): Rimuove duplicati basandosi su equals().

limit(): Limita il numero di elementi.

skip(): Salta i primi n elementi.

List<Prodotto> prodotti = getProdotti();

// Filtering complesso
List<Prodotto> prodottiFiltrati = prodotti.stream()
    .filter(p -> p.getPrezzo() > 50)
    .filter(p -> p.getCategoria().equals("Elettronica"))
    .filter(p -> p.isDisponibile())
    .distinct()
    .limit(10)
    .collect(Collectors.toList());

// Skip e limit per paginazione
List<Prodotto> secondaPagina = prodotti.stream()
    .skip(20)    // Salta primi 20
    .limit(10)   // Prendi successivi 10
    .collect(Collectors.toList());

Trasformazione

map(): Trasforma ogni elemento applicando una funzione.

flatMap(): Trasforma ogni elemento in uno stream e appiattisce il risultato.

mapToInt/Long/Double(): Trasforma in stream primitivi per operazioni numeriche.

// Trasformazioni semplici
List<String> nomiMaiuscoli = persone.stream()
    .map(Persona::getNome)
    .map(String::toUpperCase)
    .collect(Collectors.toList());

// FlatMap per appiattire strutture annidate
List<Ordine> ordini = getOrdini();
List<Prodotto> tuttiProdotti = ordini.stream()
    .flatMap(o -> o.getProdotti().stream())
    .collect(Collectors.toList());

// Stream di primitive per calcoli numerici
double prezzoMedio = prodotti.stream()
    .mapToDouble(Prodotto::getPrezzo)
    .average()
    .orElse(0.0);

// Trasformazioni complesse con flatMap
List<String> tutteParole = documenti.stream()
    .flatMap(doc -> Arrays.stream(doc.split("\\s+")))
    .map(String::toLowerCase)
    .distinct()
    .collect(Collectors.toList());

Ordinamento

sorted(): Ordina elementi usando ordinamento naturale o comparator custom.

// Ordinamento naturale
List<String> nomiOrdinati = nomi.stream()
    .sorted()
    .collect(Collectors.toList());

// Ordinamento con comparator
List<Persona> personeOrdinate = persone.stream()
    .sorted(Comparator.comparing(Persona::getEta))
    .collect(Collectors.toList());

// Ordinamento multiplo
List<Prodotto> prodottiOrdinati = prodotti.stream()
    .sorted(Comparator.comparing(Prodotto::getCategoria)
            .thenComparing(Prodotto::getPrezzo))
    .collect(Collectors.toList());

// Ordinamento inverso
List<Persona> personePerEtaDesc = persone.stream()
    .sorted(Comparator.comparing(Persona::getEta).reversed())
    .collect(Collectors.toList());

Operazioni Terminal

Le operazioni terminal producono un risultato finale e “consumano” lo stream. Una volta eseguita un’operazione terminal, lo stream non può più essere utilizzato.

Reduction Operations

reduce(): Combina elementi dello stream in un singolo risultato.

collect(): Raccoglie elementi in una collezione o altro contenitore.

// Reduce per calcoli aggregate
Optional<Integer> somma = numeri.stream()
    .reduce(Integer::sum);

// Reduce con valore iniziale
Integer prodotto = numeri.stream()
    .reduce(1, (a, b) -> a * b);

// Reduce complesso per trovare la persona più anziana
Optional<Persona> piuAnziana = persone.stream()
    .reduce((p1, p2) -> p1.getEta() > p2.getEta() ? p1 : p2);

// Collect con Collectors predefiniti
Map<String, List<Persona>> personePerCitta = persone.stream()
    .collect(Collectors.groupingBy(Persona::getCitta));

Set<String> cittaUniche = persone.stream()
    .map(Persona::getCitta)
    .collect(Collectors.toSet());

Matching e Finding

anyMatch(), allMatch(), noneMatch(): Verificano condizioni sui elementi.

findFirst(), findAny(): Trovano elementi che soddisfano criteri.

// Operazioni di matching
boolean haAdulti = persone.stream()
    .anyMatch(p -> p.getEta() >= 18);

boolean tuttiAdulti = persone.stream()
    .allMatch(p -> p.getEta() >= 18);

boolean nessunMinorenne = persone.stream()
    .noneMatch(p -> p.getEta() < 18);

// Finding operations
Optional<Persona> primaPersonaAnziana = persone.stream()
    .filter(p -> p.getEta() > 65)
    .findFirst();

Optional<Prodotto> prodottoDisponibile = prodotti.stream()
    .filter(Prodotto::isDisponibile)
    .findAny();

Iterazione

forEach(): Esegue un’azione per ogni elemento.

forEachOrdered(): Esegue un’azione mantenendo l’ordine (importante per parallel streams).

// Iterazione semplice
persone.stream()
    .filter(p -> p.getEta() >= 18)
    .forEach(p -> System.out.println("Adulto: " + p.getNome()));

// Iterazione ordinata (importante per parallel streams)
numeri.parallelStream()
    .map(n -> n * 2)
    .forEachOrdered(System.out::println); // Mantiene ordine originale

Collectors Avanzati

La classe Collectors fornisce implementazioni comuni per l’operazione collect(), ma è possibile creare collector personalizzati per casi specifici.

Collectors Predefiniti

// Raggruppamenti
Map<String, List<Persona>> personePerCitta = persone.stream()
    .collect(Collectors.groupingBy(Persona::getCitta));

Map<String, Long> conteggioPerCitta = persone.stream()
    .collect(Collectors.groupingBy(Persona::getCitta, Collectors.counting()));

// Partitioning (grouping binario)
Map<Boolean, List<Persona>> personePerEta = persone.stream()
    .collect(Collectors.partitioningBy(p -> p.getEta() >= 18));

// Joining di stringhe
String nomiConcatenati = persone.stream()
    .map(Persona::getNome)
    .collect(Collectors.joining(", ", "[", "]"));

// Statistiche su numeri
DoubleSummaryStatistics statsPrezzo = prodotti.stream()
    .collect(Collectors.summarizingDouble(Prodotto::getPrezzo));

Collectors Compositi

// Raggruppamento con trasformazione
Map<String, Set<String>> nomiPerCitta = persone.stream()
    .collect(Collectors.groupingBy(
        Persona::getCitta,
        Collectors.mapping(Persona::getNome, Collectors.toSet())
    ));

// Raggruppamento multilivello
Map<String, Map<String, List<Persona>>> personePerCittaEProfessione = persone.stream()
    .collect(Collectors.groupingBy(
        Persona::getCitta,
        Collectors.groupingBy(Persona::getProfessione)
    ));

// Collector personalizzato per media
Collector<Persona, ?, Double> mediaEtaCollector = Collector.of(
    () -> new double[2], // supplier: [sum, count]
    (acc, p) -> {
        acc[0] += p.getEta();
        acc[1]++;
    },
    (acc1, acc2) -> {
        acc1[0] += acc2[0];
        acc1[1] += acc2[1];
        return acc1;
    },
    acc -> acc[1] == 0 ? 0 : acc[0] / acc[1]
);

Parallel Streams

I parallel streams sfruttano il framework ForkJoin per eseguire operazioni in parallelo su più thread, potenzialmente migliorando le performance su dataset grandi.

Quando Usare Parallel Streams

Dataset Grandi: Benefici significativi solo con collezioni di migliaia o milioni di elementi.

Operazioni CPU-Intensive: Calcoli complessi che possono beneficiare del parallelismo.

Operazioni Stateless: Operazioni che non dipendono da stato condiviso.

// Parallel stream da collezione
List<Integer> numeriGrandi = IntStream.range(1, 10_000_000)
    .boxed()
    .collect(Collectors.toList());

// Operazione che beneficia del parallelismo
long count = numeriGrandi.parallelStream()
    .filter(n -> isPrime(n))  // Operazione CPU-intensive
    .count();

// Configurazione thread pool (se necessario)
System.setProperty("java.util.concurrent.ForkJoinPool.common.parallelism", "8");

// Attenzione all'ordinamento con parallel streams
List<Integer> risultatiOrdinati = numeri.parallelStream()
    .map(n -> complexCalculation(n))
    .sorted()  // Potenzialmente costoso in parallelo
    .collect(Collectors.toList());

Considerazioni sui Parallel Streams

Thread Safety: Assicurati che le operazioni siano thread-safe.

Overhead: Il parallelismo ha overhead; non sempre è più veloce.

Ordine: I parallel streams possono non preservare l’ordine senza operazioni specifiche.

// Problematico - non thread-safe
List<String> risultati = new ArrayList<>(); // Non thread-safe
numeri.parallelStream()
    .forEach(n -> risultati.add(processNumber(n))); // Race condition!

// Corretto - usa collect
List<String> risultatiCorretti = numeri.parallelStream()
    .map(this::processNumber)
    .collect(Collectors.toList()); // Thread-safe

Stream di Primitive

Per evitare boxing/unboxing overhead, Java fornisce stream specializzati per tipi primitivi: IntStream, LongStream, DoubleStream.

// Creazione di stream primitivi
IntStream numeri = IntStream.range(1, 100);
DoubleStream valori = DoubleStream.of(1.1, 2.2, 3.3);

// Operazioni specializzate
int somma = IntStream.range(1, 101).sum();
OptionalDouble media = DoubleStream.of(1.0, 2.0, 3.0).average();

// Conversioni tra stream
IntStream ages = persone.stream()
    .mapToInt(Persona::getEta);

Stream<Integer> boxed = ages.boxed(); // Torna a Stream<Integer>

// Generazione di stream infiniti
IntStream fibonacci = IntStream.iterate(1, n -> n + 1)
    .limit(1000);

DoubleStream randomValues = DoubleStream.generate(Math::random)
    .limit(100);

Best Practices e Performance

Ottimizzazioni

Use Primitive Streams: Per operazioni numeriche, usa IntStream, LongStream, DoubleStream.

Avoid Excessive Chaining: Pipeline troppo lunghe possono degradare performance.

Consider Data Size: Per dataset piccoli, i loop tradizionali possono essere più veloci.

Profile Performance: Misura le performance reali, non assumere che gli stream siano sempre più veloci.

// Buona pratica: operazioni early filtering
List<Prodotto> prodottiCostosi = prodotti.stream()
    .filter(p -> p.getPrezzo() > 100)  // Filtra presto
    .filter(p -> p.isDisponibile())    // Riduce il dataset
    .map(p -> enrichProduct(p))        // Operazione costosa su dataset ridotto
    .collect(Collectors.toList());

// Evita: operazioni costose prima dei filtri
List<Prodotto> inefficiente = prodotti.stream()
    .map(p -> enrichProduct(p))        // Operazione costosa su tutto il dataset
    .filter(p -> p.getPrezzo() > 100)  // Filtra dopo
    .collect(Collectors.toList());

Common Pitfalls

Stream Reuse: Non riutilizzare stream già consumati.

Side Effects: Evita side effects nelle operazioni intermediate.

Null Handling: Gestisci appropriatamente i valori null.

// Errore: riutilizzo stream
Stream<String> nomi = persone.stream().map(Persona::getNome);
long count = nomi.count();        // Consuma lo stream
// List<String> lista = nomi.collect(Collectors.toList()); // ERRORE!

// Corretto: crea nuovo stream se necessario
List<String> lista = persone.stream()
    .map(Persona::getNome)
    .collect(Collectors.toList());

// Gestione null sicura
List<String> nomiNonNull = persone.stream()
    .map(Persona::getNome)
    .filter(Objects::nonNull)
    .collect(Collectors.toList());

Conclusione

Le Stream API rappresentano un paradigm shift verso la programmazione funzionale in Java, offrendo un modo elegante e spesso più efficiente di processare collezioni di dati. La loro natura dichiarativa rende il codice più leggibile e manutenibile, mentre le ottimizzazioni interne della JVM possono migliorare le performance.

La chiave per utilizzare efficacemente gli stream è comprendere la distinzione tra operazioni intermediate e terminal, sfruttare la lazy evaluation, e sapere quando i parallel streams offrono benefici reali. Con la pratica, gli stream diventano uno strumento naturale e potente per l’elaborazione dei dati in Java moderno.

L’adozione delle Stream API richiede un cambio di mentalità da programmazione imperativa a funzionale, ma i benefici in termini di espressività del codice e potential performance giustificano ampiamente l’investimento nell’apprendimento.