W3docs

Java Stream Collectors

Java-Streams mit java.util.stream.Collectors zu Collections und anderen Ergebnissen reduzieren.

collect ist das terminale Gegenstück, das wir aufgeschoben haben. Es nimmt einen Collector<T, A, R> — ein Rezept, das Stream-Elemente über einen Zwischenbehälter A in ein Ergebnis R akkumuliert — und führt es aus. Die Rezepte befinden sich in der Factory-Klasse java.util.stream.Collectors und decken das meiste ab, was man sonst mit einer for-Schleife, einer Map und einigen compute*-Aufrufen von Hand schreiben würde. Sobald man groupingBy(..., counting()) lesen kann, wirkt die API nicht mehr kryptisch.

Das Kapitel geht die Werkzeugsammlung danach durch, was das Ergebnis sein soll: eine Liste, ein Set, eine Map, eine einzelne Zahl, ein String oder — über das Downstream-Muster — eine verschachtelte Kombination davon.

Listen, Sets und spezifische Collections

Die zwei grundlegenden:

List<String> list = words.stream().collect(Collectors.toList());
Set<String>  set  = words.stream().collect(Collectors.toSet());

Hinweise:

  • Collectors.toList() gibt irgendeine List zurück — normalerweise veränderlich, aber nicht garantiert. Für die unveränderliche Form, die man meistens will, verwendet man stream.toList() (das Terminal, nicht den Collector).
  • Collectors.toSet() ist ungeordnet — typischerweise ein HashSet. Braucht man eine stabile Iterationsreihenfolge, fordert man sie explizit mit toCollection(LinkedHashSet::new) an.
  • Collectors.toUnmodifiableList() und toUnmodifiableSet() (Java 10+) liefern unveränderliche Ergebnisse — das sind die Collector-Entsprechungen von stream.toList().

Für eine spezifische Implementierung verwendet man toCollection:

ArrayDeque<String> queue = words.stream()
    .collect(Collectors.toCollection(ArrayDeque::new));

TreeSet<String> sorted = words.stream()
    .collect(Collectors.toCollection(TreeSet::new));

Der Supplier ist eine Konstruktorreferenz; der Collector verdrahtet ihn, leert den Stream hinein und gibt ihn zurück.

toMap — jedes Element einem Schlüssel zuordnen

toMap(keyMapper, valueMapper) wandelt jedes Element in einen Map.Entry um und akkumuliert sie:

Map<String, Integer> nameAge = people.stream()
    .collect(Collectors.toMap(Person::name, Person::age));

Doppelte Schlüssel werfen IllegalStateException. Das ist die eine Regel, die jeden beim ersten Mal erwischt. Teilen sich zwei Person-Objekte einen Namen, bricht das Standard-toMap ab. Die Lösung ist die Überladung mit einer Merge-Funktion:

Map<String, Integer> sumAgePerName = people.stream()
    .collect(Collectors.toMap(
        Person::name,
        Person::age,
        Integer::sum));                 // merge: existingAge + newAge

Für einen spezifischen Map-Typ — LinkedHashMap, um die Einfügereihenfolge zu bewahren, TreeMap, um Schlüssel sortiert zu halten — übergibt man einen Supplier:

Map<String, Integer> ordered = people.stream()
    .collect(Collectors.toMap(
        Person::name, Person::age,
        (a, b) -> a,                    // keep first on collision
        LinkedHashMap::new));

toUnmodifiableMap ist die unveränderliche Variante (Java 10+).

groupingBy — nach Schlüssel in Gruppen aufteilen

Der Collector, zu dem jeder greift, sobald er merkt, dass toMap nicht das richtige Werkzeug ist:

Map<String, List<Person>> byRole = people.stream()
    .collect(Collectors.groupingBy(Person::role));

Für jedes Element erzeugt der Klassifikator einen Schlüssel, und das Element wird dem Bucket dieses Schlüssels hinzugefügt (Standard-Downstream: toList()). Vergleich zu toMap:

ErzeugtBei doppeltem Schlüssel
toMapMap<K, V> (ein V pro K)Wirft, außer man gibt einen Merger an
groupingByMap<K, List<V>> (ein Bucket pro K)Fügt zum Bucket hinzu

toMap verwendet man, wenn es per Design höchstens einen Wert pro Schlüssel gibt (id → Zeile, Code → Label). groupingBy verwendet man, wenn es viele gibt.

Die volle Stärke von groupingBy kommt vom Downstream-Parameter, der bestimmt, was mit den Elementen passiert, die denselben Schlüssel teilen. Der Standard ist toList; man kann ihn durch einen anderen Collector ersetzen — und dieser Collector kann selbst ein groupingBy sein. Der Abschnitt "Downstream" des Kapitels weiter unten ist der Ort, wo die API sich wirklich entfaltet.

partitioningBy — nach einem Prädikat aufteilen

Ein spezialisiertes groupingBy für binäre Prädikate. Gibt eine Map<Boolean, List<T>> zurück:

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 enthält immer sowohl den true- als auch den false-Schlüssel, auch wenn ein Bucket leer ist. Das ist der einzige Vorteil gegenüber groupingBy(p -> p.age() >= 18) — welches den Schlüssel weglassen würde, wenn der Bucket leer ist.

Wie groupingBy akzeptiert partitioningBy einen Downstream-Collector.

counting, summingInt, averagingDouble, minBy, maxBy

Die Downstream-Collectors, die eine einzelne Zahl pro Bucket erzeugen:

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, die Größe des Buckets.
  • summingInt/Long/Double(toX) — Summe des projizierten Primitivwerts.
  • averagingInt/Long/Double(toX)Double-Durchschnitt.
  • minBy(cmp) / maxBy(cmp)Optional<T> Extremwert.
  • summarizingInt/Long/Double(toX)IntSummaryStatistics / usw., das vollständige Bündel aus Anzahl/Summe/Min/Max/Durchschnitt.

joining — Strings verketten

Für Streams von CharSequence:

String csv = words.stream().collect(Collectors.joining(","));
String pretty = words.stream().collect(Collectors.joining(", ", "[", "]"));

Drei Überladungen: ohne Argument (nur verketten), ein Argument als Trennzeichen, drei Argumente als Trennzeichen + Präfix + Suffix. Schneller als reduce("", String::concat), weil intern ein StringBuilder verwendet wird und keine quadratische Anzahl von Objekten alloziert. Das richtige Werkzeug, wenn das Ergebnis der Pipeline ein einzelner String ist.

mapping — erst transformieren, dann sammeln

Hüllt einen anderen Collector ein, sodass Elemente zuerst transformiert werden. Der häufigste Einsatz ist innerhalb von groupingBy, wenn man nach einem Kriterium gruppieren, aber eine Projektion der Elemente statt der Elemente selbst sammeln möchte:

Map<String, List<String>> namesByRole = people.stream()
    .collect(Collectors.groupingBy(
        Person::role,
        Collectors.mapping(Person::name, Collectors.toList())));

Ohne mapping würde das Downstream-toList() ganze Person-Objekte sammeln; mit mapping(Person::name, ...) werden nur die Namen gesammelt. Verwenden, wann immer man sonst groupingBy(...).entrySet().stream().map(...).collect(...) in zwei Durchläufen hintereinander schreiben würde.

filtering (Java 9+) ist das entsprechende "Vorher-einige-aussondern"-Wrapper-Gegenstück:

Map<String, List<Person>> adultsByRole = people.stream()
    .collect(Collectors.groupingBy(
        Person::role,
        Collectors.filtering(p -> p.age() >= 18, Collectors.toList())));

Der Unterschied zu stream.filter(...) vor dem Collector: filtering behält den Schlüssel im Ergebnis-Map auch dann, wenn kein Element den Filter passiert — dessen Bucket ist dann einfach leer.

reducing — allgemeine Reduktion als Collector

Die Collector-Form von reduce, die als Downstream verwendet wird, wenn die Standardvarianten nicht passen:

Map<String, Optional<Person>> oldestPerRole = people.stream()
    .collect(Collectors.groupingBy(
        Person::role,
        Collectors.reducing(BinaryOperator.maxBy(Comparator.comparingInt(Person::age)))));

Es gibt drei Überladungen (ein Argument, zwei Argumente mit Identität, drei Argumente mit Identität + Mapper + Akkumulator), die den drei reduce-Formen aus dem vorherigen Kapitel entsprechen. Die Zweiargument-Form ist als Downstream am gebräuchlichsten, weil sie ein einfaches T statt eines Optional<T> zurückgibt.

reducing schreibt man selten an der Spitze einer Pipeline — dafür ist reduce das Terminal. Man schreibt es als Downstream von groupingBy/partitioningBy, wenn man eine Reduktion pro Bucket möchte.

collectingAndThen — das Ergebnis nachbearbeiten

Hüllt einen Collector mit einer abschließenden Funktion ein. Der typische Anwendungsfall ist, eine gesammelte List/Map unveränderlich zu machen oder einen Endwert aus einem summarizing*-Ergebnis zu extrahieren:

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));

So wandelt man auch groupingBy(..., minBy(...)) in einen einfachen Wert statt Optional<T> um — der Finisher entpackt das Optional mit einem bekannten Standardwert.

teeing — zwei Collectors in einem Durchlauf ausführen

(Java 12+) Jedes Element wird gleichzeitig zwei Collectors übergeben und ihre Ergebnisse werden kombiniert:

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())));

Beide Teil-Collectors sehen jedes Element; der Merger erhält ihre zwei Ergebnisse. Nützlich, wenn man sonst zweimal streamen würde — z. B. um Durchschnitt und Ausreißer zu berechnen.

Den richtigen Collector wählen

Das Ergebnis soll seinVerwende
List<T> (unveränderlich, häufig)stream.toList() (Terminal)
List<T> (veränderlich)Collectors.toList() oder toCollection(ArrayList::new)
Set<T>Collectors.toSet() oder toCollection(LinkedHashSet::new)
Spezifische CollectionCollectors.toCollection(supplier)
Map<K, V> eins-zu-einsCollectors.toMap(k, v) (+ Merger bei Bedarf)
Map<K, List<T>> BucketsCollectors.groupingBy(k)
Map<Boolean, List<T>>Collectors.partitioningBy(pred)
Einzelner StringCollectors.joining(delim, pre, suf)
Anzahl/Summe/Durchschnitt pro BucketgroupingBy(k, counting() / summingInt(...) / ...)
Projektion pro BucketgroupingBy(k, mapping(proj, toList()))
Extremwert pro BucketgroupingBy(k, minBy(cmp) / maxBy(cmp))
Benutzerdefinierte Reduktion pro BucketgroupingBy(k, reducing(...))
Zwei Ergebnisse in einem DurchlaufCollectors.teeing(c1, c2, merger)
Ergebnis unveränderlich machenin collectingAndThen(c, Collections::unmodifiableList) einwickeln

Ein ausgearbeitetes Beispiel: alle Collectors auf einem Datensatz

Das folgende Programm erstellt eine Liste von Person-Records und wendet jede Collector-Form darauf an.

java— editable, runs on the server

Was man aus dem Lauf mitnehmen sollte:

  • Das ungeschützte toMap(Person::name, Person::age) am Ende hat IllegalStateException geworfen, weil zwei Person-Objekte den Namen "Alice" teilen. Die Standardlösung ist ein drittes Argument: ein BinaryOperator<V>, der bestimmt, wie Werte zusammengeführt werden, wenn Schlüssel kollidieren. Den Merger entsprechend der eigenen Semantik wählen (ersten behalten, letzten behalten, summieren, verketten) — genau das hat der frühere ageByName-Aufruf mit (a, b) -> a getan.
  • groupingBy(Person::role) hat kostenfrei eine Map<String, List<Person>> erzeugt. Das Standard-Downstream-toList() durch counting(), summingInt(...), averagingDouble(...) oder maxBy(...) zu ersetzen, hat das Bucket-Ergebnis von "eine Liste" zu einer einzelnen Zahl gemacht — gleiche Pipeline-Struktur, anderes Rezept im Downstream-Slot.
  • mapping(Person::name, toList()) ist die Antwort auf "Ich möchte nach Rolle gruppieren, aber meine Buckets sollen nur Namen enthalten, keine ganzen Person-Objekte." Downstream vorab zu projizieren ist fast immer sauberer als ganze Records zu sammeln und dann die Werte zu mappen.
  • partitioningBy hat sowohl true- als auch false-Schlüssel zurückgegeben, auch wenn eine Hälfte leer hätte sein können. Diese Vorhersagbarkeit ist sein Daseinszweck gegenüber groupingBy(predicate).
  • teeing hat min und max in einem einzigen Durchlauf gesammelt und dann beide Optionals an einen Merger übergeben, der den Range-Record erstellt hat. Wann immer man sonst zweimal streamen würde, um zwei Zusammenfassungen zu erhalten, sollte man zu teeing greifen.
  • collectingAndThen(toList(), Collections::unmodifiableList) ist der klassische Finisher-Trick; die gleiche Form entpackt ein groupingBy(..., maxBy(...)) von Map<K, Optional<V>> zu Map<K, V>, wenn bereits bewiesen ist, dass jeder Bucket nicht leer ist.

Wie geht es weiter

Jeder Collector und jede Zwischenstufe im bisherigen Teil läuft standardmäßig sequenziell — ein Element nach dem anderen, in Begegnungsreihenfolge, im aufrufenden Thread. Das nächste Kapitel, Java Parallel Streams, stellt die alternative Ausführungsplanung vor — parallelStream() und stream().parallel() — was sicher in eine parallele Pipeline gehört, was nicht (gemeinsam genutzten Zustand mutieren, reihenfolgeempfindliches forEach, nicht-assoziatives reduce), und wie man erkennt, ob Parallelität tatsächlich hilft oder das Programm langsamer macht.

Übung

Übung
`people.stream().collect(Collectors.toMap(Person::name, Person::age))` wirft `IllegalStateException`, wenn zwei `Person`-Objekte denselben Namen teilen. Welche Lösung entspricht der Absicht 'ihre Alter summieren, wenn Namen kollidieren'?
`people.stream().collect(Collectors.toMap(Person::name, Person::age))` wirft `IllegalStateException`, wenn zwei `Person`-Objekte denselben Namen teilen. Welche Lösung entspricht der Absicht 'ihre Alter summieren, wenn Namen kollidieren'?
Was this page helpful?