Java Stream Intermediate Operations
Java-Streams lazy transformieren mit filter, map, flatMap, sorted, distinct, peek, limit und skip.
Eine Zwischenoperation nimmt einen Stream entgegen und gibt einen neuen Stream zurück. Sie hält fest, was mit jedem Element geschehen soll, wenn die Pipeline schließlich ausgeführt wird – sie selbst führt jedoch nichts aus. Man kettet sie aneinander; die Kette bleibt kalt, bis ein Terminal das erste Element durchzieht. Diese Laziness macht eine 30-zeilige Pipeline günstiger als ihre Einzelteile, macht unendliche Quellen handhabbar und lässt die Wahl der Operation mehr eine Frage der Klarheit als der Arbeitsvermeidung sein – benachbarte Zwischenoperationen verschmelzen zu einem einzigen Durchlauf.
Dieses Kapitel ist eine Rundreise durch alle Zwischenoperationen, die man schreiben wird. Jeder Eintrag hat dieselbe Form: was die Operation tut, welchen Typ ihr Callback hat, ob sie zustandslos oder zustandsbehaftet ist, und die ein oder zwei Fallstricke, die über die Korrektheit der Pipeline entscheiden.
filter — nur Passendes behalten
Verwirft Elemente, die ein Predicate<T> nicht erfüllen:
List<Integer> evens = nums.stream()
.filter(n -> n % 2 == 0)
.toList();Zustandslos, lazy, ordnungserhaltend. Das Predicate muss frei von Seiteneffekten sein – wenn es irgendetwas Sichtbares mutiert, werden parallele Pipelines überraschende Ergebnisse liefern, und selbst sequentielle werden schwer lesbar.
filter ändert den Elementtyp nicht. Um gleichzeitig eine Teilmenge zu behalten und den Typ zu ändern, verwendet man filter gefolgt von map oder mapMulti (Java 16+) für den seltenen Fall, dass eine Eingabe zu null oder einer andersartigen Ausgabe wird.
map — jedes Element transformieren
Wendet eine Function<T, R> auf jedes Element an und liefert einen Stream von R:
List<Integer> lengths = words.stream()
.map(String::length)
.toList();Zustandslos, lazy, ordnungserhaltend, eins-zu-eins. Bei numerischen Ergebnissen sollten die primitiven Spezialisierungen verwendet werden:
mapToInt,mapToLong,mapToDouble→ primitiver Stream (kein Boxing,sum()verfügbar).mapToObjauf einem primitiven Stream → zurück zuStream<R>.
int totalLength = words.stream().mapToInt(String::length).sum();flatMap — jedes Element durch einen Stream anderer ersetzen
Eine Function<T, Stream<R>>, die jedes Element in mehrere Ausgaben „entpackt" (oder keine, oder eine):
List<List<String>> grouped = List.of(List.of("a", "b"), List.of("c"));
List<String> flat = grouped.stream()
.flatMap(List::stream)
.toList(); // [a, b, c]Das mentale Modell: „Jedes Element wird zu einem Teilstream, und flatMap verkettet diese." So gelangt man von einem Stream von Containern (Stream<List<T>>) zu einem Stream von Inhalten (Stream<T>), so expandiert man jeden Text in seine Wörter, und so wandelt man einen Stream von Optional<T> in einen Stream vorhandener Werte um (via Optional::stream).
Es gibt auch primitive Spezialisierungen – flatMapToInt, flatMapToLong, flatMapToDouble – für Fan-out in einen primitiven Stream.
Eine häufige Verwechslung: map(s -> s.split(" ")) liefert Stream<String[]> – einen Stream von Arrays, keinen flachen Stream von Wörtern. Zum Glätten: flatMap(s -> Arrays.stream(s.split(" "))).
mapMulti — null, ein oder mehrere Elemente pro Eingabe ausgeben
mapMulti (Java 16+) ist ein effizienteres flatMap für Fälle, in denen jede Eingabe eine kleine, variable Anzahl von Ausgaben erzeugt und das Erstellen eines Stream pro Element übertrieben wäre:
people.stream()
.<String>mapMulti((p, downstream) -> {
if (p.age() >= 18) downstream.accept(p.name());
if (p.email() != null) downstream.accept(p.email());
})
.forEach(System.out::println);flatMap verwendet man, wenn natürlicherweise ein Stream/Liste ausgegeben werden soll; mapMulti wenn man anderenfalls einen winzigen Ein-oder-Zwei-Element-Stream pro Eingabe bauen würde, nur um die Signatur von flatMap zu erfüllen.
distinct — Duplikate entfernen
Entfernt gleiche Elemente anhand von equals / hashCode:
List<String> unique = words.stream().distinct().toList();Zustandsbehaftet – um zu wissen, ob ein Element ein Duplikat ist, muss distinct sich die bereits ausgegebenen merken. Auf einem geordneten Stream behält es das erste Vorkommen. Auf einem ungeordneten Stream kann die JVM bei paralleler Verarbeitung klüger vorgehen. Auf einem unendlichen Stream will man distinct fast nie ohne ein vorheriges limit verwenden.
sorted — Elemente sortieren
Zwei Formen – natürliche Reihenfolge und ein Comparator<T>:
List<String> az = words.stream().sorted().toList();
List<String> byLen = words.stream().sorted(Comparator.comparingInt(String::length)).toList();Zustandsbehaftet und terminal-blockierend: sorted muss jedes Element puffern, bevor es eines ausgeben kann. Das macht es zur teuersten Zwischenoperation, die bewusst eingesetzt werden sollte. Es vor einem limit(n) zu platzieren spart keine Arbeit – die JVM muss trotzdem jede Eingabe sehen, um zu wissen, welche n behalten werden. (Für eine „Top-N"-Pipeline empfiehlt sich eine begrenzte PriorityQueue oder Collectors.toList() gefolgt von subList nach einem sorted, je nach N und Gesamtgröße.)
Außerdem: sorted niemals auf einem Stream aus einer unendlichen Quelle aufrufen – es kehrt nie zurück.
peek — beobachten ohne zu ändern
Ein Consumer<T>, der für jedes durchgezogene Element feuert. Gibt den Stream unverändert zurück:
words.stream()
.peek(s -> System.out.println("seen: " + s))
.filter(s -> s.length() > 3)
.toList();Nur zum Debuggen. peek läuft lazy und genau einmal pro gezogenem Element, daher ist es ein nützliches Fenster auf Laziness und Short-Circuiting:
Stream.iterate(1, n -> n + 1)
.peek(n -> System.out.println("considered " + n))
.filter(n -> n > 100)
.findFirst(); // pulls 1..101 -- peek fires 101 times, then stopsKeine echte Logik in ein peek packen. Die JVM darf peek-Aufrufe unter bestimmten Bedingungen bei unmodifizierten Streams fusionieren, neu ordnen oder überspringen, und bei parallelen Streams ist die Reihenfolge undefiniert.
limit(n) — höchstens n Elemente behalten
Stoppt die Pipeline, nachdem n Elemente durchgekommen sind:
List<Integer> firstFive = Stream.iterate(1, i -> i + 1).limit(5).toList();Zustandsbehaftet (zählt) und short-circuiting (der nachgelagerte Bereich stoppt, sobald n erreicht ist). Auf einem geordneten Stream werden die ersten n behalten. Auf einem ungeordneten parallelen Stream werden irgendwelche n behalten – die Reihenfolge ist nicht garantiert, und ein paralleles limit auf einem geordneten Stream kostet die Ordnung. Wenn es egal ist, welche n man bekommt, ist stream.unordered().limit(n) bei paralleler Ausführung schneller.
Das Standardmuster zum Zähmen jeder unendlichen Quelle: Jedes Stream.iterate / Stream.generate endet entweder in einem limit, einem dreiargumentigen iterate-Bound oder einem short-circuiting-Terminal wie findFirst.
skip(n) — die ersten n verwerfen
Das Gegenstück zu limit. Verwirft die ersten n Elemente und gibt den Rest aus:
List<Integer> rest = nums.stream().skip(2).toList(); // drops nums[0], nums[1]Zustandsbehaftet (zählt herunter). Auf einem geordneten Stream ist die Bedeutung exakt; auf einem parallelen geordneten Stream entsteht ein Ordnungskosten. Zusammen mit limit ergibt sich ein „seitiger" Zugriff:
list.stream().skip(page * pageSize).limit(pageSize).toList();Das funktioniert, aber bei großem skip über einer List ist es immer noch O(skip + limit). Ein direktes list.subList(...) ist günstiger, wenn man die List zur Hand hat.
takeWhile / dropWhile — präfixbasiertes Windowing
Zwei short-circuiting-Zwischenoperationen (Java 9+), die auf einem Präfix des Streams wirken:
// take elements while predicate holds, stop at the first miss
List<Integer> small = Stream.of(1, 2, 3, 10, 4, 5)
.takeWhile(n -> n < 5)
.toList(); // [1, 2, 3]
// drop elements while predicate holds, then emit the rest
List<Integer> rest = Stream.of(1, 2, 3, 10, 4, 5)
.dropWhile(n -> n < 5)
.toList(); // [10, 4, 5]Das sind keine filter-Operationen. filter prüft jedes Element. takeWhile stoppt beim ersten Fehlschlag (einschließlich solcher, die filter später durchlassen würde). Auf einem sortierten Stream drücken sie günstig „alles bis zur Schwelle" aus.
boxed / asLongStream / asDoubleStream — zwischen primitiven Welten wechseln
Primitive Streams haben einige eigene Zwischenoperationen, um in die Objektwelt zurückzukehren:
IntStream.range(0, 5).boxed().toList(); // Stream<Integer> [0, 1, 2, 3, 4]
IntStream.range(0, 3).asLongStream().sum(); // 0L + 1L + 2L
IntStream.range(0, 3).asDoubleStream().average();boxed ist die Brücke von primitiv zu Stream<Integer/Long/Double>. Der umgekehrte Weg ist mapToInt/mapToLong/mapToDouble.
Zustandslos vs. zustandsbehaftet — warum es wichtig ist
| Zustandslos | Zustandsbehaftet |
|---|---|
filter | distinct |
map / mapToX | sorted |
flatMap / mapMulti | limit |
peek | skip |
boxed / asLongStream / asDoubleStream | takeWhile / dropWhile |
Zustandsbehaftete Zwischenoperationen müssen sich elementübergreifend etwas merken. sorted muss alles puffern. distinct muss sich jedes ausgegebene Element merken. limit und skip brauchen einen Zähler. Das macht sie teurer (besonders in parallelen Kontexten) und es lohnt sich, sie bewusst einzusetzen.
Reihenfolge zählt — fusionieren, früh filtern, spät transformieren
Da benachbarte Zwischenoperationen zu einem elementweisen Durchlauf fusionieren, bestimmt die Reihenfolge, in der sie geschrieben werden, wie viel Arbeit die Pipeline verrichtet:
// Good: filter first, then the expensive map runs only on survivors.
people.stream()
.filter(p -> p.age() >= 18)
.map(this::expensiveLookup)
.toList();
// Bad: every element pays for the map, then most are thrown away.
people.stream()
.map(this::expensiveLookup)
.filter(r -> r.score() > 0.5)
.toList();Die Faustregel: früh filtern, spät transformieren, einmal sortieren, einmal distinct. Die JVM ordnet die Zwischenoperationen nicht um – das macht man selbst.
Ein ausgearbeitetes Beispiel: der gesamte Wortschatz in einer Pipeline
Das folgende Programm erstellt einen Stream aus einer kleinen Liste, durchläuft jede behandelte Operation, druckt das Ergebnis jeder einzelnen aus und beweist Laziness/Short-Circuiting mit peek und einem unendlichen iterate.
Was man dem Durchlauf entnehmen kann:
filterundmapsind die Arbeitstiere; die anderen Eins-zu-Eins-Zwischenoperationen (mapToInt,mapToObj,boxed) sind die günstigen Währungsumrechnungen zwischen Objekt- und primitiven Streams.flatMapundmapMultisind der Weg, aus einer Eingabe mehrere Ausgaben zu machen. Das MusterStream.of("a b") -> Arrays.stream(split(...))ist das kanonische „Tokenisierungs"-Muster;mapMultiist die günstigere Wahl, wenn man anderenfalls einen winzigen Stream pro Element erzeugen würde.distinctundsortedsind zustandsbehaftet –distinctmusste sich jede zuvor ausgegebenePersonmerken, um das doppelte „Alice" zu verwerfen, undsortedmusste die gesamte Eingabe puffern. Deshalb werden beide bewusst gesetzt, in der Regel einmal und oft spät.peekfeuerte einmal pro gezogenem Element auf dem unendlicheniterate– es gab genau so viele „considered N"-Zeilen, wiefindFirstElemente betrachten musste. Ohne Short-Circuiting würde diese Pipeline nie enden.- Die beiden
lookup-Zählblöcke am Ende machten die Reihenfolgeregel greifbar. Zuerst zu filtern ließ die teure Transformation auf wesentlich weniger Elementen laufen als zuerst zu mappen. Diesen Kompromiss setzt man selbst.
Was kommt als Nächstes
Zwischenoperationen legen die Form der Arbeit fest; nichts läuft, bis ein Terminal zieht. Das nächste Kapitel, Java Stream Terminal Operations, ist das vollständige Vokabular der Terminals – forEach, count, min/max, findFirst/findAny, anyMatch/allMatch/noneMatch, reduce, toArray, toList und das Tor zum darauf folgenden Kapitel – collect.