Stream con Java

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.

Torna su