© Excellent backgrounds/Shutterstock.com
Benutzerdefinierte Kollektoren

Richtig sammeln


Im letzten Beitrag unserer Serie über Lambdas und Streams in Java haben wir die Stream-Operationen reduce() und collect() verglichen. Dabei haben wir collect() zusammen mit dem StringBuilder als Zielcontainer benutzt. Diesmal wollen wir uns ansehen, wie mächtig die Funktionalität von collect() wird, wenn man sie mit einem selbstdefinierten Zielcontainertyp kombiniert.

Die Stream-Operation collect() ist eine terminale Operation, die alle Elemente des Inputstreams in einem Zielcontainer aufsammelt. Wir haben uns das letzte Mal [1] angesehen, dass man diese Variante nutzen kann, um beispielsweise die Elemente eines Stream<String> zu konkatenieren:

R collect(Supplier<R> supplier, BiConsumer<R,? super T> accumulator, BiConsumer<R,R> combiner)

Als Zielcontainer von collect() wird dabei ein StringBuilder benutzt, der nach dem collect() wieder mit toString() in einen String konvertiert wird. Die Implementierung sieht dann so aus:

String s = IntStream.range(0, 8) //.parallel() .mapToObj(Integer::toString) .collect(() -> new StringBuilder(), (StringBuilder sb1, String s1) -> sb1.append(s1), (StringBuilder sb1, StringBuilder sb2) -> sb1.append(sb2)) .toString(); System.out.println(s);

Der Operation collect() werden dabei drei Lambda-Ausdrücke übergeben: supplier, accumulator und combiner. Der supplier implementiert, wie ein Objekt des Zielcontainers (StringBuilder) erzeugt wird. Der accumulator implementiert, wie ein Element des Streams (String) in den Zielcontainer akkumuliert wird. Und der combiner implementiert, wie zwei Zielcontainer (StringBuilder) zusammen kombiniert werden. Bei einem sequenziellen Stream wird der supplier von collect() dazu benutzt, ein Zielcontainerobjekt zu konstruieren. Danach wird jedes Streamelement mithilfe des accumulators in dem Zielcontainerobjekt aufgesammelt. Der combiner wird nicht gebraucht.

Bei einem parallelen Stream [2] wird die Stream-­Source in der Fork-Phase in Segmente aufgeteilt. Für jedes Segment wird eine Task erzeugt. Diese Tasks werden anschließend in der Execution-Phase parallel ausgeführt. Bei der Ausführung konstruiert jede Task mithilfe des suppliers ein eigenes Zielcontainerobjekt und verwendet den accumulator, um die zur Task gehörenden Streamelemente in diesem Zielcontainerobjekt aufzusammeln. Danach werden in der Join-Phase die Zielcontainerobjekte aller Tasks mit dem combiner zum Gesamtergebnis zusammengeführt.

Bemerkenswert ist dabei die Tatsache, dass der Zielcontainer ein StringBuilder ist, der verändert wird – sowohl vom accumulator als auch vom combiner. Die Javadoc der collect()-Operation spricht deshalb von einer Mut­able-Reduction-Operation. Das verändernde Verhalten der collect()-Operation ist deshalb ungewöhnlich, weil das Vorbild für die Streamabstraktion in Java aus funktionalen Programmiersprachen stammt und dort Sequenz (Englisch: sequence) genannt wird. Sequenzen in funktionalen Sprachen haben aber typischerweise keine verändernden Operationen. Bei den Streams in Java ist es anders. Denn für Java reicht es nicht aus, nur unveränderliche Operationen im Streaminterface anzubieten, weil Java eine objektorientierte Programmiersprache mit veränderlichen Typen ist. Dann kann es natürlich auch veränderliche Zielcontainer geben. Genau dafür ist die collect()-Operation gedacht. Wir haben uns das verändernde Verhalten von collect() ausführlich beim letzten Mal angesehen, als wir dem collect() mit StringBuilder den reduce() mit String gegenübergestellt haben. Die veränderliche Reduktion mit collect() und StringBuilder ist deutlich performanter. Wir haben in einem Benchmark 2 000 Strings aus einem sequenziellen Stream konkateniert und festgestellt, dass collect() dabei rund 45 Mal schneller ist als reduce(). Es gibt also gute Gründe für die Existenz einer Mutable-Reduction-Operation in Java-Streams.

collect() und der Collector

Da es in diesem Artikel darum gehen soll, collect() mit einem selbstdefinierten Zielcontainertyp zu nutzen, wollen wir uns vorab ansehen, welche Möglichkeiten es grundsätzlich gibt, um einen Zielcontainertyp im collect() zu verwenden. Wir haben uns bisher nur diese collect()-Variante aus dem Streaminterface angesehen:

R collect(Supplier<R> supplier, BiConsumer<R,? super T> accumulator, BiConsumer<R,R> combiner)

Die funktionalen Parameter supplier, accumulator und combiner werden an collect() übergeben und in der Operation an geeigneter Stelle in der Implementierung genutzt. Genau das haben wir uns bereits oben in dem Beispiel angesehen. Alternativ gibt es noch eine weitere collect()-Operation im Streaminterface:

<R,A> R collect(Collector<? super T,A,R> collector)

Diese Methode hat nur einen Parameter: den collector vom Typ Collector. Vordefinierte Kollektoren (oder genauer gesagt: statische Factory-Methoden, die vordefinierte Kollektoren erzeugen) findet man in der Klasse java.util.stream.Collectors. Dort gibt es zum Beispiel die Factory-Methode joining(), die im Wesentlichen das Gleiche macht, wie wir oben in unserem Beispiel: Strings (sogar etwas allgemeiner CharacterSequences) konkatenieren. Das heißt, wenn man in der Praxis die Elemente eines Stream<String> konkatenieren will, kann man es sich leicht machen und nutzt den vordefinierten joining()-Kollektor, statt die collect()-Operation mit supplier, accumulator und combiner zu versorgen. Wir haben die komplizierte Variante hier im Artikel besprochen, weil sie uns später hilft, das Verständnis dafür zu entwickeln, wie sich ein eigener Zielcontainertyp implementieren lässt. Schauen wir uns nun das Collector-Interface (in java.util.stream) etwas genauer an. Die abstrakten Methoden sind folgende:

public interface Collector<T, A, R> { Supplier<A> supplier(); BiConsumer<A, T> accumulator(); BinaryOperator<A> combiner(); Function<A, R> finisher(); Set<Characteristics> characteristics(); … }

Die ersten drei Methoden (supplier, accumulator und combiner) kennen wir bereits von der collect()-Operation mit drei funktionalen Parametern. Der finisher enthält die Funktionalität, die am Ende auf den Zielcontainer angewendet wird, wenn alle Elemente des Streams eingesammelt worden sind. In unserem vorhergehenden Beispiel könnte der finisher das toString() sein, das aus dem StringBuilder einen String macht, sodass das Ergebnis von collect() ein String ist. Die Methode characteristics liefert ein Set von Characteristics zurück. Dabei ist Characteristics ein Enum-Typ, der aus den Werten CONCURRENT, UNORDERED, und IDENTITY_FINISH besteht. Die Characteristics sagen der collect()-Operation, wie sie die Funktionalität des Col­lectors nutzen kann:

  • IDENTITY_FINISH bedeutet, dass der Collector lediglich einen trivialen finisher hat und das Zielcontainerobjekt schon das Ergebnis des collect() ist. Das heißt, der collect() braucht den finsisher am Ende gar nicht aufrufen.

  • CONCURRENT bedeutet, dass der accumulator Thread-safe ist und konkurrierend vom collect() aufgerufen werden kann.

  • UNORDERED bedeutet, dass der Collector bei einem parallelen Stream die Elemente nicht in der Reihenfolge, in der sie im Stream vorkommen, einsammeln kann. Das kann zum Beispiel der Fall sein, wenn der Collector CONCURRENT ist und der accumulator konkurrierend ausgeführt wird.

Das ist grob die Beschreibung der Characteristics-Werte. Die collect()-Variante mit supplier, accumulator und combiner als Parameter entspricht der collect()-Variante mit Collector, bei dem die Methode characteristics() wie folgt implementiert ist:

public Set<Characteristics> characteristics() { return Collections.unmodifiableSet(EnumSet.of(IDENTITY_FINISH)); }

Wir werden CONCURRENT und UNORDERED in diesem Artikel nicht benutzen. Falls Interesse besteht, im Detail zu sehen, wie diese beiden Characteristics den collect() beeinflussen, kann man sich einen der von toConcurrentMap() bzw. groupingByConcurrent() erzeugten Kollektoren genauer ansehen.

Eigene Kollektoren implementieren

Der offensichtliche Ansatz, um einen eigenen Kollektor zu implementieren, besteht natürlich darin, eine Klasse zu implemen...

Neugierig geworden? Wir haben diese Angebote für dich:

Angebote für Gewinner-Teams

Wir bieten Lizenz-Lösungen für Teams jeder Größe: Finden Sie heraus, welche Lösung am besten zu Ihnen passt.

Das Library-Modell:
IP-Zugang

Das Company-Modell:
Domain-Zugang