Collections effektiver durchsuchen mit Java-8-Streams

Wenn man bis Java 7 die Elemente einer Collection bearbeiten wollte, musste man sich mühsam mithilfe einer Schleife oder eines Iterators durch die Elemente klicken. In Java 8 sind Streams als neues Sprachelement eingeführt worden. Sie ermöglichen die Bearbeitung von Collections mit weniger und kompakterem Code, ohne dass die Lesbarkeit darunter leidet.

 

Collections durchlaufen bis Java 7

Wer früher in Java durch die Elemente einer Collection laufen und mit jedem Element eine Aktion durchführen wollte, musste eine For-Schleife verwenden. Eine Verkürzung stellt die For-Each-Schleife dar. Als Alternative kann auch ein Iterator-Objekt verwendet werden. (Listing 1) zeigt Beispiele für alle drei Möglichkeiten. Prinzipiell musste der Programmierer jedes Mal jede Menge repetitiven Boilerplate-Code schreiben. Mit den in Java 8 eingeführten Streams soll das Durchlaufen von Collections einfacher werden.

Listing 1

 

Was sind Streams in Java 8?

Die Java-8-Streams dürfen nicht mit den Klassen InputStream und OutputStream der I/O-Bibliothek von Java verwechselt werden. Es handelt sich dabei um zwei vollkommen unterschiedliche Konzepte. Die beiden Klassen InputStream und OutputStream ermöglichen das Bearbeiten von Dateien als einen kontinuierlichen Strom aus Zeichen.

Java-8-Streams dagegen repräsentieren eine Sequenz aus Elementen einer Collection, auf die aufeinander folgende Operationen durchgeführt werden können. Da die Stream-API als eine Fluent-API implementiert ist, ist es möglich, mehrere Methodenaufrufe aneinander zu hängen und die Verarbeitung mit einer terminalen Methode zu beenden. Dieses Konzept wird später noch genauer erläutert.

 

Das Kernstück der Stream-API von Java 8 bildet das Interface Stream, dessen Methoden meistens mit funktionalen Interfaces arbeiten und somit Lambda-Ausdrücke ermöglichen. Die Stream-API in Java 8 ist sehr umfangreich. In diesem Artikel soll die grundlegende Arbeitsweise mit Streams verdeutlicht werden, indem einige Methoden exemplarisch vorgestellt werden. Wer sich für weitere Methoden interessiert, sollte einen Blick auf die Dokumentation von Java 8 werfen.

 

Streams können nicht wiederverwendet werden, d.h. sobald eine terminale Methode aufgerufen wurde, wird der Stream beendet und geschlossen. Versucht man anschließend den Stream noch einmal zu benutzen, wirft Java eine IllegalStateException.

 

Mithilfe von forEach() durch eine Collection laufen

(Listing 2) zeigt das Beispiel aus (Listing 1) nun mit einem Stream. Die Methode forEach() erhält als Übergabe ein Predicate-Objekt, das durch einen Lambda-Ausdruck dargestellt werden kann. Dieser Lambda-Ausdruck gibt an, was mit jedem Element in der Collection getan werden soll. Damit werden die in (Listing 1)  vorgestellten Schleifen ersetzt.

 

Listing 2

 

Elemente einer Collection filtern

Die If-Anweisung stört in dem Listing noch ein wenig. Aber auch dieses Problem kann mithilfe der Stream-API gelöst werden. Dazu wird die Methode filter() zur Verfügung gestellt. Sie ermöglicht es, die Elemente einer Collection nach einer Bedingung zu filtern. (Listing 3)  zeigt das gleiche Programm noch einmal, nur wird dieses Mal die If-Anweisung durch die besagte Methode filter() ersetzt. Im Beispiel werden somit zuerst die Strings nach ihrer Länge aussortiert und auf die übrig gebliebenen Elemente wird dann die Methode forEach() angewendet.

Listing 3

 

Erzeugen von Streams

Nachdem in den ersten Beispielen die Benutzung von Streams demonstriert wurde, stellt sich nun die Frage: Wie werden Streams jetzt eigentlich erzeugt? (Listing 4) zeigt zwei Möglichkeiten zur Erzeugung von Streams. Das erste Beispiel generiert einen Stream mithilfe der Methode of(). Diese Methode erhält als Übergabe eine kommaseparierte Liste von Elementen. Übergibt man of() ein List-Objekt, wird diese Liste als ein Element einer Collection interpretiert.

 

Das zweite Beispiel zeigt wie ein Stream erzeugt wird, wenn bereits eine fertige Collection zur Verfügung steht. Dann wird direkt aus der Collection mittels der Methode stream() ein Stream-Objekt erstellt. Diese beiden Methoden erzeugen endliche serielle Streams. Später werden noch die parallelen und die unendlichen Streams vorgestellt. Im Beispiel wird ein serieller Stream aus einer Menge von String-Objekten erzeugt. Natürlich können Streams auch beliebige andere Objekttypen enthalten.

Listing 4

 

Spezialisierte Streams

Neben den Streams, die mit allen Objekttypen arbeiten können, gibt es auch noch spezialisierte Streams. Listing 5 zeigt den IntStream. Diese spezialisierten Streams bieten natürlich auch spezielle Methoden für den entsprechenden Datentyp. So ist im Beispiel die Methode average() zu sehen, welche den Durchschnitt der int-Werte in der Collection berechnet, ohne dass der Programmierer ein Schleifen-Konstrukt schreiben muss. Neben der hier vorgestellten Methode average() bietet IntStream z.B. auch die Methoden min(), max() und sum(). Für weitere spezialisierte Streams sei auf die Dokumentation verwiesen.

Listing 5

 

Streams mithilfe von map() verändern

Bisher wurden die Werte in einem Stream nur ausgegeben und nicht weiterverarbeitet. Da die Methode forEach() ein Consumer-Objekt als Übergabeparameter erhält, kann sie die Werte in der Collection nicht verändern. Mithilfe der Methode map() ist es jedoch möglich, die Elemente aus der Collection zu bearbeiten und einen Stream zu erzeugen, der die veränderten Werte enthält. (Listing 6) zeigt ein Beispiel. Nach dem Filtern der Strings mit mehr als sieben Zeichen werden die verbliebenen Strings in Großbuchstaben umgewandelt und anschließend ausgegeben. Wichtig zu beachten ist, dass die Methode map() einen Stream zurückgibt mit den Elementen, auf welche die Funktion angewendet wurde, d.h. die Elemente in der Collection werden nicht dauerhaft verändert, sondern nur im Rahmen der aktuellen Stream-Anweisung.

Listing 6

 

Verarbeitungsreihenfolge

Wenn man sich die bisherigen Beispiele ansieht und über die Verarbeitungsreihenfolge nachdenkt, könnte man vermuten, dass die Methoden horizontal abgearbeitet werden, d.h. zuerst werden alle Elemente der Collection durch die erste Methode gejagt und anschließend wird erst die zweite Methode aufgerufen. Wenn das Beispiel-Programm in (Listing 7) ausgeführt wird, ergibt sich jedoch eine interessante Erkenntnis: Die  Elemente der Collection werden zwar nacheinander abgearbeitet, aber jedes Element wird durch die gesamte Pipeline geschickt, bevor das nächste Element verarbeitet wird. Die Ausgabe wird also für die ersten beiden Elemente wie folgt aussehen:

 

filter: A

forEach: A

filter: B

forEach: B

Listing 7

 

Diese Verarbeitung soll unter bestimmten Umständen Performance-Verbesserungen ermöglichen wie in dem in (Listing 8) angedeuteten Fall. Die Methode anyMatch() läuft solange, bis zum ersten Mal ein Element gefunden wird, auf das die Bedingung passt. Würde der Stream horizontal laufen, also mit einer Methode zuerst alle Elemente bearbeiten, müsste die Methode map() für alle Elemente durchgeführt werden, auch wenn die Methode anyMatch() nach dem zweiten Element schon die Verarbeitung beendet. Durch die vertikale Verarbeitung muss map() nur für die ersten zwei Elemente aufgerufen werden.

Listing 8

Es gibt allerdings Ausnahmen von dieser Verarbeitungsreihenfolge wie es z.B. bei der Methode sorted() der Fall ist, denn Sortieren ergibt nur Sinn, wenn die Sortierung auf der gesamten Datenmenge ausgeführt wird.

 

Streams sortieren

Um die Werte in einer Collection zu sortieren, kann die bereits im letzten Abschnitt erwähnte Methode sorted() eingesetzt werden. (Listing 9) zeigt zwei Beispiele. Im ersten Beispiel wird eine Collection aus Strings sortiert. Die Methode sort() weiß wie Strings sortiert werden sollen und muss somit keine weiteren Informationen mehr erhalten. Im zweiten Beispiel sollen die Elemente absteigend sortiert werden. Da dies nicht der Standardsortierung entspricht, wird der Methode ein Comparator-Objekt in Form eines Lambda-Ausdrucks mit der Sortierregel übergeben. Auf diese Art können neben Standardsortierungen auch beliebig komplexe Objekte nach beliebigen Regeln sortiert werden.

Listing 9

 

Fluent-Programmierung

In den Beispiel-Listings ist eine bereits erwähnte Besonderheit der Stream-API zu erkennen, die eine sehr kompakte Schreibweise erlaubt. Mithilfe der Fluent-Programmierung können die Methodenaufrufe einfach miteinander verkettet werden. Intermediäre Methoden dürfen beliebig oft aufgerufen werden. Sie geben ein Stream-Objekt zurück, das von der nächsten Methode weiterverarbeitet werden kann. Abgeschlossen wird die Stream-Verarbeitung mit einer terminalen Methode. Da diese den Stream beendet, darf sie nur ein einziges Mal in der Methodenverkettung auftreten – und zwar an deren Ende. Eine terminale Methoden hat entweder keine Rückgabe oder sie gibt ein Objekt zurück, das keinen Stream repräsentiert. Eine solche terminale Operation ist z.B. die Methode forEach().

 

(Listing 6) zeigt zwei weitere Beispiele. Das erste Beispiel demonstriert die terminale Methode count(). Das zweite Beispiel zeigt einen längeren Stream-Ausdruck. Dabei werden zuerst zwei Filter-Kriterien angewandt, dann werden doppelte Werte aus dem Ergebnis entfernt. Anschließend wird die Menge der Ergebnisse auf vier limitiert, diese vier Werte werden in Kleinbuchstaben umgewandelt und zum Schluss werden sie auf dem Bildschirm ausgegeben. Vor allem im letzten Beispiel ist die hervorragende Lesbarkeit von Stream-Ausdrücken gut zu erkennen.

Listing 10

 

Parallele Streams

Bisher wurden nur die sequentiellen Streams vorgestellt. Die Stream-API bietet aber auch die Möglichkeit der parallelen Verarbeitung. Dazu wird ein neuer Stream einfach statt mit der Methode stream() mit der Methode parallelStream() erzeugt. (Listing 11) zeigt ein einfaches Beispiel, bei dem die Längenbestimmung der einzelnen Strings gut parallelisierbar ist. Die Methode reduce() ist eine terminale Methode, welche die Werte im Stream nach einer bestimmten Regel zusammenfasst. Im Beispiel werden die Längen der Strings aufsummiert.

Listing 11

 

Mithilfe des erweiterten Beispiels in (Listing 12) kann die parallele Verarbeitung besser verstanden werden. In jedem Schritt wird ausgegeben, welcher Thread die Verarbeitung durchführt.

Listing 12

 

Zusammenführen von Streams mit Collector

Die Ergebnisse eines Streams können in einem letzten Schritt zusammengeführt werden. Dazu dient das Interface Collector. 37 Methoden sind bereits fertig implementiert und können sofort eingesetzt werden. (Listing 13) zeigt mehrere Beispiele für Methoden des Interface Collector. In dem ersten Beispiel wird der Stream in eine Liste überführt. Im zweiten Beispiel werden die Längen der gefilterten Strings aufsummiert.  Eine weitere Methode ist im dritten Beispiel zu sehen. Hier wird die Methode groupingBy() verwendet, um die im Stream enthaltenen Strings nach ihrer Länge zu gruppieren. In der Map sind am Ende also Listen mit Strings enthalten und die Längen der enthaltenen Strings sind die Schlüssel, unter denen die Listen in der Map gespeichert sind.

Listing 13

 

Unendliche Streams

Neben den endlichen Streams können auch unendliche Streams erzeugt werden. Dazu stellt Java zwei Methoden zur Verfügung, die in (Listing 14) demonstriert werden. Die Methode generate() erzeugt einen unendlichen Stream, bei dem jedes Element durch das Supplier-Objekt generiert wird, das der Methode übergeben wird. Im Beispiel wird dazu eine Zufallszahl von der Math-Klasse angefordert. Der unendliche Stream, der im zweiten Beispiel in (Listing 14) erzeugt wird, enthält alle geraden Zahlen beginnend bei zwei. Die Methode iterate() erhält als Übergabe einen Startwert und eine Funktion in Form eines Lambda-Ausdrucks.

Listing 14

 

Fazit:

Die in Java 8 neu eingeführten Streams bieten mächtige Möglichkeiten zur Verarbeitung der Elemente in einer Collection. Mithilfe der Fluent-Programmierung und Lambda-Ausdrücken können sehr kompakte und gut lesbare Anweisungen formuliert werden, so dass man auf die immer gleichen Schleifen- oder Iterator-Konstrukte verzichten kann. Bei großen Datenmengen kann man mit parallelen Streams Performance-Verbesserungen erreichen.

 

Christopher Olbertz arbeitet als Lehrkraft an der Hochschule für Technik und Wirtschaft des Saarlandes. Dort betreut er Studenten vor allem in den Programmiersprachen Java und C/C++ und der UML. Sein besonderes Interesse gilt modernen Framworks wie Spring, Hibernate, JavaServier Faces und Apache Tapestry.

 

https://thinkingaboutprogramming.blogspot.com/

 

Quellen

Code-Beispiele: https://gitlab.com/colbertz/streams-beispiele

Michael Inden: Java 8 – Die Neuerungen: Lambdas, Streams, Date And Time API und JavaFX 8 im Überblick

 

Redaktion


Leave a Reply