Java Stream Collectors
Riduci gli stream Java a collezioni e altri risultati con java.util.stream.Collectors.
collect è il terminale che avevamo rimandato. Accetta un Collector<T, A, R> — una ricetta per accumulare gli elementi di uno stream in un risultato R attraverso un contenitore intermedio A — e lo esegue. Le ricette si trovano nella classe factory java.util.stream.Collectors e coprono la maggior parte di ciò che altrimenti si scriverebbe a mano con un ciclo for, una Map e qualche chiamata a compute*. Una volta che si sa leggere groupingBy(..., counting()), l'API smette di sembrare criptica.
Il capitolo percorre la cassetta degli attrezzi in base a ciò che si vuole come risultato: una lista, un insieme, una mappa, un singolo numero, una stringa o — tramite il pattern downstream — una combinazione annidata di qualsiasi di essi.
Liste, insiemi e collezioni specifiche
I due fondamentali:
List<String> list = words.stream().collect(Collectors.toList());
Set<String> set = words.stream().collect(Collectors.toSet());Note:
Collectors.toList()restituisce qualcheList— di solito mutabile, ma non garantito. Per la forma non modificabile che si usa più spesso, si usastream.toList()(il terminale, non il collector).Collectors.toSet()è non ordinato — tipicamenteHashSet. Se si ha bisogno di un ordine di iterazione stabile, lo si richiede esplicitamente contoCollection(LinkedHashSet::new).Collectors.toUnmodifiableList()etoUnmodifiableSet()(Java 10+) restituiscono risultati immutabili — sono gli equivalenti nella forma collector distream.toList().
Per un'implementazione specifica, si usa toCollection:
ArrayDeque<String> queue = words.stream()
.collect(Collectors.toCollection(ArrayDeque::new));
TreeSet<String> sorted = words.stream()
.collect(Collectors.toCollection(TreeSet::new));Il supplier è un riferimento a costruttore; il collector lo collega, scarica lo stream al suo interno e lo restituisce.
toMap — associare ogni elemento a una chiave
toMap(keyMapper, valueMapper) trasforma ogni elemento in un Map.Entry e li accumula:
Map<String, Integer> nameAge = people.stream()
.collect(Collectors.toMap(Person::name, Person::age));Le chiavi duplicate lanciano IllegalStateException. È la regola che coglie tutti di sorpresa la prima volta. Se due Person condividono un nome, il toMap predefinito si blocca. La soluzione è l'overload con la funzione di merge:
Map<String, Integer> sumAgePerName = people.stream()
.collect(Collectors.toMap(
Person::name,
Person::age,
Integer::sum)); // merge: existingAge + newAgePer un tipo di mappa specifico — LinkedHashMap per preservare l'ordine di inserimento, TreeMap per mantenere le chiavi ordinate — si passa un supplier:
Map<String, Integer> ordered = people.stream()
.collect(Collectors.toMap(
Person::name, Person::age,
(a, b) -> a, // keep first on collision
LinkedHashMap::new));toUnmodifiableMap è la variante immutabile (Java 10+).
groupingBy — suddividere in bucket per chiave
Il collector a cui tutti ricorrono una volta capito che toMap non è lo strumento giusto:
Map<String, List<Person>> byRole = people.stream()
.collect(Collectors.groupingBy(Person::role));Per ogni elemento il classificatore produce una chiave e l'elemento viene aggiunto al bucket di quella chiave (downstream predefinito: toList()). Confronto con toMap:
| Produce | Su chiave duplicata | |
|---|---|---|
toMap | Map<K, V> (un V per K) | Lancia un'eccezione se non si fornisce un merger |
groupingBy | Map<K, List<V>> (un bucket per K) | Aggiunge al bucket |
Si usa toMap quando per progetto esiste al massimo un valore per chiave (id → riga, codice → etichetta). Si usa groupingBy quando ce ne sono molti.
La piena potenza di groupingBy deriva dal suo parametro downstream, che indica cosa fare con gli elementi che condividono una chiave. Il predefinito è toList; lo si può sostituire con un altro collector — e quel collector può a sua volta essere un groupingBy. La sezione "downstream" del capitolo qui sotto è dove l'API si apre davvero.
partitioningBy — suddividere tramite un predicato
Un groupingBy specializzato per predicati binari. Restituisce una Map<Boolean, List<T>>:
Map<Boolean, List<Person>> adultsOrNot = people.stream()
.collect(Collectors.partitioningBy(p -> p.age() >= 18));
List<Person> adults = adultsOrNot.get(true);
List<Person> minors = adultsOrNot.get(false);partitioningBy contiene sempre entrambe le chiavi true e false, anche se un bucket è vuoto. Questo è l'unico vantaggio rispetto a groupingBy(p -> p.age() >= 18) — che ometterebbe la chiave se il bucket fosse vuoto.
Come groupingBy, partitioningBy accetta un collector downstream.
counting, summingInt, averagingDouble, minBy, maxBy
I collector downstream che producono un singolo numero per bucket:
Map<String, Long> headcount = people.stream()
.collect(Collectors.groupingBy(Person::role, Collectors.counting()));
Map<String, Integer> totalAgePerRole = people.stream()
.collect(Collectors.groupingBy(Person::role,
Collectors.summingInt(Person::age)));
Map<String, Double> avgAgePerRole = people.stream()
.collect(Collectors.groupingBy(Person::role,
Collectors.averagingDouble(Person::age)));
Map<String, Optional<Person>> oldestPerRole = people.stream()
.collect(Collectors.groupingBy(Person::role,
Collectors.maxBy(Comparator.comparingInt(Person::age))));counting()—Long, la dimensione del bucket.summingInt/Long/Double(toX)— somma del primitivo proiettato.averagingInt/Long/Double(toX)— mediaDouble.minBy(cmp)/maxBy(cmp)— estremoOptional<T>.summarizingInt/Long/Double(toX)—IntSummaryStatistics/ ecc., il pacchetto completo di conteggio/somma/min/max/media.
joining — concatenare stringhe
Per stream di CharSequence:
String csv = words.stream().collect(Collectors.joining(","));
String pretty = words.stream().collect(Collectors.joining(", ", "[", "]"));Tre overload: senza argomenti (semplice concatenazione), un argomento delimitatore, tre argomenti delimitatore + prefisso + suffisso. Più veloce di reduce("", String::concat) perché utilizza uno StringBuilder internamente e non alloca in modo quadratico. Lo strumento giusto quando il risultato della pipeline è una singola stringa.
mapping — trasformare poi raccogliere
Avvolge un altro collector in modo che gli elementi vengano trasformati prima. L'uso più comune è all'interno di groupingBy quando si vuole raggruppare per una cosa e raccogliere una proiezione degli elementi anziché gli elementi stessi:
Map<String, List<String>> namesByRole = people.stream()
.collect(Collectors.groupingBy(
Person::role,
Collectors.mapping(Person::name, Collectors.toList())));Senza mapping, il toList() downstream raccoglierebbe interi Person; con mapping(Person::name, ...), raccoglie solo i nomi. Si usa ogni volta che altrimenti si scriverebbe groupingBy(...).entrySet().stream().map(...).collect(...) in due passaggi consecutivi.
filtering (Java 9+) è il corrispondente wrapper "elimina alcuni prima di raccogliere":
Map<String, List<Person>> adultsByRole = people.stream()
.collect(Collectors.groupingBy(
Person::role,
Collectors.filtering(p -> p.age() >= 18, Collectors.toList())));La differenza rispetto a stream.filter(...) prima del collector: filtering mantiene la chiave nella mappa risultante anche quando nessun elemento supera il filtro — il suo bucket è semplicemente vuoto.
reducing — riduzione generale completa come collector
La forma collector di reduce, usata come downstream quando quelli standard non si adattano:
Map<String, Optional<Person>> oldestPerRole = people.stream()
.collect(Collectors.groupingBy(
Person::role,
Collectors.reducing(BinaryOperator.maxBy(Comparator.comparingInt(Person::age)))));Esistono tre overload (un argomento, due argomenti con identità, tre argomenti con identità + mapper + accumulatore) che corrispondono alle tre forme di reduce del capitolo precedente. La forma a due argomenti è la più comune come downstream perché restituisce un T semplice invece di un Optional<T>.
Raramente si scrive reducing in cima a una pipeline — reduce è il terminale per quello. Lo si scrive come downstream di groupingBy/partitioningBy quando si vuole una riduzione per bucket.
collectingAndThen — post-elaborare il risultato
Avvolge un collector con una funzione di finalizzazione. L'uso standard è rendere non modificabile una List/Map raccolta, o estrarre un valore finale da un risultato summarizing*:
List<String> immutableNames = people.stream()
.map(Person::name)
.collect(Collectors.collectingAndThen(
Collectors.toList(),
Collections::unmodifiableList));
Map<String, Long> immutableCounts = people.stream()
.collect(Collectors.collectingAndThen(
Collectors.groupingBy(Person::role, Collectors.counting()),
Collections::unmodifiableMap));È anche il modo per trasformare groupingBy(..., minBy(...)) in un valore semplice invece di Optional<T> — il finisher sblocca l'Optional con un valore predefinito noto.
teeing — eseguire due collector in un solo passaggio
(Java 12+) Fornire ogni elemento a due collector contemporaneamente e combinare i loro risultati:
record Range(int min, int max) {}
Range range = nums.stream()
.collect(Collectors.teeing(
Collectors.minBy(Integer::compare),
Collectors.maxBy(Integer::compare),
(lo, hi) -> new Range(lo.orElseThrow(), hi.orElseThrow())));I due collector figli vedono ciascuno ogni elemento; il merger riceve i loro due risultati. Utile quando altrimenti si dovrebbe fare lo stream due volte — ad esempio calcolare la media e individuare i valori anomali.
Scegliere il collector giusto
| Si vuole che il risultato sia | Usare |
|---|---|
List<T> (immutabile, caso comune) | stream.toList() (terminale) |
List<T> (mutabile) | Collectors.toList() o toCollection(ArrayList::new) |
Set<T> | Collectors.toSet() o toCollection(LinkedHashSet::new) |
| Collezione specifica | Collectors.toCollection(supplier) |
Map<K, V> uno-a-uno | Collectors.toMap(k, v) (+ merger se necessario) |
Map<K, List<T>> bucket | Collectors.groupingBy(k) |
Map<Boolean, List<T>> | Collectors.partitioningBy(pred) |
| Stringa singola | Collectors.joining(delim, pre, suf) |
| Conteggio/somma/media per bucket | groupingBy(k, counting() / summingInt(...) / ...) |
| Proiezione per bucket | groupingBy(k, mapping(proj, toList())) |
| Estremo per bucket | groupingBy(k, minBy(cmp) / maxBy(cmp)) |
| Riduzione personalizzata per bucket | groupingBy(k, reducing(...)) |
| Due risultati in un solo passaggio | Collectors.teeing(c1, c2, merger) |
| Rendere il risultato non modificabile | avvolgere in collectingAndThen(c, Collections::unmodifiableList) |
Un esempio pratico: ogni collector su un unico dataset
Il programma seguente crea una lista di record Person ed esegue ogni forma di collector su di essa.
Cosa si impara dall'esecuzione:
- Il
toMap(Person::name, Person::age)non protetto alla fine ha lanciatoIllegalStateExceptionperché duePersoncondividono il nome "Alice". La soluzione standard è un terzo argomento: unBinaryOperator<V>che indica come unire i valori quando le chiavi collidono. Si sceglie il merger in base alla propria semantica (tenere il primo, tenere l'ultimo, sommare, concatenare) — è quello che ha fatto la precedente chiamataageByNamecon(a, b) -> a. groupingBy(Person::role)ha prodotto gratuitamente unaMap<String, List<Person>>. Sostituire iltoList()downstream predefinito concounting(),summingInt(...),averagingDouble(...)omaxBy(...)ha trasformato il risultato per bucket da "una lista" a un singolo numero — stessa forma di pipeline, ricetta diversa nello slot downstream.mapping(Person::name, toList())è la risposta a "voglio raggruppare per ruolo, ma i miei bucket devono contenere solo nomi, non interiPerson". La pre-proiezione downstream è quasi sempre più pulita che raccogliere record interi e poi mappare i valori.partitioningByha restituito entrambe le chiavitrueefalseanche quando una metà avrebbe potuto essere vuota. Questa prevedibilità è la sua ragion d'essere rispetto agroupingBy(predicate).teeingha raccoltominemaxin un singolo passaggio, poi ha passato entrambi gliOptionala un merger che ha costruito il recordRange. Ogni volta che altrimenti si farebbe lo stream due volte per due riepiloghi, si ricorre ateeing.collectingAndThen(toList(), Collections::unmodifiableList)è il classico trucco del finisher; la stessa forma sblocca ungroupingBy(..., maxBy(...))daMap<K, Optional<V>>aMap<K, V>quando si è già verificato che ogni bucket è non vuoto.
Passo successivo
Ogni collector e ogni operazione intermedia nella parte finora descritta viene eseguita sequenzialmente per default — un elemento alla volta, nell'ordine di incontro, sul thread chiamante. Il capitolo successivo, Java Parallel Streams, introduce la schedulazione alternativa — parallelStream() e stream().parallel() — cosa è sicuro inserire in una pipeline parallela, cosa non lo è (mutare stato condiviso, forEach sensibile all'ordine, reduce non associativo) e come capire se il parallelismo aiuta davvero o rende il programma più lento.