Java 8: Stream::reduce vs. Stream::collect
Die Schnittstelle java.util.stream.Stream
der mit Java 8 eingeführten Stream-API besitzt zwei Methoden, die sehr ähnlich aussehen und doch sehr unterschiedlich sind: reduce
und collect
. Ihre Signaturen sind im folgenden Listing zu sehen. Im Folgenden betrachte ich, was die beiden Methoden tatsächlich gemeinsam haben – und viel wichtiger – was sie unterscheidet. Denn Letzteres ist in vielen Fällen nicht so offensichtlich, wie es vielleicht sein sollte.
interface Stream<T> {
<U> U reduce(U identity,
BiFunction<U, ? super T, U> accumulator,
BinaryOperator<U> combiner);
<U> U collect(Supplier<U> supplier,
BiConsumer<U, ? super T> accumulator,
BiConsumer<U, U> combiner);
}
Verwechslungsgefahr
Das folgende Listing zeigt die Verwandtschaft der beiden Methoden reduce
und collect
. Die Argumente sind fast identisch, und man darf sich selbst davon überzeugen, dass aufgerufen mit Stream.of("foo", "bar", "baz")
in beiden Fällen als Ergebnis "foobarbaz"
zurückkommt.
String joinWithReduce(Stream<String> stream) { // BAD
return stream.reduce(new StringBuilder(),
StringBuilder::append, StringBuilder::append).toString();
}
String joinWithCollect(Stream<String> stream) { // OK
return stream.collect(StringBuilder::new,
StringBuilder::append, StringBuilder::append).toString();
}
Doch der Eindruck täuscht. Tatsächlich ist nur die Implementierung mit collect
korrekt. Die Implementierung mit reduce
liefert dagegen nur zufälligerweise das richtige Ergebnis. Interessanterweise liefert sie mit der aktuellen Java 8-Laufzeitumgebung sogar für die meisten Testeingaben zufälligerweise
das richtige Ergebnis. Doch das hat nichts zu bedeuten. Denn mit Tests lässt sich schließlich nur die Existenz von Fehlern feststellen – jedoch nicht deren Abwesenheit. Und die Spezifikation der reduce
-Methode macht klar, dass obige Verwendung falsch ist.
Immutable Reduction
Die reduce
-Methode eignet sich ausschließlich für Reduktionen mit reinen Funktionen. Damit sind Funktionen gemeint, die beim Aufruf weder ihren eigenen Zustand, den Zustand der Übergabeparameter noch irgendwelchen global sichtbaren Zustand ändern. Sie geben lediglich eine Referenz auf ein Objekt zurück, das üblicherweise entweder neu erzeugt wird oder zumindest unveränderlich ist – in jedem Fall aber keine Auswirkungen auf den sonstigen Programmzustand hat.
Eine typische Verwendung ist im folgenden Listing zu sehen. Dort wird die reduce
-Methode verwendet, um die Gesamtsumme mehrere Auftragspositionen zu bestimmen. Entscheidend ist in diesem Fall, dass Instanzen der Klasse BigDecimal
unveränderlich sind, und die Methode BigDecimal::add
die Summe in einem neuen Objekt liefert.
BigDecimal getTotalAmount(Stream<OrderItem> orderItems) {
return orderItems.stream().reduce(
BigDecimal.ZERO,
(lhs, rhs) -> lhs.add(rhs.getAmount()),
BigDecimal::add);
}
Da auch Instanzen der Klasse String
unveränderlich sind, überrascht es nicht, dass sich die reduce
-Methode auch dafür verwenden lässt. Die Methode im folgenden Listing verknüpft die übergebenen Zeichenketten. Im Gegensatz zu obiger Variante mit StringBuilder
ist diese Implementierung sogar ganz formal korrekt.
String joinWithReduce(Stream<String> stream) {
return stream.reduce("", String::concat, String::concat);
}
Diese Implementierung sieht zwar elegant aus, doch leider sie hat ein ernst zu nehmendes Problem: Die Laufzeit ist quadratisch in der Länge der Eingabe. Darüber kann selbst die potentiell parallele Ausführung nicht hinwegtrösten.
Dieses Problem lässt sich mit reduce
nicht wirklich lösen. Oder anders formuliert: Unveränderliche Objekte sind zwar häufig einfacher zu verwenden – effizienter sind sie jedoch nur, wenn sie speziell auf die Verwendung optimiert wurden. In der Standardbibliothek von Java 8 ist das nicht der Fall, und daher gibt es eben auch eine Reduktion mit veränderlichen Objekten.
Mutable Reduction
Die Methode collect
macht eigentlich das Gleiche wie reduce
. Der Unterschied ist nur, dass Erstere die Änderung im linken Parameter durchführt, wohingegen Letztere dafür den Rückgabewert verwendet. Das typische Beispiel ist die Erzeugung einer List
, wie im folgenden Listing zu sehen.
<T> List<T> asList(Stream<T> stream) {
return stream.collect(ArrayList::new, List::add, List::addAll);
}
Im Gegensatz zu obigem Beispiel mit StringBuilder
kann man an dieser Stelle reduce
und collect
nicht miteinander verwechseln. Denn die Rückgabetypen der beiden Methoden-Referenzen erlauben das nicht. Umgekehrt formuliert: Obige Verwechslung war nur möglich, weil die Methoden von StringBuilder
eine Referenz auf this
zurückgeben, was zwar einerseits ein Fluent Interface unterstützt, jedoch anderseits aus mathematischer Sicht wenig intuitiv erscheint.
Das dritte Argument wird übrigens insbesondere für die parallele Abarbeitung benötigt, die von allen gezeigten Beispielen unterstützt wird, und sich insbesondere bei langen Streams positiv bemerkbar macht. Für die sequentielle Abarbeitung werden sie dagegen bisher noch nicht verwendet, obwohl sie auch in diesen Fällen die Laufzeit verbessern können. Man sollte sich also nicht darauf verlassen.
Die Reduktion mit collect
ist in Java sehr bedeutend. Um die Verwendung zu vereinfachen und die Wiederverwendung zu unterstützen, gibt es eine überladene Methode mit einem Collector
als Argument, der im Wesentlichen die drei Argumente zusammenfasst. In der Klasse Collectors
finden sich einige Hilfsfunktionen für die häufigsten Anwendungsfälle. So gibt es dort beispielsweise die Methode toList
, die obigem Beispiel entspricht, sowie die Methode joining
für die Verkettung von Zeichenketten, wie im folgenden Listing zu sehen.
String joinWithCollector(Stream<String> stream) {
return stream.collect(Collectors.joining());
}
Merken sollte man sich also: Die Methode reduce
sollte man ausschließlich mit reinen Funktionen einsetzen, wohingegen man die Methode collect
ausschließlich mit Methoden einsetzen sollte, die Änderungen im linken Argument durchführen. Alle anderen Fälle sind auf diese beiden Varianten zurückzuführen.