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 irgendeineListzurück — normalerweise veränderlich, aber nicht garantiert. Für die unveränderliche Form, die man meistens will, verwendet manstream.toList()(das Terminal, nicht den Collector).Collectors.toSet()ist ungeordnet — typischerweise einHashSet. Braucht man eine stabile Iterationsreihenfolge, fordert man sie explizit mittoCollection(LinkedHashSet::new)an.Collectors.toUnmodifiableList()undtoUnmodifiableSet()(Java 10+) liefern unveränderliche Ergebnisse — das sind die Collector-Entsprechungen vonstream.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 + newAgeFü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:
| Erzeugt | Bei doppeltem Schlüssel | |
|---|---|---|
toMap | Map<K, V> (ein V pro K) | Wirft, außer man gibt einen Merger an |
groupingBy | Map<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 sein | Verwende |
|---|---|
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 Collection | Collectors.toCollection(supplier) |
Map<K, V> eins-zu-eins | Collectors.toMap(k, v) (+ Merger bei Bedarf) |
Map<K, List<T>> Buckets | Collectors.groupingBy(k) |
Map<Boolean, List<T>> | Collectors.partitioningBy(pred) |
| Einzelner String | Collectors.joining(delim, pre, suf) |
| Anzahl/Summe/Durchschnitt pro Bucket | groupingBy(k, counting() / summingInt(...) / ...) |
| Projektion pro Bucket | groupingBy(k, mapping(proj, toList())) |
| Extremwert pro Bucket | groupingBy(k, minBy(cmp) / maxBy(cmp)) |
| Benutzerdefinierte Reduktion pro Bucket | groupingBy(k, reducing(...)) |
| Zwei Ergebnisse in einem Durchlauf | Collectors.teeing(c1, c2, merger) |
| Ergebnis unveränderlich machen | in 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.
Was man aus dem Lauf mitnehmen sollte:
- Das ungeschützte
toMap(Person::name, Person::age)am Ende hatIllegalStateExceptiongeworfen, weil zweiPerson-Objekte den Namen "Alice" teilen. Die Standardlösung ist ein drittes Argument: einBinaryOperator<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ühereageByName-Aufruf mit(a, b) -> agetan. groupingBy(Person::role)hat kostenfrei eineMap<String, List<Person>>erzeugt. Das Standard-Downstream-toList()durchcounting(),summingInt(...),averagingDouble(...)odermaxBy(...)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 ganzenPerson-Objekte." Downstream vorab zu projizieren ist fast immer sauberer als ganze Records zu sammeln und dann die Werte zu mappen.partitioningByhat sowohltrue- als auchfalse-Schlüssel zurückgegeben, auch wenn eine Hälfte leer hätte sein können. Diese Vorhersagbarkeit ist sein Daseinszweck gegenübergroupingBy(predicate).teeinghatminundmaxin einem einzigen Durchlauf gesammelt und dann beideOptionals an einen Merger übergeben, der denRange-Record erstellt hat. Wann immer man sonst zweimal streamen würde, um zwei Zusammenfassungen zu erhalten, sollte man zuteeinggreifen.collectingAndThen(toList(), Collections::unmodifiableList)ist der klassische Finisher-Trick; die gleiche Form entpackt eingroupingBy(..., maxBy(...))vonMap<K, Optional<V>>zuMap<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.