Zum Inhalt
wannacode

Streams

Kurz gesagt: Ein Stream verarbeitet eine Collection als Folge von Schritten: filter siebt aus, map wandelt um, sorted sortiert, und eine terminale Operation wie toList liefert das Ergebnis.

Problem

Ein Bericht in einer Schleife

Die Bestellungen haben jetzt auch eine Stadt:

Order
class Order {
private String customer;
private String city;
private double total;
public Order(String customer, String city, double total) {
this.customer = customer;
this.city = city;
this.total = total;
}
public String getCustomer() {
return customer;
}
public String getCity() {
return city;
}
public double getTotal() {
return total;
}
}
List<Order> orders = List.of(
new Order("Maria", "Hamburg", 75.50),
new Order("Tom", "Dresden", 12.90),
new Order("Laura", "Hamburg", 40.00),
new Order("Lukas", "Berlin", 120.00),
new Order("Anna", "Dresden", 64.00));

Du willst die Kunden mit Bestellungen ab 50 €, alphabetisch sortiert:

List<String> result = new ArrayList<>();
for (Order o : orders) {
if (o.getTotal() >= 50) {
result.add(o.getCustomer());
}
}
Collections.sort(result);
System.out.println(result);
Ausgabe
[Anna, Lukas, Maria]

Acht Zeilen, und die Frage ist nur eine. Der Code sagt, wie: eine leere Liste anlegen, durchlaufen, prüfen, hinzufügen, sortieren. Um zu verstehen, was er tut, musst du ihn im Kopf ausführen. Kommt „nur die ersten drei“ oder „ohne Wiederholungen“ dazu, wächst die Schleife weiter.

Die Idee

Ein Stream: eine Folge von Schritten

Derselbe Bericht, als Schritte geschrieben:

List<String> result = orders.stream()
.filter(o -> o.getTotal() >= 50)
.map(Order::getCustomer)
.sorted()
.toList();
System.out.println(result);
Ausgabe
[Anna, Lukas, Maria]

Du liest ihn von oben nach unten: Nimm die Bestellungen, behalte die ab 50 €, nimm den Kunden, sortiere, sammle in einer Liste.

orders.stream() macht aus der Liste einen Stream (Datenstrom). Ein Stream speichert keine Daten. Er ist ein Förderband, auf dem die Elemente eins nach dem anderen laufen. Jeder Schritt nimmt ein Lambda aus der vorigen Lektion:

  • filter(Bedingung) lässt nur die Elemente weiter, für die die Bedingung wahr ist. Das ist ein Predicate.
  • map(Funktion) macht aus jedem Element ein anderes. Das ist eine Function: Sie macht aus einer Order einen String.
  • sorted() sortiert nach der natürlichen Reihenfolge, sorted(Comparator) nach deiner Regel.
  • toList() sammelt alles, was am Ende angekommen ist, in einer neuen Liste.

Die Liste orders ändert sich nicht. Der Stream liest sie nur.

Genauer, für Neugierige
  • filter, map, sorted sind Zwischenoperationen: Jede gibt einen neuen Stream zurück. toList, count, collect, forEach sind terminale Operationen: Sie verlangen ein Ergebnis und starten das Förderband.
  • toList() gibt es seit Java 16, und es gibt eine Liste zurück, die man nicht ändern kann. In älterem Code siehst du collect(Collectors.toList()). Das macht dasselbe, verspricht aber nicht, welche Art von Liste genau zurückkommt. Brauchst du eine ArrayList, die du ändern kannst, schreib collect(Collectors.toCollection(ArrayList::new)).
  • Einen Stream kannst du auch aus einem Array machen: Arrays.stream(prices), und aus einzelnen Werten: Stream.of("Kaffee", "Tee").

Sieh es dir an

Was durch den Filter kommt

List<Integer> prices = List.of(12, 75, 40, 130, 58);
List<String> labels = prices.stream()
.filter(p -> p >= 50)
.map(p -> p + " €")
.toList();
System.out.println(labels);
Schritt für Schritt, in Worten
  1. Fünf Preise. stream() legt nur den Weg an: Noch ist nichts hindurchgegangen.
  2. 12 besteht p >= 50 nicht und fällt heraus. Es kommt nie bei map an.
  3. 75 besteht filter, map macht daraus den Text „75 €“, toList() sammelt ihn.
  4. Auch 40 fällt heraus. Jedes Element geht den ganzen Weg, bevor das nächste startet.
  5. 130 und 58 kommen durch. Die Liste prices ändert sich nicht.
  6. Das Ergebnis ist eine neue Liste mit drei Strings.

Die fünf Preise laufen einer nach dem anderen über das Förderband. 12 kommt bei filter an und fällt heraus. 75 kommt durch, map macht daraus den Text „75 €“, und er landet in der Liste. Dann fällt 40 heraus, und 130 und 58 kommen durch.

Jedes Element geht den ganzen Weg, bevor das nächste startet. Und nichts startet, bis die terminale Operation toList() ein Ergebnis verlangt.

Genauer, für Neugierige

Das heißt Lazy Evaluation (bedarfsgesteuerte Auswertung). Die Zwischenoperationen merken sich nur, was zu tun ist. Sieh es dir mit einer Ausgabe in jedem Schritt an:

Stream<Integer> stream = List.of(12, 75, 40).stream()
.filter(p -> {
System.out.println("filter " + p);
return p >= 50;
})
.map(p -> {
System.out.println("map " + p);
return p * 2;
});
System.out.println("Der Stream ist bereit, noch ist nichts hindurchgegangen.");
System.out.println(stream.toList());
Ausgabe
Der Stream ist bereit, noch ist nichts hindurchgegangen.
filter 12
filter 75
map 75
filter 40
[150]
  • Die erste Zeile der Ausgabe erscheint, bevor auch nur ein Element durch den Stream gelaufen ist.
  • map 75 kommt direkt nach filter 75: Die Elemente warten nicht, bis alle durch filter gelaufen sind.
  • Deshalb hält limit(3) das Förderband an, sobald drei Elemente am Ende angekommen sind. Die übrigen werden gar nicht erst geprüft.
  • parallelStream() schickt die Elemente gleichzeitig durch mehrere Threads. Das hilft bei vielen Daten und aufwendigen Berechnungen. Bei kleinen Listen ist es langsamer, und die Lambdas dürfen keine gemeinsamen Daten anfassen. Um Threads geht es im Modul „Programme in der echten Welt“.

Die Idee

Summe, Anzahl, Durchschnitt

Ein Stream kann statt mit einer Liste auch mit einer Zahl enden. Die Summe der geraden Zahlen:

List<Integer> numbers = List.of(1, 2, 3, 4, 5);
int sum = numbers.stream().filter(n -> n % 2 == 0).mapToInt(n -> n).sum();
System.out.println("Summe der geraden Zahlen: " + sum);
Ausgabe
Summe der geraden Zahlen: 6
mapToInt(n -> n) macht aus einem Stream<Integer> einen Stream von int. Er heißt IntStream und hat sum(), average(), max() und min(). Für double gibt es mapToDouble und DoubleStream.

Dasselbe für die Bestellungen:

long big = orders.stream().filter(o -> o.getTotal() >= 50).count();
double revenue = orders.stream().mapToDouble(Order::getTotal).sum();
double average = orders.stream().mapToDouble(Order::getTotal).average().orElse(0);
double maxTotal = orders.stream().mapToDouble(Order::getTotal).max().orElse(0);
System.out.println("Bestellungen ab 50 €: " + big);
System.out.printf("Umsatz: %.2f €%n", revenue);
System.out.printf("Durchschnitt: %.2f €%n", average);
System.out.printf("Größte: %.2f €%n", maxTotal);
Ausgabe
Bestellungen ab 50 €: 3
Umsatz: 312.40 €
Durchschnitt: 62.48 €
Größte: 120.00 €
  • count() liefert die Anzahl der Elemente, die am Ende angekommen sind. Der Typ ist long.
  • average() und max() geben nicht direkt ein double zurück. Gibt es keine Bestellungen, gibt es auch keinen Durchschnitt. Deshalb geben sie „vielleicht eine Zahl“ zurück, und orElse(0) sagt, was gelten soll, wenn es keine gibt. Das ist die nächste Idee.

Die Idee

Sortieren, abschneiden, ohne Wiederholungen

List<Integer> ratings = List.of(5, 3, 5, 4, 2, 4, 5);
System.out.println(ratings.stream().distinct().sorted().toList());
List<String> top = orders.stream()
.sorted(Comparator.comparing(Order::getTotal).reversed())
.limit(3)
.map(Order::getCustomer)
.toList();
System.out.println(top);
IntStream.range(1, 4).forEach(i -> System.out.println("Tisch " + i));
Ausgabe
[2, 3, 4, 5]
[Lukas, Maria, Anna]
Tisch 1
Tisch 2
Tisch 3
  • distinct() entfernt die Wiederholungen. Es vergleicht mit equals.
  • sorted(Comparator.comparing(Order::getTotal).reversed()) sortiert ab der größten Bestellung, mit einem Comparator aus der vorigen Lektion.
  • limit(3) lässt nur die ersten drei durch. Zusammen mit sorted ist das eine „Top 3“.
  • IntStream.range(1, 4) liefert die Zahlen 1, 2, 3. Das Ende ist nicht dabei, wie bei for (int i = 1; i < 4; i++). Mit rangeClosed(1, 4) ist es dabei.

Probier es aus

Was kommt am Ende an?

Was gibt das Programm aus?

List<String> drinks = List.of("kaffee", "tee", "kaffee", "wasser", "tee", "saft");
List<String> result = drinks.stream()
.distinct()
.sorted()
.limit(3)
.map(String::toUpperCase)
.toList();
System.out.println(result);
Antwort

Die Idee

Optional: ein Ergebnis, das fehlen kann

Welche Bestellung ist die größte? Ist die Liste leer, gibt es keine Antwort. Die Methode könnte null zurückgeben, aber dann muss der Aufrufer daran denken, es zu prüfen. Vergisst er es, bricht das Programm irgendwann später mit einer NullPointerException ab.

Deshalb geben max, min und findFirst ein Optional zurück: eine Box, die entweder einen Wert enthält oder leer ist.

Optional<Order> biggest = orders.stream().max(Comparator.comparing(Order::getTotal));
biggest.ifPresent(o -> System.out.println("Größte: " + o.getCustomer()));
Optional<Order> huge = orders.stream().filter(o -> o.getTotal() > 500).findFirst();
System.out.println(huge.isPresent());
System.out.println(huge.map(Order::getCustomer).orElse("keine gefunden"));
Ausgabe
Größte: Lukas
false
keine gefunden
  • ifPresent(Aktion) führt die Aktion nur aus, wenn es einen Wert gibt.
  • isPresent() sagt dir, ob es einen gibt.
  • orElse(Ersatzwert) liefert den Wert oder den Ersatzwert.
  • map(Funktion) wandelt den Wert um, wenn es einen gibt. Eine leere Box bleibt leer.

Optional hilft auch bei deinen eigenen Methoden, die null zurückgeben. Optional.ofNullable packt in die Box, was es bekommt: einen Wert oder nichts.

static String nickname(String user) {
// Gibt den Spitznamen zurück oder null, wenn die Person keinen hat.
return user.equals("maria") ? "Mimi" : null;
}
Optional<String> maria = Optional.ofNullable(nickname("maria"));
maria.ifPresent(name -> System.out.println("Spitzname: " + name));
Optional<String> tom = Optional.ofNullable(nickname("tom"));
System.out.println(tom.orElse("kein Spitzname"));
Ausgabe
Spitzname: Mimi
kein Spitzname
Genauer, für Neugierige
  • get() liefert den Wert, aber wenn die Box leer ist, wirft es eine Exception:
Optional<Order> huge = orders.stream().filter(o -> o.getTotal() > 500).findFirst();
System.out.println(huge.get().getCustomer());
Fehler
Exception in thread "main" java.util.NoSuchElementException: No value present
at java.base/java.util.Optional.get(Optional.java:143)

orElseThrow() macht dasselbe, aber sein Name sagt ehrlich, was passieren wird. Nimm es, wenn eine leere Box ein Fehler ist.

  • orElse(x) berechnet x immer. orElseGet(() -> ...) ruft die Funktion nur auf, wenn es keinen Wert gibt. Den Unterschied merkst du, wenn der Ersatzwert aufwendig zu berechnen ist.
  • average() von IntStream und DoubleStream gibt ein OptionalDouble zurück, und max() von IntStream gibt ein OptionalInt zurück. Sie sind dasselbe für primitive Typen: getAsDouble(), orElse(0).
  • Optional ist für das Ergebnis einer Methode da. Benutze es nicht als Feld einer Klasse oder als Parameter.
  • anyMatch, allMatch und noneMatch antworten mit true oder false, ob irgendein, jedes oder kein Element die Bedingung erfüllt.

Probier es aus

Finde den Fehler

Das Programm soll ausgeben, wie viele Preise teuer sind, und die Preise selbst. Die erste Zeile erscheint, dann bricht das Programm ab. In welcher Zeile ist der Fehler?

Wähle die Zeile mit dem Fehler.

Die Idee

Gruppieren und verbinden

Wie viele Bestellungen gibt es aus jeder Stadt, und wie hoch ist der Umsatz?

Map<String, Long> countByCity = orders.stream()
.collect(Collectors.groupingBy(Order::getCity, TreeMap::new, Collectors.counting()));
System.out.println(countByCity);
Map<String, Double> revenueByCity = orders.stream()
.collect(Collectors.groupingBy(Order::getCity, TreeMap::new, Collectors.summingDouble(Order::getTotal)));
revenueByCity.forEach((city, sum) -> System.out.printf("%s: %.2f €%n", city, sum));
String customers = orders.stream()
.map(Order::getCustomer)
.sorted()
.collect(Collectors.joining(", "));
System.out.println(customers);
Ausgabe
{Berlin=1, Dresden=2, Hamburg=2}
Berlin: 120.00 €
Dresden: 76.90 €
Hamburg: 115.50 €
Anna, Laura, Lukas, Maria, Tom

collect sammelt den Stream nach einer Regel aus der Klasse Collectors:

  • groupingBy(Order::getCity, TreeMap::new, Collectors.counting()): Gruppiere nach Stadt, lege die Gruppen in eine TreeMap, zähle jede Gruppe.
  • summingDouble(Order::getTotal) statt counting() addiert, statt zu zählen.
  • joining(", ") verbindet die Strings zu einem, mit einem Komma dazwischen.

TreeMap::new ist eine Konstruktorreferenz. Sie sagt, welche Art von Map groupingBy machen soll. Ohne sie ist das Ergebnis eine HashMap, und die Reihenfolge der Städte darin ist nicht garantiert.

Genauer, für Neugierige
  • Nur mit der Funktion, groupingBy(Order::getCity), ist das Ergebnis eine Map<String, List<Order>>: für jede Stadt eine Liste ihrer Bestellungen.
  • Willst du statt der Bestellungen eine Liste von Namen, wandelt mapping jedes Element um, bevor es in die Gruppe kommt:
Map<String, List<String>> namesByCity = orders.stream()
.collect(Collectors.groupingBy(Order::getCity, TreeMap::new,
Collectors.mapping(Order::getCustomer, Collectors.toList())));
System.out.println(namesByCity);
Ausgabe
{Berlin=[Lukas], Dresden=[Tom, Anna], Hamburg=[Maria, Laura]}
  • Collectors.partitioningBy(Bedingung) teilt in zwei Gruppen: true und false. Collectors.toMap(Schlüssel, Wert) macht eine Map mit einem Wert pro Schlüssel und wirft eine Exception, wenn sich ein Schlüssel wiederholt.

Übung

Berichte mit Streams

Löse die Aufgaben in deinem Editor. Importiere java.util.stream.Collectors und für Arrays java.util.Arrays.

1. Verkäufe nach Kategorie. Eine Klasse Sale mit Produkt, Kategorie, Menge und Stückpreis und einer Methode getRevenue() (Menge mal Preis). Addiere den Umsatz pro Kategorie in einer TreeMap und gib den Gesamtumsatz aus. Die Verkäufe: Kaffeebohnen (Kategorie „Kaffee und Tee“), 12 Stück zu 18,50 €; Tee („Kaffee und Tee“), 30 zu 4,20 €; Schokolade („Snacks“), 40 zu 2,80 €; Kekse („Snacks“), 25 zu 3,10 €; Honig („Feinkost“), 8 zu 12 €; noch einmal Kaffeebohnen, 5 zu 18,50 €.

Ausgabe
Feinkost: 96.00 €
Kaffee und Tee: 440.50 €
Snacks: 189.50 €
Gesamt: 726.00 €

2. Die Top 3 der Produkte. Finde mit denselben Verkäufen die drei Produkte mit dem höchsten Umsatz. Der Kaffee steht in zwei Verkäufen, deshalb gruppierst du zuerst nach Produkt und addierst, dann sortierst du die Einträge der Map nach dem Wert. Tipp: map.entrySet().stream() und Map.Entry.<String, Double>comparingByValue().reversed().

Ausgabe
Kaffeebohnen: 314.50 €
Tee: 126.00 €
Schokolade: 112.00 €

3. Die Durchschnittsnote. Lies eine Zeile mit Noten, getrennt durch Leerzeichen. Gib den Durchschnitt aus, die höchste Note und wie viele ausgezeichnet sind (5,50 und mehr). Tipp: Arrays.stream(line.split(" ")).mapToDouble(Double::parseDouble).

Eingabe
4.50 5.75 6.00 3.00 5.50
Ausgabe
Durchschnitt: 4.95
Höchste: 6.00
Ausgezeichnet: 3

4. Die Wörter in einem Text. Lies eine Zeile Text und gib die Wörter darin ohne Wiederholungen aus, alphabetisch, kleingeschrieben, durch Kommas getrennt. Teile an allem, was kein Buchstabe ist: split("[^\\p{L}]+").

Eingabe
Kaffee mit Milch, Kaffee ohne Zucker. Tee mit Honig!
Ausgabe
honig, kaffee, milch, mit, ohne, tee, zucker

Weitere Aufgaben

  • Das längste Wort. Finde mit max(Comparator.comparing(String::length)) das längste Wort in einem Satz. Was gibt das Programm bei einer leeren Zeile zurück?
  • Durchschnitt pro Klasse. Schüler mit Name, Klasse und Note: die Durchschnittsnote jeder Klasse mit groupingBy und Collectors.averagingDouble.
  • Große und kleine Bestellungen. Prüfe mit anyMatch, ob es eine Bestellung über 1000 € gibt, und mit allMatch, ob alle mindestens 10 € betragen.

Test

Teste dich

1. Was passiert mit der Liste orders nach orders.stream().filter(o -> o.getTotal() > 50).toList()?
2. Welche davon ist eine terminale Operation, die den Stream startet?
3. orders.stream().max(...) gibt ein Optional<Order> zurück. Warum nicht einfach ein Order?
4. Warum steht die Gruppierung in einer TreeMap und nicht einfach groupingBy(Order::getCity)?
5. Stream<Integer> s = list.stream(); s.count(); s.toList(); Was passiert?

Erstellt von Ivo Gergov im Rahmen der Kampagne von Evo Tech für zugängliche Programmierausbildung ·