Streams vs. Loops in Java
Java's Stream API ist eine mächtige Alternative zu Schleifen. Modernere, reaktive Programmierung setzt voll auf Streams. Hier stelle ich Streams und Loops gegenüber, indem ich die gleiche Aufgabe mittels Stream und Schleife löse.
Java Streams API
Ein Stream ist ein Datenstrom. Mit der Java Stream API können Objekte in Datenströmen analysiert, bearbeitet, gefiltert und umgewandelt werden. Ich stelle mir Stream gerne als Fließband vor. Die Objekte werden von einer Quelle (z.B. List) auf das Fließband gepackt und durchlaufen verschiedene Stationen, an denen sie bearbeitet werden. Zum Abschluss werden sie in ein Ergebnis gepackt (z.B. neue List), welches dann im weiteren Programmcode benutzt werden kann.
Fließband: Stream der analogen Welt |
Intermediate Operations
Zum Bearbeiten der Objekte im Stream gibt es sogenannte "intermediate Operations", die im Stream Interface definiert sind:
- filter - entfernt Objekte aus dem Stream
- map - wandelt Objekte um
- peek - erlaubt Bearbeitung des Objekts und lässt es im Stream
- distinct - entfernt gleiche Objekte aus dem Stream
- flatMap - löst Collections innerhalb des Streams auf
- sorted - sortiert die Objekte im Stream
- skip - überspring Objekte
- limit - begrenzt die Anzahl der bearbeitenden Objekte im Stream
Die intermediate Operationen können in beliebiger Menge und Reihenfolge auf die Objekte im Stream angewendet werden - dazu zeige ich weiter unten Beispiele.
Terminal Operations
Terminal Operations beenden die Verarbeitung der Daten im Stream und produzieren ein Ergebnis:
- forEach - bearbeitet jedes Objekt ein letztes Mal
- collect - sammelt die Objekte am Ende des Streams z.B. in einer Liste
- toArray - sammelt die Objekte am Ende des Streams in einem Array
- min / max - findet das Minimum / Maximum Objekt im Stream.
- count - zählt die Elemente am Ende des Streams.
- reduce - reduziert alle Elemente am Ende des Streams zu einem einzelnen Ergebnis
- anyMatch / allMatch / noneMatch - liefert ein Boolean Ergebnis bezüglich der Daten im Stream
- findFirst / findAny - liefert als Ergebnis des Streams ein Optional mit dem gefunden Element
Streams vs. Loops Code Beispiele
Im folgenden zeige ich einige doppelt implementierte Methoden. Eine Version verwendet Schleifen und die andere Streams mit Lambda-Ausdrücken - weitere Infos zu Lambdas findet ihr hier: lambda.html
Beispiel 1: map & collect
Zuerst wird die Liste itemNames mit der Methode stream() in einen Stream<String> umgewandelt - in allen folgenden Beispielen ist dies immer der erste Schritt. Danach wandelt die Methode map jeden String im Stream mit der funktionalen Methode generateItem in ein Objekt vom Typ Item um. Die abschließende Terminal Operation collect wandelt den Stream in eine neue Item-Liste um und speichert diese im Attribut items, welches in den folgenden Beispielen verwenden wird.
final List<String> itemNames = List.of("Ardbeg", "Lagavulin",
"Jim Beam", "Teeling", "Jameson", "Johnny Walker");
List<Item> items;
void prepareDemoData() {
Function<String, Item> generateItem =
name -> new Item(name, name.length());
items = itemNames.stream()
.map(generateItem).collect(Collectors.toList());
}
Im Beispiel mit Schleife muss das Attribut items zu Beginn instanziiert werden.
void prepareDemoData() {
items = new ArrayList<>();
for (var name : itemNames)
items.add(new Item(name, name.length()));
}
Beispiel 2: sorted & forEach
Zum Sortieren der zuvor generierten items-Liste muss die Item Klasse in dieser Schleifen-Variante das Interface Comparable implementieren.
List<Item> sortAndPrintItems() {
Collections.sort(items);
for (var item : items)
System.out.println(item);
return items;
}
Die Stream-Variante verwendet sorted zum Sortieren der Elemente im Stream. Die Klasse Item müsste hier nicht das Interface Comparable implementieren, weil wir den Lambda-Ausdruck compareByName an sorted übergeben. Comparator ist ein funktionales Interface zum Festlegen der Sortierreihenfolge. Die Terminal Operation forEach funktioniert analog zur for-each-Schleife und wird auf jedes Element im Stream angewendet. In beiden Beispielen werden die Item Objekte in der gleichen Reihenfolge in die Konsole geschrieben. Die ursprüngliche Liste items bleibt allerdings in der Stream-Variante unverändert, da die Sortierung hier nur im Stream passiert und nicht in der Quelle.
List<Item> sortAndPrintItems() {
Comparator<Item> compareByName = (a, b) -> a.name().compareTo(b.name());
items.stream().sorted(compareByName).forEach(System.out::println);
return items;
}
Beispiel 3: filter & count
Bestimmt habt ihr schon Elemente in einer Liste gezählt, welche spezielle Bedingungen erfüllen. Die klassische Schleifen-Variante sieht dazu so aus:
long countItemsById(int id) {
long counter = 0;
for (var item : items)
if (item.id() == id)
counter++;
return counter;
}
Mit der Stream API schreiben wir solche Operationen deutlich kompakter. Die filter Methode ersetzt das if-Statement und die Terminal Operation count übernimmt das Zählen.
long countItemsById(int id) {
return items.stream().filter(it -> it.id() == id).count();
}
Beispiel 4: filter, peek & findFirst
Im nächsten Beispiel sind 2 Operationen in einer Methode vermischt:
- Wir suchen das Item in der Liste, dessen name Attribut den übergebenen Substring enthält.
- Außerdem loggen wir die Position des gefunden Item in der Liste. Daher verwende ich hier auch die klassische for-Schleife.
- Um NullPointerExceptions zu vermeiden, geben wir einen Optional zurück, siehe dazu auch https://dzone.com/articles/optional-in-java.
Optional<Item> findBySubstringAndCountPosition(String substring) {
for (int i = 0; i < items.size(); i++) {
var item = items.get(i);
if (item.name().contains(substring)) {
System.out.println("Loop: Item found at position: " + i);
return Optional.of(item);
}
}
return Optional.empty();
}
Zum Ausführen mehrerer Operationen auf einem Stream-Element verwenden wir die peek Methode. Das if-Statement wird erneut mittels filter Methode umgesetzt.
Optional<Item> findBySubstringAndCountPosition1(String substring) {
final var counter = new ThreadLocal<Integer>();
counter.set(0);
return items.stream()
.peek(i -> counter.set(counter.get() + 1))
.filter(it -> it.name().contains(substring))
.peek(i -> System.out.println(
"Stream: Item found at position: " + counter.get()))
.findFirst();
}
Da peek einen Lambda-Ausdruck als Eingabe benötigt und Lambda-Ausdrücke nur auf (effektiv) finalen Variablen arbeiten können, packe ich den Integer zum Zählen der Position in ein ThreadLocal Objekt.
In diesem Fall gefällt mir die Schleifen-Variante besser. Generell ist es im Stream nicht möglich auf Elemente an bestimmten Positionen zuzugreifen. Bei Schleifen mit Index i ist der Zugriff auf bestimmte Elemente aus der Liste (oder einem Array) kein Problem.
Die Terminal Operation findFirst vereinfacht die Rückgabe, da sie entweder einen leeren oder einen Optional mit dem gefunden Element zurückgibt. Darum müssen wir uns in der Stream-Variante nicht kümmern.
Beispiel 5: noneMatch, allMatch & anyMatch
In den letzten Beispielen stelle ich nur die Stream-Variante vor, da es Code-Ausschnitte meines Unit-Test sind.
assertTrue(items.stream().noneMatch(it -> it.id() < 0));
assertFalse(items.stream().allMatch(it -> it.id() < 0));
assertTrue(items.stream().anyMatch(it -> it.id() > 0));
- Die erste Assertion prüft mit dem noneMatch, dass kein einziges Element im Stream eine Id kleiner 0 hat.
- Der allMatch in der zweiten Assertion ist das Gegenteil, daher verwende ich hier assertFalse statt assertTrue.
- Mit anyMatch prüfe ich, dass mindestens ein Element eine Id größer 0 hat - in unserer items Liste wäre hier auch allMatch möglich, da alle Ids größer 0 sind, siehe Beispiel 1.
Beispiel 6: collect mit groupingBy
Im letzten Beispiel verwende ich die groupingBy Methode der Klasse Collectors innerhalb der Terminal Operation collect. groupingBy entsprich von der Funktionalität dem GROUP BY Statement aus SQL, siehe https://www.w3schools.com/sql/sql_groupby.asp.
items.stream().collect(Collectors.groupingBy(Item::id)).forEach(
(group, elements) -> System.out.println(group + " : " + elements));
Die Item Objekte innerhalb des Stream sortiert die collect Methode mit Hilfe des Collectors in eine Map<Integer, List<Item>> ein. Die Konsolen Ausgabe sieht dann so aus:
6 : [Item[name=Ardbeg, id=6]]
7 : [Item[name=Teeling, id=7], Item[name=Jameson, id=7]]
8 : [Item[name=Jim Beam, id=8]]
9 : [Item[name=Lagavulin, id=9]]
13 : [Item[name=Johnny Walker, id=13]]
Da 2 Item-Objekte die gleiche Id 7 haben, befinden sie sich in der selben Gruppe. Alle anderen Gruppen haben nur ein Item.
Parallele Bearbeitung von Streams
Die parallele Verarbeitung der Daten im Stream kann mit einer einzigen Methode der Stream API gestartet werden:
items.stream().parallel().forEach(System.out::println);
Die Methode parallel wandelt den Stream in einen parallelen Stream um, der von mehreren Threads bearbeitet wird. Das Schöne ist: Wir müssen uns nicht um die Threads kümmern - das macht Java für uns! Alternativ können wir direkt einen parallelen Stream erzeugen:
items.parallelStream().forEach(System.out::println);
Fazit
- Schleifen basieren auf dem imperativen Programmierstil, bei dem jeder Schritt festgelegt wird.
- Streams basieren auf der funktionalen Programmierung. Daher bietet die Stream API Methoden an, deren Implementierung wir nicht kennen müssen. Wir beschreiben mit Lambda-Ausdrücken nur das Ergebnis.
Kommentare
https://youtu.be/6F3Tp1CVWz0