W3docs

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 qualche List — di solito mutabile, ma non garantito. Per la forma non modificabile che si usa più spesso, si usa stream.toList() (il terminale, non il collector).
  • Collectors.toSet() è non ordinato — tipicamente HashSet. Se si ha bisogno di un ordine di iterazione stabile, lo si richiede esplicitamente con toCollection(LinkedHashSet::new).
  • Collectors.toUnmodifiableList() e toUnmodifiableSet() (Java 10+) restituiscono risultati immutabili — sono gli equivalenti nella forma collector di stream.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 + newAge

Per 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:

ProduceSu chiave duplicata
toMapMap<K, V> (un V per K)Lancia un'eccezione se non si fornisce un merger
groupingByMap<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) — media Double.
  • minBy(cmp) / maxBy(cmp) — estremo Optional<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 siaUsare
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 specificaCollectors.toCollection(supplier)
Map<K, V> uno-a-unoCollectors.toMap(k, v) (+ merger se necessario)
Map<K, List<T>> bucketCollectors.groupingBy(k)
Map<Boolean, List<T>>Collectors.partitioningBy(pred)
Stringa singolaCollectors.joining(delim, pre, suf)
Conteggio/somma/media per bucketgroupingBy(k, counting() / summingInt(...) / ...)
Proiezione per bucketgroupingBy(k, mapping(proj, toList()))
Estremo per bucketgroupingBy(k, minBy(cmp) / maxBy(cmp))
Riduzione personalizzata per bucketgroupingBy(k, reducing(...))
Due risultati in un solo passaggioCollectors.teeing(c1, c2, merger)
Rendere il risultato non modificabileavvolgere 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.

java— editable, runs on the server

Cosa si impara dall'esecuzione:

  • Il toMap(Person::name, Person::age) non protetto alla fine ha lanciato IllegalStateException perché due Person condividono il nome "Alice". La soluzione standard è un terzo argomento: un BinaryOperator<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 chiamata ageByName con (a, b) -> a.
  • groupingBy(Person::role) ha prodotto gratuitamente una Map<String, List<Person>>. Sostituire il toList() downstream predefinito con counting(), summingInt(...), averagingDouble(...) o maxBy(...) 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 interi Person". La pre-proiezione downstream è quasi sempre più pulita che raccogliere record interi e poi mappare i valori.
  • partitioningBy ha restituito entrambe le chiavi true e false anche quando una metà avrebbe potuto essere vuota. Questa prevedibilità è la sua ragion d'essere rispetto a groupingBy(predicate).
  • teeing ha raccolto min e max in un singolo passaggio, poi ha passato entrambi gli Optional a un merger che ha costruito il record Range. Ogni volta che altrimenti si farebbe lo stream due volte per due riepiloghi, si ricorre a teeing.
  • collectingAndThen(toList(), Collections::unmodifiableList) è il classico trucco del finisher; la stessa forma sblocca un groupingBy(..., maxBy(...)) da Map<K, Optional<V>> a Map<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.

Pratica

Pratica
`people.stream().collect(Collectors.toMap(Person::name, Person::age))` lancia `IllegalStateException` quando due `Person` condividono un nome. Quale soluzione corrisponde all'intento 'sommare le loro età quando i nomi collidono'?
`people.stream().collect(Collectors.toMap(Person::name, Person::age))` lancia `IllegalStateException` quando due `Person` condividono un nome. Quale soluzione corrisponde all'intento 'sommare le loro età quando i nomi collidono'?
Was this page helpful?