Streams Basics
Ziele
- Ich kann in eigenen Worten und mit Hilfe von Skizzen erklären, was Streams sind und wofür sie verwendet werden.
- Ich kann Streams für die Iteration über Listen anwenden.
- Ich kann mindestens eine intermediäre und eine terminale Stream-Operation aus dem Kopf nennen und beschreiben.
Streams
Mit Java 8 ist das Stream-API zum java.util
-Package des JDKs hinzugekommen.
Das API ist eine Erweiterung des Java-Collection-Frameworks mit einer Schnittstelle im Stil der funktionalen Programmierung.
Mit dem Stream-API wurden mächtige Möglichkeiten zur Durchführung von Operationen auf Array
s und List
en eingeführt.
In diesem Teil wird erklärt, was Stream
s sind und wie sie für Operationen auf Arrays und Listen eingesetzt werden können.
Was ist ein Stream?
Streams stellen Ströme von Referenzen dar, die es erlauben, verkettete Operationen auf diesen Referenzen nacheinander oder parallel auszuführen.
Ein Stream erhält seinen Input aus Datenstrukturen wie Arrays oder Listen und führt die gewünschte Operationen auf diesem Input aus, ohne die ursprüngliche Datenstruktur zu verändern.
Nachfolgend ist ein Code aufgelistet, der aus einem Array mit den verschiedenen Punktzahlen von verschiedenen Studierenden aus einer Prüfung
- alle Punktzahlen aussortiert, die
0
oder kleiner sind (Intermediäre Operationfilter(...)
), - dann die Punktzahlen in Noten umrechnet (Intermediäre Operation
mapToDouble(...)
), - und dann den Durchschnitt über alle Studierenden berechnet (Terminale Operation
average()
, zu Deutsch “Durchschnitt”).
|
|
Die einzelnen Bestandteile werden in den weiteren Unterkapitel genauer beleuchtet und die sogenannten Lambda-Ausdrücke score -> score > 0
und score -> score * 5f / maxScores + 1f
werden später erläutert.
Erzeugung von Streams
Damit überhaupt mit Streams gearbeitet werden kann, muss zuerst ein Stream existieren bzw. erzeugt werden. Streams können aus Arrays, Listen und anderen Collections erzeugt werden.
Erzeugung aus Elementes eines Arrays
Aus den Elementen eines Arrays kann ein Stream mithilfe der Klasse Arrays
aus dem java.util
-Package wie folgt erzeugt werden:
|
|
Erzeugung aus Elementen einer Liste
Wenn eine Liste bereits vorhanden ist, kann die Methode stream()
aufgerufen werden, um einen Stream
aus den Elementen der Liste zu erzeugen:
|
|
Unterschied zwischen generischen Streams und IntStreams
Wenn du beim Aufruf von Arrays.stream(...)
ein Array vom Typ int[]
oder double[]
übergibst, erhältst du keinen gewöhnlichen Stream vom Typ Stream
sondern einen optimierten Stream-Typ für den entsprechenden Datentyp:
- ein
int
-Array resultiert in einemIntStream
, - ein
double
-Array in einemDoubleStream
, usw.
An dieser Stelle könnte man sich fragen wieso. Aber die Antwort ist ziemlich klar:
Ein IntStream
besitzt mehr Methoden als ein Stream<Integer>
. So kannst du auf dem Stream z.B. direkt eine Summe (.sum()
) oder Durchschnitt (.average()
) berechnen, statt selbst diese Funktionen zu implementieren.
Hast du z.B. ein Stream<Integer>
und möchtest aber eine Summe berechnen, dann kannst du z.B. den Stream<Integer>
mit der Methode mapToInt(...)
in einen IntStream
umwandeln:
|
|
Lasse dich von der “Methodenreferenz” Integer::intValue
nicht verwirren - wird in einem der nächsten Unterkapitel erklärt. Diese wird hier angegeben, damit beim Stream klar ist, wie jeder einzelne Integer
in einen int
umgewandelt wird. In diesem Fall wird ein Integer integer
wie folgt umgewandelt: int neuerWert = integer.intValue()
.
Lambda Expressions
Streams arbeiten mit sog. Lambda-Expressions oder Methodenreferenzen. Lambda Expressions (Lambda-Ausdrücke) wurden in Java 8 eingeführt, damit Funktionen als Argumente bei Methoden übergeben werden können.
Da Lambda-Expressions oft in Streams verwendet werden, wird hier aufgezeigt, wie Lambdas aussehen und wie sie verwendet werden können.
Lambda-Ausdrücke in Java sind quasi Methoden ohne Namen. Sie bestehen aus folgenden Elementen:
- einer Liste von Parametern. Mehrere Parameter werden durch ein Komma separiert und mit Klammern umrundet.
(keine Parameter werden mit leeren Klammern
()
dargestellt, einen Parameter muss nicht zwingend mit Klammern umrundet werden) - einem Pfeil-Token
->
- und einem Funktionsrumpf. Wenn der Funktionsrumpf mehrere Anweisungen lang ist, wird er mit geschweiften Klammern
{ ... }
umrundet. Wenn keine geschweiften Klammern verwendet werden, dann ist der Ausdruck nach dem Pfeil-Token automatisch der Rückgabewert der Funktion (dasreturn
entfällt).
Im Gegensatz zu Methoden werden der Rückgabetyp und Exceptions nicht spezifiziert, sondern vom Compiler “erraten”.
Im Beispiel mit den Prüfungsnoten haben wir mit .mapToDouble(score -> score * 5.0 / maxScores + 1.0)
die einzelnen Punktzahlen in Noten umgerechnet (map()
-Methoden werden später erklärt). Hierbei wurde der Lambda-Ausdruck score -> score * 5.0 / maxScores + 1.0
verwendet. Dieser Lambda-Ausdruck ist eine Funktion (Methode), die beschreibt, wie jede Punktzahl in eine Note umgerechnet werden soll. Würden wir diesen Lambda-Ausdruck in eine Methode umschreiben, dann könnte diese so aussehen:
|
|
Der Lambda-Ausdruck score -> score > 0
hingegen könnte als Methode so geschrieben werden:
|
|
Beispiele
Hier noch ein paar Beispiele, wie Lambda-Ausdrücke geschrieben werden können:
|
|
Method Reference
Eine Methoden-Referenz ist die verkürzte Schreibweise einer Lambda-Expression, welche nur einen einzigen Methodenaufruf beinhaltet. Die generische Syntax für Methodenreferenz sieht wie folgt aus: Klasse::methode. Bei Methoden-Referenzen werden die Argumente für die Methode nicht notiert.
|
|
Der wesentliche Vorteil von dieser Schreibweise ist, dass er kürzer ist. Lambda-Ausdrücke sind aber oft einfacher zu verstehen.
Parallele Streams
Anders als beim sequentiellen Stream werden beim ParallelStream
mehrere Elemente gleichzeitig verarbeitet, um die Geschwindigkeit zu erhöhen.
Als Beispiel haben wir eine Liste von Zahlen wollen diese summieren. Mit einem normalen Stream würdest du nun jede Zahl nacheinander verarbeiten. Mit einem ParallelStream hingegen werden die Zahlen auf mehrere Threads verteilt und gleichzeitig verarbeitet.
|
|
ParallelStream
kann bei großen Datenmengen schneller sein, muss aber nicht immer der Fall sein.
Methodenausführung auf Streams
Im Beispiel mit den Prüfungsnoten haben wir verschiedene Operationen auf dem Stream durchgeführt, die die einzelnen Werte entweder umrechnen oder am Schluss in einem einzigen Wert zusammenfasst (z.B. average()
).
Folglich stellen Streams Operationen zur Verfügung, welche in zwei Kategorien unterteilt werden können:
- Intermediäre Operationen, welche am Ende der Verarbeitung in einem Stream resultieren (und somit eine weitere, verkettete Verarbeitung ermöglichen) wie z.B.
filter(...)
odermap(...)
. - Terminale Operationen, welche am Ende der Verarbeitung einen Wert zurückliefern (und somit den Stream beenden) wie
sum()
oderaverage()
.
Folgendes Bild illustriert die Arbeitsweise von Streams
Nun werden einige Operationen auf Streams vorgestellt:
- Intermediäre Operationen:
filter(...)
sortiert alle Elemente aus, die NICHT die übergebenen Bedingung erfüllen.map(...)
,mapToInt(...)
undmapToDouble(...)
wandeln die einzelnen Stream-Elemente in andere Werte um (bilden diese ab auf andere).sorted()
sortiert die einzelnen Werte.
- Terminale Operationen:
- Mit
forEach(...)
kann für jedes Element etwas gemacht werden (z.B. jedes Element ausgeben). collect(...)
undtoArray(...)
füllen die einzelnen Elemente in Listen oder Arrays ab.
- Mit
Intermediäre Operationen
Die filter(...)
-Methode
Die filter(...)
-Methode ist eine intermediäre Operation, die Elemente in einem Stream auf diejenigen beschränkt, die einer bestimmten Bedingung entsprechen. Diese Bedingung wird als Lambda-Ausdruck angegeben, der true
zurückgibt, wenn das Element im Stream bleiben soll. Gibt er false
zurück, wird das Element aussortiert.
Im folgenden Beispiel werden alle ungeraden Zahlen aus einem Stream entfernt und dann alle verbleibenden Elemente ausgegeben:
|
|
Die map(...)
- und mapToInt(...)
-Methode
Die map(...)
- und mapToInt(...)
-Methode gehört zu den intermediären Operationen eines Streams.
Die Methode liefert einen Stream zurück, worin jedes einzelne Element durch den Rückgabewert der übergebenen Funktion ersetzt wurde.
Die map(...)
-Methode wird oft verwendet, um Daten umzuwandeln oder den Stream auf ein Feld/Methode eines Objekts zu fokussieren.
Hier ein Beispiel, in welchem Zahlen durch ihr Quadrat ersetzt werden:
|
|
Und hier ein Beispiel, wo uns nur die Länge der Strings interessiert:
|
|
Im zweiten Beispiel könnte unser Ziel sein, die durchschnittliche Länge der Wörter zu berechnen. Wenn man mathematische Operationen mit Streams durchführen möchte, dann ist es oft einfacher, den Stream in einen für den mathematischen Typ spezifischen Stream wie IntStream
zu “verwandeln”, damit Funktionen wie sum()
und average()
(Durchschnitt) nicht manuell implementiert werden müssen. Hierfür kannst du statt der map(...)
- die mapToInt(...)
-Methode (oder mapToDouble
) verwenden:
|
|
Die sorted() Methode
Die sorted()
-Methode gehört zu den intermediären Operationen eines Streams.
Die Methode liefert ein Stream zurück, worin die Elemente im Stream nach ihrer natürlichen Reihenfolge (natural order) sortiert sind.
Die Syntax der Methode ist wie folgt: Stream<T> sorted()
wobei T
der Typ der Elemente innerhalb des Streams ist
Beispiel mit einem Array
|
|
Beispiel mit einer Liste
|
|
Terminale Operationen
Die forEach() Methode
Die forEach(Consumer action) Methode gehört zu den terminalen Operationen eines Streams.
Der Parameter action
ist vom Typ Consumer
(ist ein FunctionalInterface
). Dieser Typ repräsentiert eine Operation (eine Funktion),
welche nur ein einziges Input-Argument akzeptiert und keine Ergebnisse (also void
) zurückliefert.
Ein Beispiel für so einen Consumer ist die Methode System.out.println(...)
,
welche maximal ein einziges Objekt als Parameter akzeptiert, dieses Objekt in den Standard-Output ausgibt und void
(also kein Ergebnis) zurückliefert.
Die Methode System.out.println
erfüllt also die Bedingungen eines Consumers und kann als Parameter für die forEach()
Methode verwendet werden
Die forEach()
-Methode kann als Ersatz für einen for
-Loop verwendet werden.
Beispiel mit einem Array
|
|
Beispiel mit einer Liste
|
|
Die collect()
-Methode
Die collect(Collector collector)
-Methode ist auch eine terminale Operation auf einem Stream.
Sie ermöglicht es, die Ergebnisse der Bearbeitung des Streams in einer neuen Collection (List, Map usw.) zu speichern.
Dies ist nötig, da bei der Bearbeitung des Streams die ursprüngliche Elemente nicht geändert werden können.
Der Parameter collector ist vom Typ Collector. Die Aufgabe eines Collectors besteht darin, mehrere Input-Elemente in einem Result-Container zusammenzufassen. Zum Beispiel können die Elemente eines Streams in einer Liste “gespeichert” und zurückgeliefert werden.
Um ein Collector zu erzeugen, wird oft die Klasse Collectors aus dem java.util.stream
-Package verwendet.
Diese beinhaltet mehrere öffentliche, statische Methode um Collectors unterschiedlicher Typen (List, Map usw.) erzeugen zu können.
Beispiel
|
|
Die toArray(...)
-Methode
Mit der collect(...)
-Methode kannst du den Stream in eine Liste umwandeln. Wenn du den Stream aber in ein Array umwandeln möchtest, dann hilft dir die toArray(...)
-Methode:
|
|
Die toArray(...)
-Methode ist eine terminale Operation auf einem Stream.
Die reduce(...)
-Methode
Die reduce()
-Methode in Java Streams wird verwendet, um mehrere Werte in einen einzelnen Wert zu kombinieren. Man gibt eine Funktion an, die zwei Werte zusammenfügt, und kann einen Startwert angeben, um auch bei einem leeren Stream ein Ergebnis zu erhalten.
|
|
Jetzt bist du dran. Löse bitte die Aufgabe 1 - 3 in den Stream-Labs.