Java 8: Stream::reduce vs. Stream::collect

von Hubert Schmid vom 2014-05-25

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.