1. Metodo forEach: azione finale sugli elementi
Prima di entrare nei dettagli, ricordiamo ancora una volta che cosa sono le operazioni terminali e intermedie di uno stream.
- Operazioni intermedie (ad esempio, filter, map, distinct, peek) — restituiscono un nuovo stream e di solito non eseguono azioni fino alla chiamata di un’operazione terminale.
- Operazioni terminali (ad esempio, forEach, collect, count, anyMatch) — avviano l’elaborazione degli elementi dello stream e restituiscono un risultato (oppure non restituiscono nulla, come forEach).
Dopo un’operazione terminale lo stream è considerato chiuso, e non è più possibile applicare altre operazioni. È come cercare di finire un gelato già mangiato — non si può: lo stream è già «usato».
Ora conosciamo due metodi importanti per lavorare con effetti collaterali: forEach e peek.
Che cosa fa forEach?
forEach è un’operazione terminale dello stream che esegue l’azione specificata per ogni elemento dello stream. Di solito è usata per stampare a video, scrivere nei log, calcolare statistiche e altri effetti collaterali (side effects).
Firma del metodo:
void forEach(Consumer<? super T> action)
Consumer<T> è un’interfaccia funzionale che accetta un argomento e non restituisce nulla (ad esempio, System.out::println).
Esempio: stampare tutti gli elementi della lista
Supponiamo di avere una lista di nomi utente:
List<String> users = List.of("Anna", "Boris", "Alex", "Alina", "Dmitry");
Stampare tutti gli utenti a video con le Stream API è molto semplice:
users.stream().forEach(System.out::println);
Risultato:
Anna
Boris
Alex
Alina
Dmitry
Si può usare anche un’espressione lambda:
users.stream().forEach(name -> System.out.println("Utente: " + name));
Risultato:
Utente: Anna
Utente: Boris
Utente: Alex
Utente: Alina
Utente: Dmitry
Importante: forEach chiude lo stream
Dopo la chiamata a forEach lo stream si «chiude». Non si può continuare la catena:
users.stream()
.filter(name -> name.startsWith("A"))
.forEach(System.out::println)
.map(String::toUpperCase); // Errore! Lo stream è già chiuso.
Un tentativo di invocare qualcosa dopo forEach porterà a un errore di compilazione: un’operazione terminale restituisce void, non un nuovo stream.
2. Metodo peek: sbirciare senza intervenire
peek è un’operazione intermedia. Consente di eseguire un’azione per ogni elemento in una fase specifica dell’elaborazione, senza modificare l’elemento stesso e senza chiudere lo stream.
Firma del metodo:
Stream<T> peek(Consumer<? super T> action)
- peek restituisce un nuovo stream, in cui per ogni elemento verrà eseguita l’azione action.
- Di solito è usato per il debug, il logging o il monitoraggio dello stato dello stream.
Esempio: logging dopo il filtro
List<String> users = List.of("Anna", "Boris", "Alex", "Alina", "Dmitry");
List<Integer> nameLengths = users.stream()
.filter(name -> name.startsWith("A"))
.peek(name -> System.out.println("Passato il filtro: " + name))
.map(String::length)
.collect(Collectors.toList());
Risultato in console:
Passato il filtro: Anna
Passato il filtro: Alex
Passato il filtro: Alina
Contenuto di nameLengths:
[4, 4, 5]
Dove è comodo usare peek?
- Per fare debug della catena di operazioni: vedere cosa succede a ogni fase.
- Per raccogliere statistiche (ad esempio, contare gli elementi).
- Per fare logging dei dati nelle fasi intermedie.
Importante: peek non dovrebbe essere usato per modificare gli elementi dello stream. Per le trasformazioni c’è map. peek significa «sbirciare», non «intervenire».
3. forEach vs peek: qual è la differenza?
| Metodo | Tipo di operazione | Quando si applica | Si può continuare la catena? | Per cosa è più adatto |
|---|---|---|---|---|
|
Terminale | Alla fine dell’elaborazione dello stream | No | Azioni finali (stampa, logging, scrittura nel DB) |
|
Intermedia | Nel mezzo della catena di operazioni | Sì | Debug, logging intermedio, conteggio |
Esempio: differenza d’uso
// Esempio con forEach
users.stream()
.filter(name -> name.startsWith("A"))
.map(String::toUpperCase)
.forEach(System.out::println); // Qui lo stream termina
// Esempio con peek
users.stream()
.filter(name -> name.startsWith("A"))
.peek(name -> System.out.println("Filtro superato: " + name))
.map(String::toUpperCase)
.collect(Collectors.toList()); // Si può continuare la catena
Da ricordare
- forEach è un punto di non ritorno: dopo di esso non si può più fare nulla con lo stream.
- peek non garantisce l’esecuzione delle azioni se non viene chiamata un’operazione terminale. Se si scrive solo una catena di operazioni intermedie, non succederà nulla.
4. Aspetti non ovvi: forEach non è sempre la scelta migliore!
Perché non conviene usare forEach per modificare le collezioni?
Molti principianti provano a usare forEach per modificare gli elementi di una collezione o la collezione stessa (ad esempio, rimuovere elementi). Ma è una cattiva pratica: gli stream non sono pensati per modificare le collezioni di origine.
Esempio di uso scorretto:
List<String> names = new ArrayList<>(List.of("Anna", "Boris", "Alex"));
names.stream().forEach(name -> {
if (name.startsWith("A")) {
names.remove(name); // Può portare a ConcurrentModificationException!
}
});
Risultato: errore a runtime — non si può modificare la collezione durante l’iterazione dello stream (ConcurrentModificationException).
Per cosa usare comunque forEach?
- Per la stampa a video (ad esempio, stampare un report).
- Per il logging.
- Per chiamare servizi esterni (ad esempio, invio di email).
- Per raccogliere statistiche (ad esempio, incrementare un contatore).
5. Ancora su peek: solo per il debug!
La tentazione è usare peek per modificare gli elementi, per esempio aumentare l’età dell’utente:
users.stream()
.peek(user -> user.setAge(user.getAge() + 1)) // Male!
.collect(Collectors.toList());
Perché è sbagliato?
- Viola la natura dichiarativa e la purezza delle Stream API.
- Questo codice diventa difficile da mantenere e testare.
- Gli effetti collaterali in un’operazione intermedia possono portare a bug non ovvi.
Meglio usare map per trasformare i dati:
List<User> olderUsers = users.stream()
.map(user -> new User(user.getName(), user.getAge() + 1))
.collect(Collectors.toList());
Schema: differenza tra forEach e peek
users.stream()
.filter(...) // operazione intermedia
.peek(...) // operazione intermedia, "sbirciamo"
.map(...) // operazione intermedia
.forEach(...) // operazione terminale, "eseguiamo un'azione"
Spiegazione:
— Tutto ciò che è prima di forEach si può combinare, riordinare, aggiungere.
— Dopo forEach lo stream è chiuso.
6. Errori tipici nell’uso di forEach e peek
Errore n. 1: usare peek per modificare i dati. peek è pensato solo per l’osservazione, non per modificare gli elementi dello stream. Per le trasformazioni usate map.
Errore n. 2: aspettarsi che peek venga sempre eseguito. peek viene eseguito solo se dopo c’è un’operazione terminale (collect, forEach, count, ecc.). Senza un’operazione terminale non succederà nulla.
Errore n. 3: tentare di continuare lo stream dopo forEach. forEach è un’operazione terminale. Dopo di essa non si possono chiamare altri metodi dello stream.
Errore n. 4: modificare la collezione all’interno di forEach. Modificare la collezione di origine (rimuovere o aggiungere elementi) durante l’iterazione tramite forEach porta direttamente a ConcurrentModificationException.
Errore n. 5: usare forEach invece di collect per raccogliere il risultato. Se volete raccogliere gli elementi in una nuova collezione, usate collect(Collectors.toList()), non forEach con aggiunta manuale. Ciò viola la dichiaratività e può portare a errori in scenari multithread.
GO TO FULL VERSION