Grundlegende Operationen
Arten von Operationen auf Streams
- Streams sind optimiert und versuchen so wenig wie möglich Iterationen durchzurühren
- Man unterscheidet bei den Stream-Operationen
- intermediate operations: beschreiben, was mit den Daten zu tun ist, führen aber noch nicht zu einer Iteration oder Aktion (z. B.
filter()
) - terminal operations: erzeugen ein Ergebnis und benötigen eine Iteration (z. B.
forEach()
odercollect()
) - Nach einer terminal operation wird der Stream geschlossen und kann nicht mehr verwendet werden
Die intermediate operations bezeichnet man als lazy (faul), die terminal operations als eager (ehrgeizig). Die intermediate operations geben einen Stream zurück, sodass man über Method-Chaining weitere Operationen aufrufen kann.
Dass die Auswertung wirklich erst bei einer terminal operation durchgeführt wird, sieht man an folgendem Beispiel, das keine Ausgabe erzeugt, weil keine terminal operation aufgerufen wird:
List<String> namen = Arrays.asList(
"Anton", "Egon", "Alfred", "Barbara", "Klaus", "Herbert");
namen.stream()
.filter(name -> {
System.out.println("Filter 1: " + name);
return name.startsWith("A");
})
.filter(name -> {
System.out.println("Filter 2: " + name);
return name.charAt(1) == 'n';
});
Erst das Hinzufügen einer entsprechenden Operation (z. B. forEach()
) führt hier zu einer Ausgabe.
namen.stream()
.filter(name -> {
System.out.println("Filter 1: " + name);
return name.startsWith("A");
})
.filter(name -> {
System.out.println("Filter 2: " + name);
return name.charAt(1) == 'n';
})
.forEach(System.out::println);
}
An der Ausgabe sieht man sehr gut, wie die Operationen optimiert werden:
Filter 1: Anton
Filter 2: Anton
Anton
Filter 1: Egon
Filter 1: Alfred
Filter 2: Alfred
Filter 1: Barbara
Filter 1: Klaus
Filter 1: Herbert
Konzepte des Stream-APIs
- Stream-Methoden liefern wieder einen Stream zurück (Ausnahme: terminal operations)
- Auf dem Ergebnis können weitere Methoden aufgerufen werden (method chaining)
- Collection → Stream über
stream()
-Methode (s. o.) - Stream → Collection über
collect()
-Methode (s. u.) - Stream direkt erzeugen über
Stream.of(...)
Stream<String> stream = Stream.of(
"Anton", "Egon", "Alfred", "Barbara", "Klaus", "Herbert");
stream.forEach(System.out::println);
Das method chaining ist in Java durchaus schon vor Streams zu Einsatz gekommen, z. B. bei der Klasse StringBuilder
.
StringBuilder sb = new StringBuilder();
sb.append("Hallo")
.append(' ')
.append("Welt");
Im Gegensatz zu diesen älteren Klassen ist es aber bei Streams wichtig, dass man es benutzt, da hierdurch die Performance gesteigert wird. Der Grund liegt darin, dass die Klasse bis zum Erreichen einer terminal operation keine Werte generieren muss. Dies kann aber nur funktionieren, wenn alle Methoden auf derselben Instanz aufgerufen werden. Aus diesem Grund gilt es als guter Stil, im Zusammenhang mit Streams grundsätzlich die Methoden zu verketten.
Man wird bei Streams immer wie im folgenden Beispiel vorgehen:
List<String> namen = Arrays.asList(
"Anton", "Egon", "Alfred", "Barbara", "Klaus", "Herbert");
namen.stream()
.filter(name -> name.startsWith("A"))
.forEach(System.out::println);
Das folgende Beispiel gilt als schlechter Stil:
List<String> namen = Arrays.asList(
"Anton", "Egon", "Alfred", "Barbara", "Klaus", "Herbert");
Stream<String> aNamen = namen.stream().filter(name -> name.startsWith("A"));
aNamen.forEach(System.out::println);
Collect
- Methode
collect()
sammelt Elemente eines Streams ein (z. B. inList
) - Ist eine terminal operation
- Parameter gibt an, in welche Datenstruktur die Elemente gepackt werden sollen (Funktion)
Stream<String> stream = Stream.of(
"Anton", "Egon", "Alfred", "Barbara", "Klaus", "Herbert");
List<String> result = stream
.filter(name -> name.startsWith("A"))
.collect(toList());
System.out.println(result); // -> [Anton, Alfred]
Da die collect()
-Methode sehr umfangreich ist, wird sie später noch einmal behandelt werden. Die Methode toList()
im Beispiel stammt aus der Klasse java.util.stream.Collectors
und muss statisch importiert werden, damit das Beispiel wie oben gezeigt funktioniert:import static java.util.stream.Collectors.toList;
Ohne diesen Import muss man schreiben:
...
List<String> result = stream
.filter(name -> name.startsWith("A"))
.collect(Collectors.toList());
Die toList()
-Methode gibt eine Funktion zurück, die der collect()
-Methode mitteilt, wie sie die Objekte aus dem Stream einsammeln soll. Diese Funktion ist vom Typ Collector<T, ?, List<T>>
.
Filter
- Methode
filter()
wählt bestimmte Werte aus dem Stream aus - Ist eine intermediate operation
- Parameter ist ein
Predicate<? super T>
true
: Element soll in den neuen Stream übernommen werdenfalse
: Element soll nicht übernommen werden
List<String> namen = Arrays.asList(
"Anton", "Egon", "Alfred", "Barbara", "Klaus", "Herbert");
namen.stream()
.filter(name -> name.startsWith("A"))
.forEach(System.out::println);
Anton
Alfred
Map
- Methode
map()
konvertiert die Werte aus dem einen Stream in einen neuen Stream - Ist eine intermediate operation
- Parameter legt die Konvertierung fest, ist vom Typ
Function<? super T, ? extends R>
Stream<String> stream = Stream.of(
"Anton", "Egon", "Alfred", "Barbara", "Klaus", "Herbert");
stream
.map(name -> name.toLowerCase())
.forEach(System.out::println); // anton egon ...
Man kann das Beispiel noch kürzer schreiben, wenn man für die Map-Operation eine Methodenreferenz verwendet:
stream
.map(String::toLowerCase)
.forEach(System.out::println); // anton egon ...
Bei der Map-Operation handelt es sich um die am meisten genutzt Methode bei den Streams.
public class Schauspieler {
private String name;
private String film;
public Schauspieler(String name, String film) {
this.name = name;
this.film = film;
}
public String getName() {
return name;
}
public String getFilm() {
return film;
}
}
Stream<Schauspieler> stream = Stream.of(
new Schauspieler("John Travolta", "Pulp Fiction"),
new Schauspieler("Brad Pitt", "Inglourious Basterds"));
stream
.map(Schauspieler::getName)
.forEach(System.out::println);
John Travolta
Brad Pitt
In diesem Beispiel wurde eine Methodenreferenz für die Map-Operation verwendet, um sie kurz zu halten. Statt Schauspieler::getName
hätte man schreiben können s -> s.getName()
.
Reduce
- Methode
reduce()
fasst den gesamten Stream zu einem Wert zusammen - Ist eine terminal operation
- Braucht zwei Parameter
T identity
: Startwert der AkkumulationBinaryOperator<T> accumulator
: Funktion, die beschreibt, wie die Reduktion erfolgen soll
Stream<Integer> stream = Stream.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
int summe = stream.reduce(0, (sum, value) -> sum + value);
System.out.println(summe); // -> 55
Der Ablauf der reduce()
-Funktion lässt sich durch folgenden Java-Code beschreiben:
T result = identity;
for (T element : this stream) {
result = accumulator.apply(result, element)
}
return result;
Die übergebene Funktion (hier (sum, value) -> sum + value
) wird also
- beim ersten Durchlauf mit dem als
identity
übergebenen Wert und dem ersten Element aus dem Stream aufgerufen, - danach mit dem Rückgabewert des vorhergehenden Aufrufs und dem aktuellen Element aufgerufen.
Die folgende Tabelle zeigt die Werte für die Aufrufe des Lambdas im Beispiel:
Durchlauf | sum | value | Ergebnis |
---|---|---|---|
1. | 0 | 1 | 1 |
2. | 1 | 2 | 3 |
3. | 3 | 3 | 6 |
4. | 6 | 4 | 10 |
5. | 10 | 5 | 15 |
6. | 15 | 6 | 21 |
7. | 21 | 7 | 28 |
8. | 28 | 8 | 36 |
9. | 36 | 9 | 45 |
10. | 45 | 10 | 55 |
Die hier vorgestellte reduce()
-Operation geht davon aus, dass die Elemente des Streams und das Ergebnis der Reduktion denselben Typ haben. Wir werden später noch sehen, wie man bei der Reduktion andere Typen als Ergebnis erhalten kann.
Stream<String> stream = Stream.of("A", "B", "C", "D");
String ergebnis = stream.reduce("", (acc, value) -> acc + value);
System.out.println(ergebnis); // -> ABCD
Dieses Beispiel verkettet die Elemente des Streams zu einem einzigen String. Später wird noch eine elegantere Methode hierfür vorgestellt.
flatMap
- Methode
flatMap()
ersetzt einen Wert im Stream mit einem Stream und fügt am Ende alle Streams zu einem einzigen Stream zusammen - Ist eine intermediate operation
- Parameter legt die Konvertierung fest, ist vom Typ
Function<? super T,? extends Stream<? extends R>>
List<String> l1 = Arrays.asList("A", "B", "C");
List<String> l2 = Arrays.asList("D", "E", "F");
List<List<String>> gesamt = Arrays.asList(l1, l2);
List<String> result = gesamt.stream()
.flatMap(e -> e.stream())
.collect(toList());
System.out.println(result); // -> [A, B, C, D, E, F]
Im Beispiel werden die Daten aus einer Liste, die selbst wieder Liste enthält, in eine einzige Liste zusammengefasst.
Ohne Streams hätte man die im Beispiel gezeigte Funktionalität wie folgt implementieren müssen:
List<String> l1 = Arrays.asList("A", "B", "C");
List<String> l2 = Arrays.asList("D", "E", "F");
List<List<String>> gesamt = Arrays.asList(l1, l2);
List<String> result = new ArrayList<>();
for (List<String> l : gesamt) {
for (String s : l) {
result.add(s);
}
}
System.out.println(result); // -> [A, B, C, D, E, F]
Man sieht hier deutlich die Vorteile, die sich durch die Streams ergeben.
Optional
- Lästiges Problem in Java: Null-Werte
- Stream API gibt nie
null
zurück, sondern fallsnull
möglich, einOptional
(Typ:Optional<T>
) - Wichtige Methoden
get()
– gibt den Wert zurück oder wirft Exception, wenn kein Wert vorhandenisPresent()
– zeigt an, ob Wert vorhanden (true
)orElse(T other)
– wenn leer, dann wirdother
zurückgegeben andernfalls der Wert im OptionalofNullable(T value)
– erzeugt ein Optional aus einem Wert, dernull
sein kann
Die Klasse Optional
dient dazu, die Fallunterscheidungen, die durch mögliche Null-Werte erzeugt werden, zu vereinfachen. Anstatt jeden Rückgabewert mit != null
prüfen zu müssen, bekommt man auf jeden Fall ein gültiges Objekt zurück. Dieses kann man über isPresent()
fragen, ob ein Wert vorhanden ist oder nicht. Obwohl dies noch keinen Vorteil gegenüber einem Vergleich mit != null
bringt, hat man über die Methode orElse
die Möglichkeit, den Test ganz zu vermeiden. Andere Programmiersprachen kennen dieses Konzept schon lange, so ist zum Beispiel in Ruby Null
ein vollwertiges Objekt, auf dem man Methoden aufrufen kann.
Da das leere Optional (zu bekommen über Optional.empty()
) als Singleton realisiert ist, kommt es durch die Verwendung von Optional
nicht zu einem zusätzlichen Speicherverbrauch.
Maximum und Minimum
max()
undmin()
finden das Maximum/Minimum in einem Stream- Sind terminal operations
- Benötigen einen Comparator, um den Wert zu finden, Typ
Comparator<? super T>
- Geben ein
Optional
zurück
Stream<String> stream = Stream.of("K", "O", "B", "T", "I");
String max = stream
.max((a, b) -> a.compareTo(b))
.orElse("<leer>");
System.out.println(max); // -> T
Das Beispiel sucht in dem Stream nach dem String, der gemäß dem angegebenen Comparator
das Maximum darstellt. Anstatt des Lambdas (a, b) -> a.compareTo(b)
im Beispiel hätte man die entsprechende Methodenreferenz String::compareTo
verwenden können.
Das Schreiben eigener Comparatoren kann man bei max()
und min()
vermeiden, wenn man auf die Hilfsmethode Comparator.comparing()
zurückgreift, die es erlaubt durch Angabe einer Funktion, die den Wert eines Objektes extrahiert einen Comparator zu erzeugen. Damit sähe das Beispiel dann wie folgt aus:
Stream<String> stream = Stream.of("K", "O", "B", "T", "I");
String max = stream
.max(Comparator.comparing(a -> a))
.orElse("<leer>");
System.out.println(max); // -> T
Beispiel: Kombinierte Operationen
public class Item {
private String name;
private int price;
public Item(String name, int price) {
this.name = name;
this.price = price;
}
public int getPrice() {
return price;
}
public String getName() {
return name;
}
}
Das Beispiel beginnt mit einer Klasse Item
, die eine Bestellposition in einer Bestellung repräsentiert (einen Artikel). Jeder Artikel hat nur zwei Informationen: einen Namen und einen Preis (angegeben in Cent).
public class Order {
private List<Item> items = new ArrayList<>();
public Order(Item ... items) {
for (Item item : items) {
this.items.add(item);
}
}
public List<Item> getItems() {
return this.items;
}
}
Eine Bestellung besteht aus einer Liste von Artikeln. Diese können über den Konstruktor gesetzt werden und über die Methode getItems()
wieder ausgelesen.
List<Order> orders =
Arrays.asList(
new Order(
new Item("Java Insel", 3980),
new Item("GoTo Java", 2240),
new Item("Planet der Affen", 1690)
),
new Order(
new Item("Breaking Dawn", 2130),
new Item("Lord of the Rings", 1870)
)
);
Als Datensatz dient eine Liste von Bestellungen (orders
), die zwei Bestellungen mit drei bzw. zwei Artikeln enthält.
List<String> namen =
orders.stream()
.flatMap(e -> e.getItems().stream())
.filter(e -> e.getPrice() > 2000)
.map(Item::getName)
.collect(toList());
System.out.println(namen);
[Java Insel, GoTo Java, Breaking Dawn]
Um alle Artikel in allen Bestellungen zu finden, wird die verschachtelte Struktur der Bestellungen zuerst mit flatMap
in eine flache Datenstruktur verwandelt. Durch eine Filter-Operation werden mit filter
diejenigen Positionen extrahiert, deren Preis größer als 2000 Cent ist. Im nächsten Schritt werden durch map
die Namen der Bestellpositionen extrahiert und abschließend das Ergebnis über collect
in einer Liste gesammelt.
Würde man versuchen, dasselbe Ergebnis ohne Streams zu erreichen, würde man folgende, stark verschachtelte Schleife benötigen:
List<String> namen = new ArrayList<>();
for (Order order : orders) {
for (Item item : order.getItems()) {
if (item.getPrice() > 2000) {
namen.add(item.getName());
}
}
}
System.out.println(namen);
int summe =
orders.stream()
.flatMap(e -> e.getItems().stream())
.map(Item::getPrice)
.reduce((a, e) -> a + e)
.orElse(0);
System.out.println(summe);
11910
Hier wird wieder die Datenstruktur mit flatMap
in einen flachen Stream von Elementen verwandelt. Dann wird über map
der Stream der Bestellpositionen durch einen ersetzt, der nur noch die Preise enthält, um diese dann über eine reduce
-Operation zusammenzuzählen.
Da reduce
ein Optional und nicht direkt den Wert liefert, wird dieser mit orElse
ausgelesen. Sollte sich keine Summe bilden lassen, wird als Wert einfach 0
zurückgegeben.
Spezialisierte Streams
- Speicherung von primitiven Datentypen (
int
,long
, …) in normalen Collections ist ineffizient - ständiges boxing und unboxing
- höherer Speicherverbrauch
- Lösung
- Spezialisierte Streams für primitive Daten:
IntStream
,LongStream
,DoubleStream
- Spezialisierte Methoden zur Umwandlung zwischen den Welten:
Stream.mapToInt
,IntStream.mapToObj
, … - Spezialisierte funktionale Interfaces für die Lambdas:
IntPredicate
,IntUnaryOperator
, …
Wie bereits im Kapitel zu den Wrapper-Typen erläutert wurde, wird bei der Zuweisung eines primitiven Datentyps an eine Objektreferenz automatisch eine Konvertierung des primitiven Wertes in ein passendes Wrapper-Objekt durchgeführt (autoboxing). In die andere Richtung (Zuweisung eines Wrapper-Objektes an einen primitiven Datentyp) findet eine Umwandlung in die andere Richtung statt (autounboxing).
Integer i = 7; // boxing
int k = i; // unboxing
Diese Operationen sind rechenintensiv und daher zu vermeiden, wenn die Eingaben und Ausgaben einer Berechnung ohnehin primitiv sind.
Ein weiteres Problem ist der Speicherverbrauch von Wrapper-Objekten. Während ein int
nur 32 Bit belegt, benötigt ein Integer
-Objekt ein vielfaches (gespeicherter Wert, Referenz auf das Class-Objekt, Objekt-Header etc.).
Beispiel: IntStream
IntStream stream = IntStream.of(8, 9, 12, 22, 2, 3, 44, 11, 7);
int[] even =
stream
.filter(i -> i % 2 == 0)
.toArray();
System.out.println(Arrays.toString(even));
[8, 12, 22, 2, 44]
In diesem Beispiel wird direkt ein Stream von int
-Werten angelegt und mithilfe eines IntPredicate
i -> i % 2 == 0
gefiltert. Das Ergebnis wird in ein int
-Array konvertiert und ausgegeben. Durch die Verwendung eines IntStreams
kommt das Programm ohne jedes Boxing aus.
Beispiel: Umwandlung zu einem IntStream
int summe =
orders.stream()
.flatMap(e -> e.getItems().stream())
.mapToInt(Item::getPrice)
.reduce((a, e) -> a + e)
.orElse(0);
System.out.println(summe);
11910
Hier wurde noch einmal das vorhergehende Beispiel aufgegriffen und insofern optimiert, als dass bei der Map-Operation direkt über mapToInt
in einen IntStream
gemappt wird. Hierdurch ist beim reduce
kein Boxing mehr nötig und das Programm läuft schneller und mit deutlich weniger Speicherverbrauch.