Streams: Java 8 Streams Einführung

Aus MimiPedia

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.