1. Methode forEach: finale Aktion an den Elementen
Bevor wir in die Details eintauchen, erinnern wir uns kurz daran, was terminale und intermediäre Stream-Operationen sind.
- Intermediäre Operationen (z. B. filter, map, distinct, peek) – liefern einen neuen Stream zurück und führen in der Regel keine Aktionen aus, bevor eine terminale Operation aufgerufen wird.
- Terminale Operationen (z. B. forEach, collect, count, anyMatch) – starten die Verarbeitung der Stream-Elemente und liefern ein Ergebnis zurück (oder liefern nichts zurück wie forEach).
Nach einer terminalen Operation gilt der Stream als geschlossen, und es können keine weiteren Operationen darauf angewendet werden. Das ist so, als wollte man bereits aufgegessenes Eis weiteressen – das geht nicht: Der Stream ist bereits „verbraucht“.
Schauen wir uns nun zwei wichtige Methoden für Nebeneffekte an: forEach und peek.
Was macht forEach?
forEach ist eine terminale Stream-Operation, die für jedes Element des Streams eine vorgegebene Aktion ausführt. Typische Einsätze sind Ausgabe auf den Bildschirm, Logging, Erfassen von Statistiken und andere Nebeneffekte (side effects).
Methodensignatur:
void forEach(Consumer<? super T> action)
Consumer<T> ist ein funktionales Interface, das ein Argument annimmt und nichts zurückgibt (z. B. System.out::println).
Beispiel: Alle Listenelemente ausgeben
Angenommen, wir haben eine Liste von Benutzernamen:
List<String> users = List.of("Anna", "Boris", "Alex", "Alina", "Dmitry");
Mit dem Stream API lassen sich alle Benutzer ganz einfach ausgeben:
users.stream().forEach(System.out::println);
Ergebnis:
Anna
Boris
Alex
Alina
Dmitry
Man kann auch einen Lambda-Ausdruck verwenden:
users.stream().forEach(name -> System.out.println("Benutzer: " + name));
Ergebnis:
Benutzer: Anna
Benutzer: Boris
Benutzer: Alex
Benutzer: Alina
Benutzer: Dmitry
Wichtig: forEach schließt den Stream ab
Nach dem Aufruf von forEach wird der Stream „geschlossen“. Die Kette kann nicht fortgesetzt werden:
users.stream()
.filter(name -> name.startsWith("A"))
.forEach(System.out::println)
.map(String::toUpperCase); // Fehler! Der Stream ist bereits geschlossen.
Ein weiterer Aufruf nach forEach führt zu einem Kompilierfehler: Die terminale Operation gibt void zurück, nicht einen neuen Stream.
2. Methode peek: beobachten, nicht eingreifen
peek ist eine intermediäre Operation. Sie erlaubt, zu einem bestimmten Verarbeitungsschritt für jedes Element eine Aktion auszuführen, ohne das Element zu verändern und ohne den Stream zu beenden.
Methodensignatur:
Stream<T> peek(Consumer<? super T> action)
- peek gibt einen neuen Stream zurück, in dem für jedes Element die Aktion action ausgeführt wird.
- Wird typischerweise für Debugging, Logging oder Monitoring des Stream-Zustands verwendet.
Beispiel: Logging nach dem Filtern
List<String> users = List.of("Anna", "Boris", "Alex", "Alina", "Dmitry");
List<Integer> nameLengths = users.stream()
.filter(name -> name.startsWith("A"))
.peek(name -> System.out.println("Filter bestanden: " + name))
.map(String::length)
.collect(Collectors.toList());
Ausgabe in der Konsole:
Filter bestanden: Anna
Filter bestanden: Alex
Filter bestanden: Alina
Inhalt von nameLengths:
[4, 4, 5]
Wo ist peek sinnvoll?
- Zum Debuggen der Operationskette: sehen, was in jedem Schritt passiert.
- Zum Erfassen von Statistiken (z. B. zum Zählen der Elemente).
- Für Logging in Zwischenstufen.
Wichtig: peek sollte nicht verwendet werden, um Elemente des Streams zu verändern. Für Transformationen gibt es map. peek bedeutet „hinsehen“, nicht „eingreifen“.
3. forEach vs. peek: worin liegt der Unterschied?
| Methode | Operationstyp | Wann anwenden | Kann die Kette fortgesetzt werden? | Wofür am besten geeignet |
|---|---|---|---|---|
|
terminal | Am Ende der Stream-Verarbeitung | Nein | Finale Aktionen (Ausgabe, Logging, Schreiben in die Datenbank) |
|
intermediär | In der Mitte der Operationskette | Ja | Debugging, Zwischen-Logging, Zählen |
Beispiel: Unterschied in der Verwendung
// Beispiel mit forEach
users.stream()
.filter(name -> name.startsWith("A"))
.map(String::toUpperCase)
.forEach(System.out::println); // Hier wird der Stream abgeschlossen
// Beispiel mit peek
users.stream()
.filter(name -> name.startsWith("A"))
.peek(name -> System.out.println("Filter bestanden: " + name))
.map(String::toUpperCase)
.collect(Collectors.toList()); // Die Kette kann fortgesetzt werden
Wichtig zu beachten
- forEach ist ein Point of no Return: Danach kann mit dem Stream nichts Weiteres gemacht werden.
- peek garantiert keine Ausführung, wenn keine terminale Operation folgt. Besteht die Kette nur aus intermediären Operationen, passiert nichts.
4. Weniger offensichtliche Punkte: forEach ist nicht immer die beste Wahl!
Warum sollte man forEach nicht zum Ändern von Collections verwenden?
Viele Einsteiger versuchen, mit forEach Elemente einer Collection oder die Collection selbst zu ändern (z. B. Elemente zu entfernen). Das ist jedoch eine schlechte Praxis: Streams sind nicht dazu gedacht, die Ausgangs-Collections zu modifizieren.
Beispiel für eine falsche Verwendung:
List<String> names = new ArrayList<>(List.of("Anna", "Boris", "Alex"));
names.stream().forEach(name -> {
if (name.startsWith("A")) {
names.remove(name); // Kann zu ConcurrentModificationException führen!
}
});
Ergebnis: Laufzeitfehler – die Collection darf während des Stream-Durchlaufs nicht verändert werden (ConcurrentModificationException).
Wofür sollte man forEach dennoch verwenden?
- Für die Ausgabe auf den Bildschirm (z. B. das Drucken eines Berichts).
- Für Logging.
- Für Aufrufe externer Dienste (z. B. das Senden einer E-Mail).
- Zum Erfassen von Statistiken (z. B. das Erhöhen eines Zählers).
5. Noch einmal zu peek: nur für Debugging!
Es ist verlockend, peek zu verwenden, um Elemente zu verändern, etwa das Alter eines Benutzers zu erhöhen:
users.stream()
.peek(user -> user.setAge(user.getAge() + 1)) // Schlecht!
.collect(Collectors.toList());
Warum ist das schlecht?
- Das verletzt die Deklarativität und die „Reinheit“ des Stream API.
- Solcher Code wird schwer wartbar und schwer testbar.
- Nebeneffekte in einer intermediären Operation können zu schwer erkennbaren Bugs führen.
Verwenden Sie für Transformationen besser map:
List<User> olderUsers = users.stream()
.map(user -> new User(user.getName(), user.getAge() + 1))
.collect(Collectors.toList());
Schaubild: Unterschied zwischen forEach und peek
users.stream()
.filter(...) // intermediäre Operation
.peek(...) // intermediäre Operation, „hinsehen”
.map(...) // intermediäre Operation
.forEach(...) // terminale Operation, „ausführen”
Erklärung:
– Alles vor forEach lässt sich kombinieren, umordnen, hinzufügen.
– Nach forEach ist der Stream geschlossen.
6. Typische Fehler im Umgang mit forEach und peek
Fehler Nr. 1: peek zum Ändern von Daten verwenden. peek ist nur zum Beobachten gedacht, nicht zum Ändern der Stream-Elemente. Für Transformationen verwenden Sie map.
Fehler Nr. 2: Erwarten, dass peek immer ausgeführt wird. peek wird nur ausgeführt, wenn danach eine terminale Operation folgt (collect, forEach, count usw.). Ohne terminale Operation passiert nichts.
Fehler Nr. 3: Versuchen, den Stream nach forEach fortzusetzen. forEach ist eine terminale Operation. Danach können keine weiteren Stream-Methoden aufgerufen werden.
Fehler Nr. 4: Eine Collection innerhalb von forEach modifizieren. Die Ausgangs-Collection (Elemente löschen oder hinzufügen) während des Durchlaufs über forEach zu verändern, führt direkt zu ConcurrentModificationException.
Fehler Nr. 5: forEach statt collect zum Sammeln des Ergebnisses verwenden. Wenn Sie Elemente in eine neue Collection sammeln möchten, verwenden Sie collect(Collectors.toList()) und nicht forEach mit manuellem Hinzufügen. Das verletzt die Deklarativität und kann in Multithreading-Szenarien zu Fehlern führen.
GO TO FULL VERSION