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() oder collect())
  • 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:

Nur intermediate operations
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.

Abschluss mit einer terminal operation
 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:

Gut: Mit method chaining
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:

Schlecht: Ohne method chaining / 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. in List)
  • 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>>.

Collect-Operation auf einem Stream

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 werden
    • false: 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
Filter-Operation auf einem Stream

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.

Map-Operation auf einem Stream
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);
Ausgabe
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 Akkumulation
    • BinaryOperator<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.

Reduce-Operation auf einem Stream
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.

FlatMap-Operation auf einem Stream

Optional

  • Lästiges Problem in Java: Null-Werte
  • Stream API gibt nie null zurück, sondern falls null möglich, ein Optional (Typ: Optional<T>)
  • Wichtige Methoden
    • get() – gibt den Wert zurück oder wirft Exception, wenn kein Wert vorhanden
    • isPresent() – zeigt an, ob Wert vorhanden (true)
    • orElse(T other) – wenn leer, dann wird other zurückgegeben andernfalls der Wert im Optional
    • ofNullable(T value) – erzeugt ein Optional aus einem Wert, der null 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() und min() 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.

Finde die Namen aller Artikel mit einem Preis > 20 EUR
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:

Lösung ohne Streams
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);
Berechne die Summe der Preise aller Artikel in allen Bestellungen
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.


Copyright © 2025 Thomas Smits