CodeGym /Corsi /JAVA 25 SELF /Stile funzionale con Stream API

Stile funzionale con Stream API

JAVA 25 SELF
Livello 49 , Lezione 3
Disponibile

1. Stile imperativo vs stile funzionale

Iniziamo con una domanda semplice: a cosa serve davvero questo «stile funzionale»? In che cosa è migliore dell’approccio abituale basato sui cicli? E che cosa significa «stile funzionale» in Java?

Stile imperativo

Lo stile imperativo è quando dite al computer come fare qualcosa, passo dopo passo. Per esempio, se da una lista di stringhe dovete ottenere una lista delle loro lunghezze, lasciare solo quelle dispari e ordinarle in ordine decrescente, scriverete più o meno così:

List<String> words = Arrays.asList("kot", "slon", "nosorog", "tigr", "mysh'");
List<Integer> lengths = new ArrayList<>();

for (String word : words) {
    int len = word.length();
    if (len % 2 != 0) {
        lengths.add(len);
    }
}
lengths.sort(Comparator.reverseOrder());
System.out.println(lengths); // [7, 5, 3]

Qui creiamo esplicitamente una lista intermedia, aggiungiamo manualmente gli elementi, ordiniamo — tutto per passi.

Stile funzionale

Lo stile funzionale è quando descrivete che cosa volete ottenere, non come si fa. In Java questo è realizzato tramite Stream API:

List<String> words = Arrays.asList("kot", "slon", "nosorog", "tigr", "mysh'");

List<Integer> result = words.stream()
    .map(String::length)
    .filter(len -> len % 2 != 0)
    .sorted(Comparator.reverseOrder())
    .toList();

System.out.println(result); // [7, 5, 3]

Qui costruiamo una sorta di «nastro trasportatore» di elaborazione dati: prima trasformiamo le parole nelle loro lunghezze (map), poi filtriamo le dispari (filter), poi ordiniamo (sorted). Tutto in un’unica catena, senza collezioni intermedie esplicite e senza cicli.

Confronto: imperativo vs funzionale

Nel consueto approccio imperativo scriviamo un ciclo, dentro il quale spieghiamo al computer passo dopo passo cosa fare: iterare ogni elemento, verificare la condizione, aggiungere a una nuova lista o stampare. Il codice funziona, ma occupa più righe e, più il compito è complesso, più è difficile da leggere e mantenere.

Lo stile funzionale consente di descrivere non il processo, ma ciò che vogliamo ottenere. Invece di un ciclo lungo costruiamo una catena di operazioni: filtrare, trasformare, raccogliere il risultato. È più corto, più chiaro e riduce il rischio di errori, perché c’è meno «lavoro manuale» con collezioni mutabili.

C’è anche il rovescio della medaglia. Per un principiante una catena del genere può sembrare confusa: più lambda di seguito si leggono peggio di un semplice ciclo. Dunque lo stile funzionale vince in concisione ed espressività, ma richiede abitudine ed esperienza.

2. Operazioni principali di Stream API

Stream API non è semplicemente un «nuovo tipo di ciclo», ma un insieme di strumenti per elaborare collezioni in stile funzionale. Vediamo le operazioni principali.

Come ottenere uno Stream?

List<String> list = List.of("a", "bb", "ccc");
Stream<String> stream = list.stream();

Operazioni intermedie

  • map — trasforma gli elementi dello stream
  • filter — filtra gli elementi in base a una condizione
  • flatMap — trasforma ogni elemento in uno stream e «appiattisce» i risultati
  • sorted — ordinamento
  • distinct — rimuove i duplicati
  • limit / skip — limitare/saltare elementi

Operazioni terminali

  • forEach — eseguire un’azione per ogni elemento
  • collect — raccogliere il risultato in una collezione
  • reduce — ridurre lo stream a un unico valore (per esempio, una somma)
  • count — contare il numero di elementi
  • anyMatch, allMatch, noneMatch — verifiche di condizioni

Esempio: catena di elaborazione

List<String> names = List.of("Anna", "Boris", "Vika", "Gleb", "Dasha");

List<String> filtered = names.stream()
    .filter(name -> name.length() > 3)
    .map(String::toUpperCase)
    .sorted()
    .toList();

System.out.println(filtered); // [ANNA, BORIS, DASHA, GLEB, VIKA]

Schema visivo della «catena di montaggio»:

[Anna, Boris, Vika, Gleb, Dasha]
   | filter (length>3)
[Anna, Boris, Vika, Gleb, Dasha]
   | map (toUpperCase)
[ANNA, BORIS, VIKA, GLEB, DASHA]
   | sorted
[ANNA, BORIS, DASHA, GLEB, VIKA]
   | toList

Ogni operazione non modifica la collezione originaria: viene creato un nuovo stream.

3. Esecuzione a flusso e valutazione lazy

Operazioni intermedie e terminali

Gli Stream hanno due tipi di operazioni. Le prime sono intermedie, come map, filter o sorted. Restituiscono un nuovo stream e «promettono» di fare qualcosa, ma in realtà non eseguono ancora nulla. Le seconde sono terminali, ad esempio forEach, collect o reduce. Queste avviano davvero tutta l’elaborazione. Il punto chiave è che finché non chiamate un’operazione terminale, lo stream resta «lazy» — i calcoli non partono.

Esempio:

Stream<String> stream = List.of("a", "bb", "ccc").stream()
    .map(s -> {
        System.out.println("map: " + s);
        return s.toUpperCase();
    });

System.out.println("Prima di forEach");
stream.forEach(System.out::println);

Output:

Prima di forEach
map: a
A
map: bb
BB
map: ccc
CCC

Si vede che map non viene eseguita finché non parte forEach.

Perché è utile?

Questo approccio consente di costruire senza costi inutili catene di trasformazioni lunghe quanto si vuole. Lo stream inizierà a lavorare solo quando serve davvero. Grazie a ciò si possono elaborare senza problemi enormi volumi di dati o persino sequenze infinite. Inoltre, la lazy evaluation aiuta a risparmiare memoria ed eseguire i calcoli in modo più efficiente.

4. Pratica: «Stringhe → lunghezze → dispari → in ordine decrescente»

Risolviamo passo passo il compito: «Da una lista di stringhe ottenere la lista delle lunghezze, lasciare solo quelle dispari, ordinare in ordine decrescente.»

Soluzione imperativa

List<String> words = Arrays.asList("kot", "slon", "nosorog", "tigr", "mysh'");
List<Integer> lengths = new ArrayList<>();

for (String word : words) {
    int len = word.length();
    if (len % 2 != 0) {
        lengths.add(len);
    }
}
lengths.sort(Comparator.reverseOrder());
System.out.println(lengths); // [7, 5, 3]

Soluzione funzionale con Stream API

List<String> words = Arrays.asList("kot", "slon", "nosorog", "tigr", "mysh'");

List<Integer> result = words.stream()
    .map(String::length) // trasformiamo le stringhe nelle loro lunghezze
    .filter(len -> len % 2 != 0) // lasciamo solo le lunghezze dispari
    .sorted(Comparator.reverseOrder()) // ordiniamo in ordine decrescente
    .toList(); // raccogliamo in un List (Java 16+)

System.out.println(result); // [7, 5, 3]

Spiegazioni:

  • map(String::length) — per ogni stringa otteniamo la sua lunghezza.
  • filter(len -> len % 2 != 0) — lasciamo solo le lunghezze dispari.
  • sorted(Comparator.reverseOrder()) — ordiniamo in ordine decrescente.
  • toList() — raccogliamo lo stream in una nuova lista.
Analogia

È come costruire un «nastro» in fabbrica: a ogni fase i pezzi vengono lavorati in modo diverso e solo alla fine tutto viene messo nella scatola.

5. Altri esempi: map, filter, forEach, collect

Esempio 1: filtraggio e stampa

List<String> names = List.of("Anna", "Boris", "Vika", "Gleb", "Dasha");

names.stream()
    .filter(name -> name.contains("a"))
    .forEach(System.out::println);
// Stamperà: Anna, Dasha

Esempio 2: trasformazione e raccolta in Set

Set<String> upperNames = names.stream()
    .map(String::toUpperCase)
    .collect(Collectors.toSet());

System.out.println(upperNames); // [ANNA, BORIS, VIKA, GLEB, DASHA]

Esempio 3: ottenere la somma delle lunghezze di tutte le stringhe

int totalLength = names.stream()
    .mapToInt(String::length)
    .sum();

System.out.println("Lunghezza totale: " + totalLength);

Esempio 4: uso di Predicate e Function

Predicate<String> longName = name -> name.length() > 4;
Function<String, String> greet = name -> "Ciao, " + name + "!";

names.stream()
    .filter(longName)
    .map(greet)
    .forEach(System.out::println);
// Ciao, Boris!
// Ciao, Dasha!

6. Caratteristiche dello stile funzionale con Stream API

Assenza di stato mutabile

Lo stile di Stream API incoraggia funzioni «pure» — senza effetti collaterali. Significa che è meglio non modificare variabili esterne dentro le lambda.

Male:

List<String> result = new ArrayList<>();
names.stream()
    .filter(name -> name.startsWith("A"))
    .forEach(result::add); // side-effect!

Meglio:

List<String> result = names.stream()
    .filter(name -> name.startsWith("A"))
    .toList();

Composizione delle operazioni

Si possono costruire catene molto lunghe, combinando map, filter, sorted e altri metodi. L’importante è non esagerare: se la catena supera lo schermo, forse conviene dividerla in parti.

Valutazione lazy

Stream API non fa nulla finché non si arriva a un’operazione terminale. Questo permette di risparmiare risorse e costruire pipeline di elaborazione efficienti.

Immutabilità delle collezioni di origine

Uno Stream non modifica la collezione di partenza! Tutte le trasformazioni restituiscono un nuovo stream/una nuova collezione.

7. Quando usare Stream API

Stream API è perfetto quando:

  • Serve elaborare rapidamente una collezione (filtraggio, trasformazione, ordinamento).
  • Volete codice conciso e leggibile.
  • Non volete creare manualmente collezioni intermedie.
  • Serve aggiungere facilmente il parallelismo (tramite parallelStream()).

Lo stile imperativo è talvolta preferibile se:

  • Serve una logica complessa con più cicli annidati e condizioni.
  • È importante mantenere la massima performance in punti critici (Stream API a volte è leggermente più lento).
  • Bisogna lavorare con stato mutabile (ad esempio, aggiornare elementi «in-place»).

8. Errori tipici nell’uso di Stream API

Errore n. 1: usare forEach per raccogliere una collezione. Molti principianti usano forEach per aggiungere elementi a una nuova collezione. Non è stile funzionale! Usate invece collect o toList().

Errore n. 2: ottimizzazione prematura. Non cercate di usare subito parallelStream() — il parallelismo serve solo per collezioni davvero grandi e per task CPU-intensive.

Errore n. 3: mescolare Stream API e collezioni mutabili. Stream API presuppone il lavoro con dati immutabili. Non modificate gli elementi della collezione dentro le lambda.

Errore n. 4: «perdere» il risultato. Se dimenticate di chiamare un’operazione terminale, non succederà nulla.

Errore n. 5: lambda troppo complesse. Se un’espressione lambda supera una o due righe — estraetela in un metodo separato con un nome chiaro.

Commenti
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION