|
|||||||||||||||||||
HOME | COURSES | TALKS | ARTICLES | GENERICS | LAMBDAS | IOSTREAMS | ABOUT | CONTACT | | | | |||||||||||||||||||
|
Effective Java
|
||||||||||||||||||
Diesmal wollen wir uns die beiden Stream-Operationen reduce() und collect() genauer ansehen und sie dabei vergleichen. Wir hatten beide Operationen jede für sich schon einmal in vorhergehenden Beiträgen besprochen; reduce() in / KLSO / und collect() in / KLSC /. Java und Funktionale Programmierung
Mit dem Release 8 sind Elemente der Funktionalen
Programmierung in Java dazugekommen. Dies sind im Wesentlichen das Sprachmittel
der Lambda Expressions (siehe /
KLLE
/) und die Erweiterung
des JDK um Streams (siehe /KLSÜ/). Diese Erweiterungen machen aber aus
Java keine funktionale Programmiersprache und das sollen sie auch gar nicht.
Schließlich ist Java rund zwanzig Jahre alt und es wäre eher kontraproduktiv
gewesen, mit der bisherigen objekt-orientierten Charakteristik von Java
zu brechen. Der Punkt war vielmehr, dass man die neuen funktionalen
Features so in Java einbringen wollte, dass sie möglichst nahtlos zum
bisherigen Java passen. Zur objekt-orientierten Programmierung in Java
gehört die Verwendung von veränderlichem Zustand (Englisch:
state
bzw.
shared mutable state
). Das ist ein Aspekt, der in der funktionalen
Programmierung aber gar nicht unterstützt wird bzw. eher als falsches
Konzept betrachtet wird.
Das ist nun der Punkt, wo unser Paar reduce() und collect() ins Spiel kommen. reduce() ist im Gegensatz zu collect() eine klassische Rekuktionsoperation aus der funktionalen Programmierung. Da es in funktionalen Sprachen keinen veränderlichen Zustand gibt, funktioniert reduce() nur auf Typen mit unveränderlichem Zustand. Da in Java aber Typen mit unveränderlichem Zustand eher untypisch sind, wollte man auch eine Reduktion auf veränderlichen Typen unterstützen. Deshalb haben die JDK Entwickler mit collect() eine Eigenentwicklung für Java hinzugefügt. collect() ist die Reduktion auf veränderlichen Typen, während reduce() die aus der funktionalen Welt stammende Reduktion auf unveränderlichen Typen ist. Das Problem ist, dass collect() Softwareentwicklern, die sich bereits mit funktionaler Programmierung auskennen, nicht bekannt ist und deshalb von ihnen meist ignoriert wird. reduce() & collect()Ein einfaches Beispiel mit reduce()
Wie sieht das Ganze nun konkret aus?
Fangen wir mit einem Beispiel an, das es in ähnlicher Form in unserem
Workshop gibt. Die Aufgabe besteht darin, einen Stream der ganzen Zahlen
von 0 bis 7 zu erzeugen, danach diese Zahlen auf Strings abzubilden und
zuletzt diese Strings zu einem gesamten String zusammenzufassen, der dann
"01234567"
lautet.
Eine mögliche Lösung sieht so aus:
String s = IntStream.
range
(0, 8)
System.
out
.println(s);
Diese Lösung verwendet das reduce() mit dem String -Operator+ und dem leeren String als neutralem Anfangswert. Diese Lösung produziert das richtige Ergebnis und sieht auch relativ knapp und elegant aus. Leider ist die Performance aber relativ schlecht. Denn mit jedem Ziffernstring, der ins reduce() kommt, wird ein neuer String erzeugt, der das aktuelle Teilergebnis repräsentiert. Der String -Operator+ erzeugt immer wieder einen neuen String , weil String ein unveränderlicher Typ ist. Aber gerade weil String ein unveränderlicher Typ ist, passt er zum reduce() , so dass die Lösung funktioniert, wenn auch wie gesagt nicht besonders performant. Ein einfaches Beispiel mit collect()
Dass der
String
-Operator+
nicht ideal ist für die Stringkonkatenation, lernt man im Allgemeinen
schon im Java-Anfängerkurs. Man lernt dort auch die performantere Alternative
kennen: nämlich die
String
s erst in
einem
StringBuilder
mit
append()
akkumulieren und aus diesem am Ende mit
toString()
den
Ergebnisstring erzeugen.
Wie kann man diesen Lösungsansatz auf unser Stream-Problem übertragen?
Das geht so:
String s = IntStream.
range
(0, 8)
System.
out
.println(s);
Statt
reduce()
verwenden wir
collect()
mit dem
Collector
,
der von der Factory-Methode
Collectors.joining()
erzeugt wird. Intern verwendet dieser
Collector
den
StringBuilder
mit
append()
.
In den Workshops ist das Problem mit dieser Lösung meist, dass die Teilnehmer sie nicht besonders mögen, weil sie ziemlich intransparent ist. Die Javadoc der joining() -Factory-Methode sagt nur: Returns a Collector that concatenates the input elements into a String , in encounter order. Dass die Lösung auf dem StringBuilder basiert, findet man erst heraus, wenn man sich den Source Code im JDK ansieht. Ein aufwändiges Beispiel mit collect()
Entsprechend ist der Wunsch groß, das
Ganze selbst zu implementieren, damit die unterliegenden Mechanismen (
StringBuilder
mit
append()
) deutlicher sichtbar werden.
Eine Frage kommt an dieser Stelle häufig auf: Wenn man es selbst macht,
geht es dann mit
collect()
oder mit
reduce()
?
Die Antwort ist: man muss
collect
()
verwenden.
reduce()
funktioniert
nur für unveränderliche Typen wie
String
.
Wir wollen aber eine Reduktion mit dem veränderlichen Typ
StringBuilder
machen. Also müssen wir
collect
()
verwenden.
In dem obigen Beispiel haben wir die Version des
collect()
genutzt, die einen
Collector
als Parameter
nimmt:
R collect(Collector<? super T,A,R> collector)
Nun benötigen wir für die eigene Implementierung
der Funktionalität mit Hilfe des
StringBuilder
s
die folgende, überladene Version des
collect()
:
R collect(Supplier<R>
supplier, BiConsumer<R,? super T> accumulator, BiConsumer<R,R> combiner)
Die Lösung damit sieht dann so aus:
String s = IntStream.range(0, 8) .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);
Wenn man Methoden-Referenzen (statt Lambda Ausdrücke) verwendet, geht
es auch noch etwas knapper:
String s = IntStream.
range
(0, 8)
System.
out
.println(s);
Was haben wir jetzt eigentlich genau implementiert?
Unser
collect()
verlangt drei funktionale
Parameter:
supplier
,
accumulator
und
combiner
(siehe Signatur oben).
Der
supplier
beschreibt die Funktionalität, mit der das
collect()
ein Zielobjekt erzeugt kann, in das dann im Weiteren die Elemente des Streams
aufgesammelt werden. Wir wollen unsere
String
s
in einem
StringBuilder
aufsammeln, also
ist die Implementierung des
suppliers
:
()
-> new StringBuilder()
, oder als Methoden-Referenz einfach:
StringBuilder::new
.
Der
accumulator
beschreibt, wie das
collect()
ein Element
aus dem Stream (also einen
String
) in
dem Zielobjekt (also dem
St
r
ingBuilder
)
aufsammeln soll. Das hatten wir uns oben schon überlegt. Es soll die
append()
-Methode
des
StringBuilders
mit dem
String
als Parameter verwendet werden. Das heißt, die Implementierung mit einem
Lambda-Ausdruck ist:
(StringBuilder sb1, String
s1) -> sb1.append(s1)
, als Methoden-Referenz einfach:
StringBuilder::append
.
Der dritte Parameter (der
combiner
)
wird nur bei einem parallelen Stream benutzt. Trotzdem müssen wir ihn
übergeben, da es nur ein API für parallele und sequentielle Streams gibt
(siehe /KLPS/). Der
combiner
beschreibt,
wie zwei Zielobjekte (also zwei
StringBuilder
)
zusammen-kombiniert werden. Das ist relativ einfach und zwar, indem man
den zweiten
StringBuilder
mit
append()
an den ersten anhängt. Als Lambda Ausdruck ist dies:
(StringBuilder
sb1, StringBuilder sb2) -> sb1.append(sb2)
und als Methoden-Referenz
einfach wieder:
StingBuilder::append
.
Beim parallelen Stream (siehe /
KLPS
/)
werden in der Ausführungsphase mit dem
supplier
mehrere
StringBuilder
parallel erzeugt
und befüllt (
a
ccumulator
).
Der
combin
er
wird dann benutzt, um in der Join-Phase die verschiedenen
StringBuilder
zu einem Gesamtergebnis zusammenzufassen.
Damit haben wir uns im Detail angesehen, wie man mit Hilfe des collect() die Reduktion auf Basis des StringBuilder s selbst implementieren kann. Ein falsches Beispiel mit reduce()
Wie sieht es nun aus, wenn man die Reduktion
auf Basis des
StringBuilders
mit
reduce()
versucht. Wie und wo scheitert man da? Findige Workshopteilnehmer unternehmen
gerne den Versuch mit einer Version des
reduce()
,
die der oben verwendeten Version von
collect()
sehr ähnlich sieht:
U reduce(U identity, BiFunction<U,?
super T,U> accumulator, BinaryOperator<U> combiner)
Auch wenn hier die Typen von
accumulator
(
BiFunct
ion
statt
BiConsumer
)
und
combiner
(
BinaryOperator
statt
BiConsumer
) andere sind als beim
collect()
,
so kann man sie beide wieder mit einer Methoden-Referenz
StringBuilder::append
implementieren:
// Falsch ! Falsch! Falsch! Falsch! Falsch!
String s = IntStream.
range
(0,
8)
System.
out
.println(s);
Lässt man den Code laufen, wird sogar der
gewünschte Ergebnis-
String
:
"01234567"
erzeugt. Trotzdem ist die Implementierung mit
reduce()
falsch und die mit
collect()
richtig.
Testen kann man es am einfachsten dadurch, dass man einen parallelen Stream
statt eines sequentiellen verwendet, also den Code folgendermaßen ändert:
// Falsch ! Falsch! Falsch! Falsch! Falsch!
String s = IntStream.
range
(0,
8)
.
parallel()
System.
out
.println(s);
Der Ergebnis-
String
ist dann auf unserer
Dual-Core-Plattform:
"
456745672301456745672301456745672301456745672301
"
Der falsche Ergebnis- String ist plattform-abhängig, im Wesentlichen abhängig von der Zahl der CPU-Cores und thread-safe ist es auch nicht, weil der StringBuilder nicht thread-safe ist.
Der Algorithmus von
reduce()
läuft nämlich so, dass der eine
StringBuilder
,
der dem
reduce()
übergeben wird, in
der Execution-Phase von allen parallelen Worker-Threads gleichzeitig verwendet
wird. Jeder Thread hängt die Elemente aus seinem
Spli
terator
der Stream-Source an diesen einen
StringBuilder
an. Damit das verlässlich funktioniert, müsste der
StringBuilder
thread-safe sein; das ist er aber nicht. In der anschließenden Join-Phase
werden die Teilergebnisse zum Gesamtergebnis zusammengefasst. Beim
reduce()
sind aber gar keine Teilergebnisse entstanden, sondern alle Worker-Threads
haben wahllos einen einzige
StringBuilder
Instanz modifiziert. Diese eine
StringBuilder
Instanz wird dann als Teilergebnis des jeweiligen Worker-Threads angesehen
und in der Join-Phase x-mal an sich selbst angehängt. Das Ergebnis ist
völlig falsch, weil
reduce()
davon ausgeht,
dass die Typen unveränderlich sind. Es wird erwartet, dass der
accumulator
und der
combiner
in jedem Schritt ein
neues unveränderliches Objekt erzeugen. Stattdessen haben wir ein einziges
veränderliches Objekt modifiziert und genau das ist falsch.
Beim collect() oben ist es anders. Dem collect() wird kein StringBuilder , sondern ein Supplier für einen StringBuilder übergeben und jeder Worker-Thread erzeugt sich mit dem Supplier seinen eigene StringBuilder Instanz, die er ganz alleine verwendet und ganz alleine modifiziert. Beim collect() wird - anders als beim reduce() - keine Thread-Safety gebraucht und es ist ausdrücklich erlaubt, dass Objekte verändert werden. Anschließend in der Join-Phase werden die einzelnen StringBuilder , die in der Execution-Phase entstanden sind, aneinandergehängt, um aus den Teilergebnissen das Gesamtergebnis zu erzeugen. FazitWir haben uns damit umfassend angesehen:• reduce() ist für eine Reduktion auf Basis von unveränderliche Typen (zum Beispiel: String ),
•
collect()
ist für eine Reduktion auf Basis von veränderlichen Typen (zum Beispiel:
StringBuilder
).
Wenn man die Javadoc der Methoden sehr aufmerksam
vergleicht, kann man das auch dort herauslesen. Die Javdoc von
reduce()
beginnt mit: “
Performs
a reduction on the elements of this
stream …”
, und die von
collect()
beginnt mit: “
Performs a
mutable
reduction
operation
on the elements of this stream …
” . Zugegeben, die Unterschiede
in der Beschreibung sind recht subtil.
Weiter hilft es auch, die Signaturen von
accumulator
und
combiner
beim
reduce()
und
collect()
zu vergleichen. Beim
reduce()
sind
accumulator
und
combiner
vom Typ
BiFunction
und
BinaryOperator
,
d.h. sie liefern ein Ergebnis, weil der unterliegende Typ unveränderlich
ist. Beim
collect()
sind
accumulator
und
combiner
beide vom Typ
BiConsumer
und produzieren kein Ergebnis, sondern verändern stattdessen jeweils den
Input-Parameter, der das Zielobjekt repräsentiert. Auch hier ist der
Unterschied recht subtil.
Obwohl reduce() die klassische Methode aus der funktionalen Programmierung ist, spielt sie bei den Java Streams im Vergleich zu collect() nicht unbedingt die wichtigere Rolle. Das liegt daran, dass in Java die veränderliche Reduktion oft performanter und deshalb vorzuziehen ist. Das haben wir ja an unserem Beispiel mit der String -Konkatenation bereits ausführlich diskutiert. Horror Code
Genaugenommen ist die Regel "
reduce()
für unveränderliche Typen" und "
collect()
für veränderliche Typen" etwas verkürzt. Es kommt nämlich nicht allein
auf den Typ an, den man in der Reduktion verwendet, sondern auch darauf,
ob man die Reduktion unverändernd oder verändernd implementiert.
Schauen wir uns dazu ein Beispiel an, bei
dem wir auf Basis von
reduce()
eine unveränderliche
Reduktion mit
StringBuilder
implementieren.
Das geht so:
String s = IntStream.range(0, 8).parallel() .mapToObj(Integer::toString) .reduce(new StringBuilder(), (sb1, s1) -> new StringBuilder(sb1).append(s1), (sb1, sb2) -> new StringBuilder(sb1).append(sb2))
.toString();
System.out.println(s);
Wir implementieren sowohl den
accumulator
als auch den
combiner
so, dass sie nicht
das vorhandene
StringBuilder
-Objekt
sb1
verändern, sondern mit
new
ein neues
StringBuilder
Objekt erzeugen, an das der
String
(beim
accumulator
)
bzw. der
StringBuilder
(beim
combiner
)
angehängt wird. Der Code funktioniert korrekt auch bei einem parallelen
Stream. Das Problem ist aber natürlich - wie auch beim
reduce()
mit
String
- die Performance, da in jedem
Reduktionsschritt ein neues Objekt erzeugt wird. Der Performance-Vorteil,
den man erzielen könnte, weil
Strin
gBuilder
ein veränderlicher Typ ist, wird bei dieser Lösung nicht genutzt. Das
Beispiel zeigt nur, dass auch die Kombination
reduce()
und
StringBuilder
möglich ist. Besonders
sinnvoll ist sie nicht. Nicht umsonst ist der Titel dieses Absatzes:
Horror
Code
.
Der Vollständigkeit halber: Gibt es auch
die Möglichkeit,
collect()
und
String
zu kombinieren? Ja, auch das geht irgendwie. Da
String
als unveränderlicher Typ implementiert ist, geht es mit
String
direkt nicht. Aber wir können ein
String
-Array
der Länge 1 als veränderliches Zielobjekt des
collect()
verwenden. Die Implementierung sieht dann so aus:
String s = IntStream.range(0, 8).parallel() .mapToObj(Integer::toString) .collect(() -> { String[] sa = {""}; return sa; }, (sa1,s1) -> sa1[0]+=s1, (sa1,sa2) -> sa1[0]+=sa2[0])[0];
System.out.println(s);
Bezüglich der Performance hilft uns diese
Lösung aber auch nicht, da von der nullten Stelle des Arrays nach jedem
Reduktionsschritt ein neues
String
-Objekt
referenziert wird. Der Grund dafür ist der in
accumulator
und
combiner
verwendete Operator+= von
String
.
Also auch hier wieder:
Horror Code
.
Den Horror Code bitte nicht nachmachen. Wir wollten nur illustrieren, dass man mit reduce() und collect() jede Menge Unfug machen kann. Wichtig für die Praxis ist, dass man sich anschaut, ob man eine Reduktion mit einem veränderlichen oder unveränderlichen Typ machen will, und dann gilt die Regel: " reduce() für unveränderliche Typen" und " collect() für veränderliche Typen". Alles andere ist entweder performance-mäßig indiskutabel oder schlicht falsch. Zusammenfassung und Vorschau
Wir haben uns diesmal angesehen, wie sich
die Stream-Operationen
reduce()
und
collect()
unterscheiden bzw. ergänzen. Beim nächsten Mal wollen wir uns ansehen
wie mächtig
collect()
sein kann, wenn
man benutzerdefinierte Typen in der veränderlichen Reduktion verwendet.
Literaturverweise
Die gesamte Serie über Java 8:
|
|||||||||||||||||||
© Copyright 1995-2018 by Angelika Langer. All Rights Reserved. URL: < http://www.AngelikaLanger.com/Articles/EffectiveJava/83.Java8.Reduce-vs-Collect-Stream-Operations/83.Java8.Reduce-vs-Collect-Stream-Operations.html> last update: 26 Oct 2018 |