17.3 Java Stream-API
Zusammen mit Lambda-Ausdrücken, die auf der Sprache-Seite stehen, wurde in Java 8 eine ganz neue Bibliothek implementiert, die ein einfaches Verarbeiten von Datenmengen möglich macht. Ihr Name: Stream-API. In ihrem Zentrum stehen Operationen zum Filtern, Abbilden und Reduzieren von Daten aus Sammlungen.
17.3.1 Deklaratives Programmieren
Die Stream-API wird im funktionalen Stil genutzt, und Programme lesen sich damit sehr kompakt – die einzelnen Methoden werden in diesem Kapitel alle detailliert vorgestellt:
Object[] words = { " ", '3', null, "2", 1, "" };
Arrays.stream( words ) // Erzeugt neuen Stream
.filter( Objects::nonNull ) // Belasse Nicht-null-Referenzen im Stream
.map( Objects::toString ) // Konvertiere Objects in Strings
.map( String::trim ) // Schneide Weißraum ab
.filter( s -> ! s.isEmpty() ) // Belasse nichtleere Elemente im Stream
.map( Integer::parseInt ) // Konvertiere Strings in Ganzzahlen
.sorted() // Sortiere die Ganzzahlen
.forEach( System.out::println ); // 1 2 3
Während die Klassen aus der Collection-API optimale Speicherformen für Daten realisieren, ist es Aufgabe der Stream-API, die Daten komfortabel zu erfragen und zu aggregieren. Gut ist hier zu erkennen, dass die Stream-API das Was betont, nicht das Wie. Das heißt, Durchläufe und Iterationen kommen im Code nicht vor, sondern die Fluent-API beschreibt deklarativ, wie das Ergebnis aussehen soll. Die Bibliothek realisiert schlussendlich das Wie. So kann eine Implementierung zum Beispiel entscheiden, ob die Abarbeitung sequenziell oder parallel erfolgt, ob die Reihenfolge eine Rolle spielen muss oder ob alle Daten zwecks Sortierung zwischengespeichert werden müssen usw.
17.3.2 Interne versus externe Iteration
Als Erstes fällt bei der Stream-API auf, dass die klassische Schleife fehlt. Normalerweise gibt es Schleifen, die durch Daten laufen und dann Abfragen auf den Elementen vornehmen. Traditionelle Schleifen sind immer sequenziell und laufen von Element zu Element, und zwar vom Anfang bis zum Ende. Das Gleiche gilt auch für einen Iterator. Die Stream-API verfolgt einen anderen Ansatz. Mit ihrer Hilfe kann die externe Iteration (durch Schleifen vom Entwickler gesteuert) durch eine interne Iteration (die Stream-API holt sich Daten) abgelöst werden. Wenn etwa forEach(…) nach Daten fragt, wird die Datenquelle abgezapft und ausgesaugt, aber erst dann. Der Vorteil ist, dass wir zwar bestimmen, welche Datenstruktur abgelaufen werden soll, aber wie das intern geschieht, kann die Implementierung selbst bestimmen und dahingehend optimieren. Wenn wir selbst die Schleife schreiben, läuft die Verarbeitung immer Element für Element, während die interne Iteration auch von sich aus parallelisieren und Teilprobleme von mehreren Ausführungseinheiten berechnen lassen kann.
[»] Hinweis
An verschiedenen Sammlungen hängt eine forEach(…)-Methode, die über alle Elemente läuft und eine Methode auf einem übergebenen Konsumenten aufruft. Das heißt jetzt nicht, dass die klassische for-Schleife – etwa über das erweiterte for – damit überflüssig wird. Neben der einfachen Schreibweise und dem einfachen Debuggen hat die übliche Schleife immer noch einige Vorteile. forEach(…) bekommt den auszuführenden Code in der Regel über einen Lambda-Ausdruck, und der hat Einschränkungen. So darf er etwa keine lokalen Variablen beschreiben (alle vom Lambda-Ausdruck adressierten lokalen Variablen sind effektiv final), und Lambda-Ausdrücke dürfen keine geprüften Ausnahmen auslösen – im Inneren einer Schleife ist das alles kein Thema. Im Übrigen gibt es für Schleifenabbrüche das break, das in Lambda-Ausdrücken nicht existiert (ein return im Lambda entspricht continue).
17.3.3 Was ist ein Stream?
Ein Strom ist eine Sequenz von Daten (aber keine Datenquelle an sich), die Daten wie eine Datenstruktur speichert. Die Daten vom Strom werden in einer Kette von nachgeschalteten Verarbeitungsschritten
gefiltert (engl. filter),
transformiert/abgebildet (engl. map) und
komprimiert/reduziert (engl. reduce).
Die Verarbeitung entlang einer Kette nennt sich Pipeline und besteht aus drei Komponenten:
Am Anfang steht eine Datenquelle, wie etwa ein Array, eine Datenstruktur oder ein Generator.
Es folgen diverse Verarbeitungsschritte wie Filterungen (Elemente verschwinden aus dem Strom) oder Abbildungen (ein Datentyp kann auch in einen Datentyp konvertiert werden). Diese Veränderungen auf dem Weg nennen sich intermediäre Operationen (engl. intermediate operations). Ergebnis einer intermediären Operation ist wieder ein Stream.
Am Schluss wird das Ergebnis eingesammelt, und das Ergebnis ist kein Stream mehr. Eine Reduktion wäre zum Beispiel die Bildung eines Maxiums oder die Konkatenation von Strings.
Die eigentliche Datenstruktur wird nicht verändert, vielmehr steht am Ende der intermediären Operationen eine terminale Operation, die das Ergebnis erfragt. So eine terminale Operation ist etwa forEach(…): Sie steht am Ende der Kette, und der Strom bricht ab.
Viele terminale Operationen reduzieren die durchlaufenden Daten auf einen Wert, anders als etwa forEach(…). Dazu gehören etwa Methoden zum einfachen Zählen der Elemente oder zum Summieren. Das nennen wir reduzierende Operationen. In der API gibt es für Standardreduktionen – wie für die Bildung der Summe, des Maximums, des Durchschnitts – vorgefertigte Methoden, doch sind allgemeine Reduktionen über eigene Funktionen möglich, etwa statt der Summe das Produkt.
Lazy Love
Alle intermediären Operationen sind »faul« (engl. lazy), weil sie die Berechnungen so lange hinausschieben, bis sie benötigt werden. Am ersten Beispiel ist das gut abzulesen: Wenn die Elemente aus dem Array entnommen werden, werden sie der Reihe nach zum nächsten Verarbeitungsschritt weitergereicht. Entfernt der Filter Elemente aus dem Strom, sind sie weg und müssen in einem späteren Schritt nicht mehr berücksichtigt werden. Es ist also nicht so, dass die Daten mehrfach existieren, zum Beispiel in einer Datenstruktur mit allen Elementen ohne null, dann alle Objekte, die in Strings konvertiert werden, dann alle getrimmten Srings usw.
Im Gegensatz zu den fortführenden Operationen stehen terminale Operationen, bei denen das Ergebnis vorliegen muss: Sie sind »begierig« (engl. eager). Im Prinzip wird alles so lange aufgeschoben, bis ein Wert gebraucht wird, das heißt, bis eine terminale Operation auf das Ergebnis wirklich zugreifen möchte.
Zustand ja oder nein
Intermediäre Operationen können einen Zustand haben oder nicht. Eine Filteroperation zum Beispiel hat keinen Zustand, weil sie zur Erfüllung ihrer Aufgabe nur das aktuelle Element betrachten muss, nicht aber die vorangehenden. Eine Sortierungsoperation hat dagegen einen Status: Sie »will«, dass alle anderen Elemente gespeichert werden, denn das aktuelle Element reicht für die Sortierung nicht aus, sondern auch alle vorangehenden werden benötigt.