Streams: Java 8 Streams Einführung
Man kann Java-8-Streams als ein Konzept beschreiben, mit dem eine Menge von Objekten in gleichartiger Weise verarbeitet werden kann. Die Verarbeitung muß nicht unbedingt sequentiell erfolgen, Tatsächlich wurden Streams als Konzept für die parallele Verarbeitung eingeführt. Die Verarbeitung von Listen mit Java-Steams führt zu elegantem, übersichtlichem Code; darauf liegt das Augenmerk dieses Beitrags. Er ist als Einführung zu betrachten und kann daher nicht alle Aspekte beleuchten. Es wird vielmehr versucht das abstrakte Konzept zu beschreiben, nicht wie die Verarbeitung durch die JDK-Implementierung geschieht..
Der Begriff "Stream" wird unglücklicherweise für zwei verschiedene Dinge verwendet. Zum einen ist damit der Strom der Objekte gemeint, die verarbeitet werden sollen. Das ist mehr eine bildliche Vorstellung dessen, was im Innern der Stream-Implementierung passiert, denn so etwas wie eine "Röhre", durch die die Objekte im Wortessinne hindurchschwimmen existiert im Java-Programm natürlich nicht. Zum anderen ist damit ein Java- Objekt gemeint, das das Stream-Interface implementiert. Das ist tatsächlich etwas, das man im Java-Code wiederfindet, anfassen und verwenden kann. Im Folgenden wird der Begriff "Stream" für das verarbeitende Java- Objekt verwendet und der Begriff "Strom" für den Objekt-Strom. "Stream" kann auch als Bezeichnung für das Stream-Konzept als Ganzes stehen.
Man kann sich das Stream-Objekt wie ein Rohrleitungssystem vorstellen, in das ein Strom von Objekten hineingeschüttet wird. Beim Durchlaufen wird der Strom gefiltert, eingekocht und mit Zusätzen angereichert. Das Leitungsende verschließt ein Stöpsel mit einem Hahn, der nur die Tropfen durchläßt, die das vollständige Rohrsystem passiert haben und das Ergebnis bilden. Die Art der Flüssigkeit kann sich beim Durchlaufen des Leitungssystems ändern – aber lassen wir uns durch das Bild nicht zu weit von den Streams fortspülen...
Wie die Verarbeitung abläuft
Die Verwendung eines Stream
-Objekts besteht aus drei Schritten:
Im ersten Schritt muß zunächst ein Stream
-Objekt erzeugt werden, mit dem die Objekte verarbeitet werden
können. Diesem Stream
-Objekt werden dann im zweiten Schritt Arbeitsanweisungen hinzugefügt die angeben, was
mit den Objekten geschehen soll, die später als "Strom" durch die gedachte Röhre fließen. Sie können Objekte
verändern, umwandeln, Objekte herausfiltern und vieles mehr. Der dritte Schritt schließt die Verarbeitung durch
eine Anweisung ab, die den Ergebnis-Strom "abfüllt". Man kann den gesamten Strom auf Flaschen ziehen (also zum
Beispiel alle verarbeiteten Objekte in eine Liste füllen), ein einzelnes Objekt herauspicken oder den Strom auf eine
einzige Eigenschaft des Ergebnis-Stroms reduzieren (zum Beispiel auf die Anzahl der enthaltenen Objekte).
Abweichend von der Analogie muß man man sich bereits beim Erzeugen des Stream
-Objekts entscheiden, welche
Objekte man durch die Rohrleitung schickt. Glücklicherweise ist die Erzeugung eines Stream
-Objekts aber deutlich
einfacher als die Installation einer sanitären Anlage...
Typischerweise schreibt man die komplette Verarbeitung via Method-Chaining in eine einzige Anweisung. Das Zählen aller Zahlen einer Liste die größer als 100 sind, könnte zum Beispiel so aussehen (die Erklärung der Methoden folgt später):
Stream.of(10, 4711, 815).filter(x -> x > 100).count();
Der Strom der Objekte beginnt tatsächlich erst zu fließen, wenn die abschließende Methode von Schritt drei
aufgerufen wird (im Beispiel ist das die Methode count()
). Man muß die Methoden daher nicht unmittelbar
hintereinenader ausführen, sondern kann – wenn erforderlich – die Konstruktion des Stream
-Objekts schrittweise
ausführen und dann durch Ausführen der Abschluß-Methode die Verarbeitung starten. In der Rohrleitungs-Analogie
wird das Leitungssystem installiert – der Installateur muß zwischendurch mal ein anderes Rohrsystem aufsuchen –
dann wird am Leitungs-Ende ein Hahn aufgeschraubt und erst wenn dieser geöffnet wird, beginnt die Flüssigkeit zu fließen.
Der Stream ist also ein Mechanismus zur Verarbeitung und nicht zur Speicherung. Diese Erkenntnis ist essentiell! Der Stream
verhält sich damit ähnlich wie das Optional. Tatsächlich handelt es sich um einen Einweg-Mechanismus, der nach
Abschluß des Streams verbraucht ist und nicht wieder verwendet werden kann – für jede Verarbeitung muß daher ein
neues Stream
-Objekt erzeugt werden. Spätestens hier endet die Rohrleitungs-Analogie...
Der Stream muß die Objekte bei der Verarbeitung nicht notwendigerweise verändern, auch "rein untersuchende" Operationen, wie das Suchen von Objekten mit bestimmten Eigenschaften läßt sich mit Streams sehr übersichtlich und effizient durchführen. Aber genug der Theorie, schauen wir uns die drei Schritte zum Aufbau eines Streams an ein paar – mehr oder minder – praktischen Beispiel an. Wir betrachten die drei genannten Schritte und lernen verschiedene Methoden kennen, die zu diesem Schritt gehören.
Schritt 1: Streams erzeugen
Betrachten wir zunächst die Erzeugung des Streams. Der typische Anwendungsfall beginnt mit einer Sammlung von Objekten die irgendwo im Speicher herumliegen und verarbeitet werden möchten. Java bietet einige Möglichkeiten an um einen Stream zu erzeugen, der aus einem Haufen von Objekten einen Strom formt.
Im einfachsten Fall hat man eine Liste von Literalen. Hier bietet sich die statische Methode Stream.of
aus dem Stream
-Interfaces an:
Stream<String> stream = Stream.of("This", "is", "a", "test");
Man kann die gleiche (überladene) Methode auch für klassische Java-Arrays verwenden:
String[] liste = {"This", "is", "a", "test"}; Stream<String> stream = Stream.of(liste);
Stream
ist ein generisches Interface. Jeder Stream liefert Objekte genau eines Typs. Die Streams in unseren
Beispielen liefern – der Einfachheit halber – Strings, aber selbstverständlich ist jeder Objekt-Typ erlaubt.
Ungleich häufiger ist der Wunsch, einen Stream aus einem Java-Collection-Objekt zu erzeugen. Hier erzeugen wir
zur Demonstration erst ein List
-Objekt aus einer Literal-Liste und erzeugen dann einen Stream aus der Liste:
List<String> liste = Arrays.asList("This", "is", "a", "test"); Stream<String> stream = liste.stream();
In diesem Falle brauchen wir keine Hilfsfunktion, sondern erzeugen den Stream mit der Methode stream()
direkt
aus dem List
-Objekt. Ein Konzept, das man sehr gut auch für eigene Datenstrukturen verwenden kann um eine
Verarbeitung des Inhalts ohne Kenntnis der internen Struktur zu ermöglichen.
Nach dem gleichen Muster lassen sich auch aus Datei-Inhalten Streams erzeugen:
Stream<String> stream = Files.lines(Paths.get(fileName)));
Hier wird aus einer Text-Datei ein Stream von Strings erzeugt. Jeder String enthält den Text eine Zeile der Datei.
Weitere Möglichkeiten bieten Iteratoren und Generator-Funktionen, auf die hier nicht weiter eingegangen wird.
Auch die Möglichkeit, Streams zusammenzuführen oder aus einem Stream von List
-Objekten einen Stream der
darin enthaltenen Objekte zu machen wird hier nicht näher erläutert. Es sei nur gesagt, daß da noch einiges zu
entdecken ist...
Schritt 2: Die Verarbeitung
Nachdem der Stream erzeugt ist, muß definiert werden, was mit dem Objekt-Strom passieren soll der später
hindurchrauscht. Das Stream
-Interface bietet dazu eine Reihe von Methoden. Man kann sich diese Methoden
vorstellen als Ventile im Rohr. Vorne kommt ein Strom von Objekten hinein und hinten fließt ein Strom von
Objekten wieder hinaus. Der Typ der Objekte kann, muß dabei aber nicht verändert werden. Ändert sich dabei der
Typ der Objekte, ändert sich auch der Typ des Stream
-Objekts. Eclipse ist dabei sehr hilfreich, den Überblick zu
behalten, mit welchem Typ man gerade arbeitet...
Da wir mit Java-8 arbeiten, verwenden wir natürlich λ-Ausdrücke um die zu verwendende Funktion zu spezifizieren. Genau diese Möglichkeit macht die Streams zu einem extrem flexiblen und vielfältigen Mechanismus. Die Auswahl an Stream-Methoden ist nicht erschöpfend und soll nur einen Eindruck geben von den verschiedenen Möglichkeiten die die Verarbeitung hat.
Objekte betrachten: filter()
Der vielleicht einfachste Use Case ist die Anwendung eines Filters. Seine Aufgabe ist es, Objekte des Stroms
durchzulassen oder herauszufiltern (und fallen zu lassen). Für die Angabe der Filterbedingung verwenden wir einen
λ-Ausdruck der das Interface Predicate
implementiert. Predicate
definiert eine Funktion, die ein Objekt vom Typ
des Eingabe-Stroms als Argument nimmt und als Ergebnis true
oder false
liefert. Nur Objekte, die die Bedingung
des Prädikats erfüllen werden durchgelassen. Als Beispiel bauen wir uns wieder ein List
-Objekt, erzeugen einen
Stream und filtern alle null
-Werte heraus:
List<String> liste = Arrays.asList("This", "is", "a", null, "test"); Stream<String> stream = liste.stream().filter(x -> x != null);
Es entsteht dabei wieder ein Strom gleichen Typs (hier String
), der gegebenenfalls weniger Objekte enthält als der
ursprüngliche Strom, aber garantiert keine neuen Objekte. Durch Hintereinanderschalten lassen sich Filter beliebig verketten:
Stream<String> stream = liste.stream().filter(x -> x != null).filter(x -> x.length() > 5);
Leider gibt es kein "negatives" Äquivalent zur filter
-Methode, also eine Methode die diejenigen Objekte fallen
läßt, die das Prädikat erfüllen. Um das zu erreichen, muß man die filter
-Methode mit einem ein entsprechenden,
negierten Prädikat verwenden.
Objekte manipulieren: map()
Die mächtigste Funktion ist zweifellos map()
. Sie erlaubt die Anwendung einer Funktion, die ein Objekt des
Eingangsstroms als Parameter nimmt und ein beliebiges Objekt als Ergebnis liefert. Damit kann man die Objekte
eines Streams verändern oder umwandeln. Ändert sich der Typ des Objekts durch die Anwendung der Funktion,
ändert sich damit auch der Typ des Ausgabe-Stroms. Nehmen wir wieder einen String
-Stream, und "trimmen" die
Strings beim Durchlauf:
List<String> liste = Arrays.asList("This ", " is ", " a ", null, " test"); Stream<String> stream = liste.stream().map(String::trim);
Richtig, das wird schiefgehen und in einer Null-Pointer-Exception enden. Der Fehler wird aber noch nicht an dieser Stelle
auftreten, sondern erst, wenn der Stream im dritten Schritt abgeschlossen wird. Erst dann wird die
Verarbeitung gestartet, und die Objekte strömen los. Bevor wir trim()
anwenden können, sollten wir also die null
-
Werte herausfiltern. Dazu können wir die Methoden map()
und filter()
miteinander kombinieren.
Filtern wir also zuerst die null
-Werte heraus; Java-8 bietet extra für diesen Fall eine convenience-Methode
in der Objects
-Klasse:
List<String> liste = Arrays.asList("This ", " is ", " a ", null, " test"); Stream<String> stream = liste.stream().filter(Objects::nonNull).map(String::trim);
Bei der gezeigten Anwendung von map()
bleibt der Typ des Streams unverändert, da String::trim
ebenfalls Strings
liefert. Mit der String-Methode length()
können wir zu jedem String dessen Länge bestimmen und so einen
integer
-Stream erzeugen. Das Autoboxing von Java erzeugt dabei automatisch Integer
-Objekte:
List<String> liste = Arrays.asList("This ", " is ", " a ", null, " test"); Stream<Integer> stream = liste.stream().filter(Objects::nonNull).map(String::length);
Es sei darauf hingewiesen, daß Java-8 eigene Implementierungen für Zahlen-Streams anbietet (IntStream
,
LongStream
etc.) die wesentlich effizienter arbeiten. Das Beispiel sollte nur illustrieren, wie sich durch Anwendung
einer Funktion, die den Typ ändert, auch der Typ des Streams ändert. Die Klasse Integer
im obigen Beispiel steht
stellvertretend für jede beliebige andere Klasse. Mit der Methode mapInt
läßt sich zum Beispiel
ein IntStream
erzeugen:
List<String> liste = Arrays.asList("This ", " is ", " a ", null, " test"); IntStream stream = liste.stream().filter(Objects::nonNull).mapToInt(String::length);
An dieser Stelle macht sich der Unterschied zwischen funktionaler und objektorientierter Sichtweise bemerkbar:
Der Methodenaufruf .filter(Objects::nonNull)
liefert ein Objekt vom Typ Stream<String>
das vom
Aufruf .map(String::length)
in ein Objekt vom Typ Stream<Integer>
umgewandelt wird. Technisch entsteht
dabei natürlich ein neues Objekt, aber es hilft dem Verständnis wenn man sich vorstellt, als gäbe es nur ein Stream
-Objekt
das nach dem Aufruf von .filter(Objects::nonNull)
einen String
-Strom liefert und nach dem Aufruf
von .map(String::length)
einen Integer
-Strom. Oder übertragen auf die Rohrleitung: Das Leitungssystem (in
Analogie zum Stream-Objekt) liefert nach Aufstecken des ersten Ventils (dem non-null-filter) einen Strom von
Buchstabenketten und nach Aufstecken des zweiten Ventils (der length-map) einen Strom von Zahlen.
Der folgende JUnit-Schnipsel führt zu einer Exception im Assert:
List<String> liste = Arrays.asList("This ", " is ", " a ", null, " test"); Stream<String> stream = liste.stream(); Stream<String> streamNotNull = stream.map(String::trim); Assert.assertEquals(5, stream.count());
In alle Objekte des Strom hineinschauen: sorted() und distinct()
Wer Listen hat, hat auch bedarf nach Reihenfolge. Das Stream
-Interface hat dafür zwei Methoden im Angebot.
Die Methode sorted()
liefert einen Strom, der die Objekte im Eingangs-Strom nach der "natürlichen" Ordnung sortiert.
Die Objekte des Eingangs-Stroms müssen dafür das Interface Comparable
implementieren. Alternativ
kann man der Methode auch ein Comparator
-Objekt übergeben, das Objekte des Eingabe-Typs vergleicht.
Analog zum gleichnamigen SQL-statement vergleicht die Methode distinct()
die Objekte des Eingangs-Stroms
mithilfe der Methode equals()
und sorgt dafür, daß sich alle Objekte des Ausgangs-Stroms unterscheiden. Für
sortierte Ströme wird immer das erste Element durchgelassen, für unsortierte Ströme ist das nicht garantiert.
Alle Objekte es Stroms betrachten: skip() und limit()
Ähnlich wie die filter
-Methode dienen diese beiden Methoden dazu, Objekte aus dem Strom auszuschließen, im
Gegensatz zum Filter schauen diese Methoden jedoch nicht in die Objekte hinein.
Die Methode skip()
hat einen Parameter vom Typ long und überspringt Objekte im Strom.
skip(5)
hat zur Wirkung, daß die nächsten fünf Objekte des Eingabe-Stroms herausgefiltert werden und nicht
mehr im Ausgabe-Strom auftauchen.
Die Methode limit()
hat ebenfalls einen Parameter vom Typ long und beschränkt die Zahl der Objekte im
Ausgabe-Strom.
limit(10)
hat zur Wirkung, daß der Ausgabe-Strom maximal 10 Objekte enthält.
Parallelisierung: parallel() und sequential()
Streams wurden eingeführt um die parallele Verarbeitung von Objekten zu ermöglichen. Mit der Methode
parallel()
läßt sich aus jedem Strom eine Menge – potentiell parallel laufender – Ströme machen. Alle Objekte in
allen parallelen Strömen werden dann in der gleichen Art und Weise – so wie es der Stream definiert –
verarbeitet. Wenn die ausführende JVM eine Möglichkeit sieht, wird sie versuchen die Verarbeitung mit
verschiedenen Threads zu parallelisieren. Mit der Methode sequential()
lassen sich die parallelen Ströme wieder
zusammenführen:
Stream<String> stream = liste.stream().parallel().filter(Objects::nonNull).map(String::trim).sequential();
Welcher Strom dann welche Objekte zur Verarbeitung erhält ist nicht vorhersagbar. Die λ-Ausdrücke, die .map
verwendet, sollten daher immer zustandsfrei sein und keinen Bezug zu anderen Objekten haben. Und bevor man
ein Objekt verändert, sollte man überlegen, ob es nicht besser ist statt dessen ein neues Objekt zu erzeugen – so
wie String::trim
es tatsächlich auch tut.
Streams entstammen der funktionalen Welt und funktionieren daher am besten mit seiteneffektfreien Funktionen
und mit unveränderlichen Objekten.
Schritt 3: Abschluß
Nachdem wir nun allerhand Schabernack mit den Objekten des Stroms getrieben haben, ist es an der Zeit den
Stream zu schließen und die Früchte unserer Arbeit zu ernten. Die meisten Abschluß-Funktionen des Stream
-
Interface liefern ein einzelnes Ergebnis das in ein Optional
verpackt ist. Das bietet uns die Möglichkeit mit leeren
Streams und ungültigen Ergebnissen umzugehen.
Wie bereits erwähnt läuft der Stream mit Aufruf der Abschluß-Methode los und die (komplette) Verarbeitung wird durchgeführt.
Quantoren: Alle oder Keins
Mit den Methoden anyMatch()
und allMatch()
kann geprüft werden, ob der Strom ein Objekt mit einer
bestimmten Eigenschaft enthält oder nicht. Sie arbeiten "lazy", das heißt daß nur solange Objekte des Stroms
ausgewertet werden bis das Ergebnis feststeht. Auch das ist beim Design der verwendeten λ-Ausdrücke zu beachten.
Enthält ein λ-Ausdruck Seiteneffekte, muß man berücksichtigen daß diese nicht für jedes Objekt im Strom zur Geltung
kommen. Das folgende Beispiel prüft, ob die Liste (mindestens) einen String der Länge 5 enthält:
boolean fuenfExists = liste.stream().filter(Objects::nonNull).map(String::length).anyMatch(x -> x == 5);
Dieses Beispiel soll prüfen, ob alle Strings in der Liste ungleich null
sind.
boolean alleGesetzt = liste.stream().allMatch(Objects::nonNull);
Statt "alle Objekte sind nicht null" kann man auch sagen "kein Objekt ist null". Die passende dafür Methode ist
noneMatch()
. Mit der Methode Objects::isNull
, kann man also alternativ schreiben:
boolean alleGesetzt = liste.stream().noneMatch(Objects::isNull);
Suchfunktionen: Wenn ja, welches?
Wenn das schiere Wissen um die Existenz nicht reicht, kann man sich auch das erste oder irgendein Objekt aus dem
Strom geben lassen. Die Methode findAny()
liefert irgendein Objekt und findFirst()
liefert das erste Objekt
das den Stream vollständig passiert hat. Hier suchen wir den ersten String, der nicht null
ist:
Optional<String> firstOne = liste.stream().filter(Objects::nonNull).findFirst();
Da der Strom kein solches Objekt erhalten muß, ist das Ergebnis in ein Optional
verpackt. Die Methode findAny()
tut im Prinzip das Gleiche, liefert aber nicht unbedingt das erste Objekt im Strom, das die gegebenen Eigenschaften
erfüllt. Der Unterschied der beiden Methoden wird erst bei parallelen Strömen spürbar, wenn die Reihenfolge der
Verarbeitung nicht mehr vorhersagbar ist.
Für nicht-parallele Streams liefern beide Methoden das gleiche Ergebnis. Man tut aber gut daran, für den jeweiligen Zweck die entsprechende Methode zu verwenden. Erstens wird dadurch dei Intention des Programmierers dokumentiert und zweitens ist die Implementierung für Parallelisierung gewappnet; falls das eines Tages gewünscht sein sollte.
Mach' was mit: Verarbeitung der Objekte
Ein Use Case für Streams ist die Verarbeitung einer Menge von Objekten. Hierfür dient die Methode foreach()
. Als
Parameter dient ein λ-Ausdruck das das Interface {{java|Consumer{{java| implementiert. Es nimmt ein Objekt des Eingabe-Stroms
und "liefert" void
– also gar nichts. Das Objekt verschwindet zwar nicht, wird aber auch nicht weitergereicht, die
Verarbeitung endet hier.
Im Beispiel werden alle Strings die nicht null
sind auf der System-Ausgabe ausgegeben:
liste.stream().filter(Objects::nonNull).forEach(System.out::print);
Das Ergebnis sammeln: collect und count
Streams sind sehr gut dafür geeignet, um aus Listen neue Listen zu generieren. Dazu muß man den Ergebnis-Strom
auffangen und in ein passendes Gefäß füllen. Die Möglichkeiten, die Java hier bietet sind vielfältig und einen
eigenen Beitrag wert. Die einfachste Variante ist die Verwendung der Klasse Collectors
um den Ergebnis-Strom in
ein Collection
-Objekt zu füllen. Im Beispiel werden zunächst alle null
-Werte aus dem Strom entfernt, das
Ergebnis sortiert und schließlich in ein neues List
-Objekt gefüllt:
List<String> liste = Arrays.asList("This", "is", "a", "test"); List<String> sort = liste.stream().filter(Objects::nonNull).sorted().collect(Collectors.toList());
Die Methode Collectors.toList()
entscheidet dabei, welche List
-Implementierung verwendet wird.
Eine weitere, einfache Anwendung ist das Zählen von Objekten. Hier zählen wir alle Objekte im Strom, die nicht null
sind:
long anzahl = liste.stream().filter(Objects::nonNull).count();
Ein Optional
brauchen wir diesmal nicht. Wenn der Ausgabe-Strom leer ist, ist das Ergebnis 0, also "zero" und nicht "null".
Im Grunde genommen ist das ein Spezialfall eines Collectors, der die Liste auf ein einzelnes Objekt – in diesem Falle eine Zahl – reduziert.
Es gibt weitere, komplexere Kollektoren, aber für den Anfang mögen diese einfachen Beispiele genügen. Diese Kombination von Verarbeitung einer Liste und Zusammenführen der Ergebnisse ist auch als "map-reduce"-Pattern bekannt. Weitergehende Betrachtungen zur collect-Methode finden sich hier: Streams: Ergebnis sammeln.
Fazit
Der Beitrag konnte nur einen Einblick bieten, vor allem die Themen "Parallelisierung" und "Kollektoren" konnten nur
angerissen werden,
aber vielleicht wird hier schon deutlich, welches Potential in den Streams steckt. Java-9 erweitert das Konzept
zudem in Richtung "reactive", was neue Möglichkeiten bietet – Streams gehört (wenigstens zu einem Teil) die Zukunft.
Zum Schluß noch der direkte Vergleich zwischen einer klassischen und einer Stream-Lösung.
Eine Methode soll uns dafür mitteilen, wieviele null
-Werte eine String-Liste enthält.
Mit einer "enhanced for"-Schleife sieht das so aus:
private long count(List<String> liste) { int result = 0; for(String s : liste) { if (s != null) { result++; } } return result; }
Mit Streams sieht das so aus:
private long count(List<String> liste) { return liste.stream().filter(Objects::nonNull).count(); }
Der Leser entscheide selbst, was schöner ist.