Streams in Java 8
In einem früheren Posting hatten wir bereits die wichtigste Neuerung in Java 8, nämlich die Lambda-Ausdrücke vorgestellt. Inzwischen steht das offizielle Release von Java 8 unmittelbar bevor und es gibt einen Release Candidate. Heute beleuchten wir die Hauptmotivation für die Einführung von Lambda-Ausdrücken, die neuen Streams in Java 8. Die lassen tatsächlich etwas funktionales Programmiergefühl in Java aufkommen. Allerdings muss man einige Feinheiten beachten.
Eine der besonderen Stärken der funktionalen Programmierung ist der Umgang mit „Collections“, weil viele Operationen auf Listen, Mengen oder Maps als Higher-Order-Funktion ausgedrückt werden können. Im „alten Java“ waren dafür Schleifen nötig. Das ist nicht nur umständlich, sondern sequenzialisiert den Programmablauf unnötig, was bei modernen Multicore-Rechnern häufig Potenzial zum Datenparallelismus verschwendet.
Das alles lässt sich am einfachsten an einem konkreten Beispiel demonstrieren: Wir analysieren die Spiele einer Fußballsaison. Dazu erzeugen wir erst einmal eine Klasse für die wichtigsten Daten eines Fußballspiels:
Leider erspart uns Java 8 weder das explizite Hinschreiben des
Konstruktors, noch das von equals
- und hashCode
-Methode - diese
stellen wir uns hier einfach vor. Auf Getter-Methoden verzichten wir
einfach. Dies reicht, um eine komplette Fußball-Saison zu
repräsentieren:
Als erstes fällt auf, dass auch in Java 8 die Collections nicht
persistent
sind.
Vorsicht also bei der letzten Zeile: Das Array season_2009_2010a
hält
die Elemente der Liste season_2009_2010
. Wenn das Array
nachträglich verändert wird, verändert sich auch die Liste. Wer das
Problem vermeiden möchte, sollte lieber folgendes schreiben:
Wir benötigen einige Methoden, um wenigstens halbwegs interessante Sachen zu machen:
(Ich weiß, seit gotofail macht man {...}
nach den if
s. Tschuldigung.)
Es ist eine lohnende Fingerübung zu versuchen, die frustrierenden
Gemeinsamkeiten von homePoints
und guestPoints
durch Abstraktion
zusammenzufassen, vielleicht sogar unter Verwendung von Lambda-Ausdrücken.
Die Funktion playsGame
können wir zum Beispiel benutzen, um alle
Spiele von Nürnberg aus der Saison herauszufiltern. Jede funktionale
Programmiersprache hält hierfür eine Funktion namens
filter
vor, die eine Collection und ein Prädikat akzeptiert. Das Prädikat
liefert für jedes Collection-Element einen booleschen Wert, und
filter
liefert eine neue Collection mit allen Elementen zurück, bei
denen das Prädikat true
liefert. In Java 8 geht das jetzt endlich
auch, allerdings nicht auf den bekannten Collection-Interfaces wie
List
,
sondern auf einer neuen Klasse namens
Stream
,
und die hat eine
filter
-Funktion.
Glücklicherweise hat List
eine Methode
stream()
,
die aus der Liste einen Stream macht. Wir können dann filter
benutzen, um z.B. alle Spiele mit Nürnberg herauszusortieren:
Allerdings liefert filter
wiederum einen Stream. Um aus dem Stream
wieder herauszukommen, müssen wir einen
Collector
bemühen, mit dessen Hilfe die
collect
-Methode
die die Elemente des Streams aufsammelt. Eine Menge
vorgefertigter Collector-Funktionen gibt es in der
Collectors
-Klasse,
insbesondere eine zum Aufsammeln in eine Liste:
Wenn wir jetzt auch noch die Punkte aufsummieren wollen, dann können
wir noch
summingInt
bemühen, die ein „Map“ mit einer Summenbildung kombiniert:
Aus irgendeinem Grund gibt es keinen Collector, der direkt die Summe der Stream-Elemente ausrechnet. Dies lässt sich natürlich optimieren zu:
Das tolle an Operationen wie map
und filter
ist, dass sie die
Elemente der Collection unabhängig voneinander verarbeiten. Es ist
also möglich, jeweils Teile der Liste in verschiedenen Threads zu
verarbeiten. Dazu ist nur eine klitzekleine Änderung nötig, nämlich
parallelStream()
statt stream()
:
Die filter
-Methode bemüht sich dann, das Filtern parallel
durchzuführen. Genauso ist es bei map
. Diese Art der parallelen
Programmierung ist deutlich angenehmer und weniger fehleranfällig als
das Programmieren mit Locks oder sogar der Fork/Join-Parallelismus
aus Java
7.
(Ein nicht-paralleler Stream kann auch mit der parallel()
-Methode
nachträglich zu einem parallelen gemacht werden.)
Selbst collect
kann parallel laufen, da die Collectoren
assoziative
Operationen implementieren müssen. Bonuspunkte gibt es für
kommutative
Operationen. (Die gute alte Schulmathematik hat also tatsächlich
praktische Anwendungen!) Mehr Informationen gibt es in der
Dokumentation von
Collector
.
Insgesamt also eine tolle Sache, diese Streams.
Einige Aspekte der Streams sind trotzdem zu beachten, vor allem für funktionale Programmierer. So sind Streams nicht persistent. Das hier geht also nicht:
Das compiliert zwar noch, liefert aber folgenden Fehler:
Exception in thread "main" java.lang.IllegalStateException: stream has already been operated upon or closed
at java.util.stream.AbstractPipeline.<init>(AbstractPipeline.java:203)
at java.util.stream.ReferencePipeline.<init>(ReferencePipeline.java:94)
at java.util.stream.ReferencePipeline$StatelessOp.<init>(ReferencePipeline.java:618)
at java.util.stream.ReferencePipeline$2.<init>(ReferencePipeline.java:163)
at java.util.stream.ReferencePipeline.filter(ReferencePipeline.java:162)
at Game.main(Game.java:202)
at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at java.lang.reflect.Method.invoke(Method.java:483)
at com.intellij.rt.execution.application.AppMain.main(AppMain.java:120)
Außerdem gibt es separate Stream-Klassen für einige primitiven Typen:
IntStream
,
LongStream
und
DoubleStream
(allerdings kein BooleanStream
) und einen ganzen Zoo spezialisierter
Methoden wie
mapToInt
.
Da haben es Scala-Programmierer doch deutlich leichter.