1. Imperativer vs. funktionaler Stil
Beginnen wir mit einer einfachen Frage: Wozu braucht man überhaupt diesen „funktionalen Stil“? Worin ist er besser als der gewohnte, übliche Ansatz mit Schleifen? Und was bedeutet „funktionaler Stil“ in Java eigentlich?
Imperativer Stil
Imperativ bedeutet, dass Sie dem Computer sagen, wie er etwas Schritt für Schritt tun soll. Wenn Sie zum Beispiel aus einer Liste von Strings die Längen dieser Strings erhalten, nur ungerade Längen behalten und absteigend sortieren möchten, schreiben Sie ungefähr so:
List<String> words = Arrays.asList("cat", "elephant", "rhino", "tiger", "mouse");
List<Integer> lengths = new ArrayList<>();
for (String word : words) {
int len = word.length();
if (len % 2 != 0) {
lengths.add(len);
}
}
lengths.sort(Comparator.reverseOrder());
System.out.println(lengths); // [7, 5, 3]
Hier erstellen wir explizit eine Zwischenliste, fügen Elemente manuell hinzu, sortieren – alles Schritt für Schritt.
Funktionaler Stil
Funktional bedeutet, dass Sie beschreiben, was Sie bekommen möchten, nicht, wie es umgesetzt wird. In Java geschieht das über die Stream-API:
List<String> words = Arrays.asList("cat", "elephant", "rhino", "tiger", "mouse");
List<Integer> result = words.stream()
.map(String::length)
.filter(len -> len % 2 != 0)
.sorted(Comparator.reverseOrder())
.toList();
System.out.println(result); // [7, 5, 3]
Hier bauen wir gewissermaßen ein „Fließband“ zur Datenverarbeitung: Zuerst verwandeln wir Wörter in ihre Längen (map), dann filtern wir die ungeraden (filter), danach sortieren wir (sorted). All das in einer einzigen Kette – ohne explizite Zwischenkollektionen und Schleifen.
Vergleich: iterativer vs. funktionaler Stil
Im gewohnten imperativen Ansatz schreiben wir eine Schleife, in der wir dem Computer Schritt für Schritt erklären, was zu tun ist: über jedes Element iterieren, eine Bedingung prüfen, zu einer neuen Liste hinzufügen oder ausgeben. Der Code funktioniert, braucht aber mehr Zeilen, und je komplexer die Aufgabe wird, desto schwerer ist er zu lesen und zu warten.
Der funktionale Stil erlaubt es, nicht den Prozess selbst zu beschreiben, sondern das gewünschte Ergebnis. Statt einer langen Schleife bauen wir eine Kette von Operationen: filtern, transformieren, Ergebnis sammeln. Das ist kürzer, anschaulicher und verringert das Fehlerrisiko, weil weniger „Handarbeit“ mit veränderlichen Kollektionen nötig ist.
Es gibt auch eine Kehrseite. Für Einsteiger kann eine solche Kette verwirrend wirken: Mehrere Lambdas hintereinander sind schwieriger zu lesen als eine einfache Schleife. Daher punktet der funktionale Stil bei Kürze und Ausdruckskraft, erfordert aber Übung und Erfahrung.
2. Grundlegende Operationen der Stream-API
Die Stream-API ist nicht einfach nur „eine neue Art von Schleife“, sondern ein ganzes Werkzeugset für die Verarbeitung von Kollektionen im funktionalen Stil. Schauen wir uns die grundlegenden Operationen an.
Wie bekommt man einen Stream?
List<String> list = List.of("a", "bb", "ccc");
Stream<String> stream = list.stream();
Zwischenoperationen
- map – transformiert die Elemente des Streams
- filter – filtert Elemente nach einer Bedingung
- flatMap – macht aus jedem Element einen Stream und „klappt“ sie auf
- sorted – sortiert
- distinct – entfernt Duplikate
- limit / skip – Elemente begrenzen/überspringen
Terminaloperationen
- forEach – führt für jedes Element eine Aktion aus
- collect – sammelt das Ergebnis in einer Kollektion
- reduce – reduziert den Stream auf einen Wert (z. B. eine Summe)
- count – zählt die Elemente
- anyMatch, allMatch, noneMatch – prüft Bedingungen
Beispiel: Verarbeitungskette
List<String> names = List.of("Anna", "Boris", "Victoria", "George", "Deborah");
List<String> filtered = names.stream()
.filter(name -> name.length() > 3)
.map(String::toUpperCase)
.sorted()
.toList();
System.out.println(filtered); // [BORIS, VIKA, DASHA]
Visuelle Darstellung der „Pipeline“:
[Anna, Boris, Victoria, George, Deborah]
| filter (length>3)
[Boris, Victoria, Deborah]
| map (toUpperCase)
[BORIS, VIKA, DASHA]
| sorted
[BORIS, VIKA, DASHA]
| toList
Jede Operation verändert die Ursprungskollektion nicht – es wird ein neuer Stream erzeugt.
3. Stream-Ausführung und Trägheit
Zwischen- und Terminaloperationen
Streams haben zwei Arten von Operationen. Erstens Zwischenoperationen wie map, filter oder sorted. Sie geben einen neuen Stream zurück und scheinen etwas zu versprechen, führen aber tatsächlich noch nichts aus. Zweitens Terminaloperationen, zum Beispiel forEach, collect oder reduce. Diese starten die Verarbeitung wirklich. Der Schlüsselpunkt ist: Solange Sie keine Terminaloperation aufrufen, bleibt der Stream „träge“ – die Berechnungen beginnen nicht.
Beispiel:
Stream<String> stream = List.of("a", "bb", "ccc").stream()
.map(s -> {
System.out.println("map: " + s);
return s.toUpperCase();
});
System.out.println("Vor forEach");
stream.forEach(System.out::println);
Ausgabe:
Vor forEach
map: a
A
map: bb
BB
map: ccc
CCC
Man sieht, dass map nicht ausgeführt wird, bis forEach startet.
Warum ist das großartig?
Dieser Ansatz erlaubt es, ohne großen Aufwand beliebig lange Transformationsketten zu bauen. Der Stream beginnt erst zu arbeiten, wenn es wirklich nötig ist. Dadurch lassen sich problemlos sehr große Datenmengen oder sogar unendliche Sequenzen verarbeiten. Außerdem spart die Trägheit Speicher und ermöglicht effizientere Berechnungen.
4. Praxis: Aufgabe „Strings → Längen → ungerade → absteigend“
Gehen wir die Aufgabe Schritt für Schritt durch: „Aus einer Liste von Strings eine Liste der Stringlängen bilden, nur ungerade Längen behalten, absteigend sortieren.“
Imperative Lösung
List<String> words = Arrays.asList("cat", "elephant", "rhino", "tiger", "mouse");
List<Integer> lengths = new ArrayList<>();
for (String word : words) {
int len = word.length();
if (len % 2 != 0) {
lengths.add(len);
}
}
lengths.sort(Comparator.reverseOrder());
System.out.println(lengths); // [7, 5, 3]
Funktionale Lösung mit der Stream-API
List<String> words = Arrays.asList("cat", "elephant", "rhino", "tiger", "mouse");
List<Integer> result = words.stream()
.map(String::length) // Strings in ihre Längen umwandeln
.filter(len -> len % 2 != 0) // nur ungerade Längen behalten
.sorted(Comparator.reverseOrder()) // absteigend sortieren
.toList(); // in eine List sammeln (Java 16+)
System.out.println(result); // [7, 5, 3]
Erläuterungen:
- map(String::length) – für jeden String seine Länge bestimmen.
- filter(len -> len % 2 != 0) – nur ungerade Längen behalten.
- sorted(Comparator.reverseOrder()) – absteigend sortieren.
- toList() – den Stream in eine neue Liste sammeln.
Analogie
Das ist, als würden Sie ein Fließband in einer Fabrik bauen: Auf jeder Station werden die Teile anders bearbeitet, und erst ganz am Ende landet alles in der Kiste.
5. Weitere Beispiele: map, filter, forEach, collect
Beispiel 1: Filtern und Ausgeben
List<String> names = List.of("Anna", "Boris", "Victoria", "George", "Deborah");
names.stream()
.filter(name -> name.contains("a"))
.forEach(System.out::println);
// Wird ausgeben: Anna, Deborah
Beispiel 2: Transformieren und in ein Set sammeln
Set<String> upperNames = names.stream()
.map(String::toUpperCase)
.collect(Collectors.toSet());
System.out.println(upperNames); // [ANNA, BORIS, VIKA, GLEB, DASHA]
Beispiel 3: Summe der Längen aller Strings ermitteln
int totalLength = names.stream()
.mapToInt(String::length)
.sum();
System.out.println("Gesamtlänge: " + totalLength);
Beispiel 4: Verwendung von Predicate und Function
Predicate<String> longName = name -> name.length() > 4;
Function<String, String> greet = name -> "Hallo, " + name + "!";
names.stream()
.filter(longName)
.map(greet)
.forEach(System.out::println);
// Hallo, Boris!
// Hallo, Deborah!
6. Besonderheiten des funktionalen Stils mit der Stream-API
Kein veränderlicher Zustand
Der Stil der Stream-API fördert „pure“ Funktionen – ohne Seiteneffekte. Das bedeutet, dass man in Lambdas externe Variablen möglichst nicht verändern sollte.
Schlecht:
List<String> result = new ArrayList<>();
names.stream()
.filter(name -> name.startsWith("A"))
.forEach(result::add); // side-effect!
Besser:
List<String> result = names.stream()
.filter(name -> name.startsWith("A"))
.toList();
Komposition von Operationen
Man kann sehr lange Ketten bauen, indem man map, filter, sorted und andere Methoden kombiniert. Wichtig ist, es nicht zu übertreiben: Wenn die Kette länger als der Bildschirm wird, lohnt es sich, sie in Teile zu zerlegen.
Träge Ausführung
Die Stream-API tut nichts, bevor eine Terminaloperation erreicht wird. Das spart Ressourcen und ermöglicht effiziente Verarbeitungs-Pipelines.
Unveränderlichkeit der Ausgangskollektionen
Ein Stream verändert die ursprüngliche Kollektion nicht! Alle Transformationen geben einen neuen Stream/eine neue Kollektion zurück.
7. Wann sollte man die Stream-API verwenden
Stream-API eignet sich hervorragend, wenn:
- Sie eine Kollektion schnell verarbeiten möchten (Filtern, Transformieren, Sortieren).
- Sie knappen, gut lesbaren Code brauchen.
- Sie nicht manuell Zwischenkollektionen erstellen wollen.
- Sie leicht Parallelität hinzufügen möchten (über parallelStream()).
Imperativer Stil ist manchmal vorzuziehen, wenn:
- Komplexe Logik mit mehreren verschachtelten Schleifen und Bedingungen nötig ist.
- Maximale Performance in kritischen Bereichen wichtig ist (die Stream-API ist manchmal etwas langsamer).
- Mit veränderlichem Zustand gearbeitet werden muss (z. B. Elemente „in place“ aktualisieren).
8. Typische Fehler beim Arbeiten mit der Stream-API
Fehler Nr. 1: Verwendung von forEach zum Sammeln einer Kollektion. Viele Einsteiger nutzen forEach, um Elemente in eine neue Kollektion hinzuzufügen. Das ist kein funktionaler Stil! Verwenden Sie stattdessen collect oder toList().
Fehler Nr. 2: Verfrühte Optimierung. Versuchen Sie nicht sofort parallelStream() einzusetzen – Parallelität ist nur für wirklich große Kollektionen und CPU-intensive Aufgaben sinnvoll.
Fehler Nr. 3: Stream-API mit veränderlichen Kollektionen mischen. Die Stream-API setzt auf unveränderliche Daten. Verändern Sie Elemente nicht innerhalb von Lambdas.
Fehler Nr. 4: „Verlust“ des Ergebnisses. Wenn Sie eine Terminaloperation vergessen, passiert gar nichts.
Fehler Nr. 5: Zu komplexe Lambdas. Wenn ein Lambda-Ausdruck länger als ein oder zwei Zeilen wird – extrahieren Sie ihn in eine eigene Methode mit verständlichem Namen.
GO TO FULL VERSION