16_Streams

Code-Dateien

DateinameAktion
CODECode_Fahrzeug.zipDownload
CODECode_Mitarbeiter.zipDownload
CODECode_Student.zipDownload

PDF-Dokumente

DateinameAktion
PDFFolie_Mitarbeiter.pdfÖffnen
PDFUebung_Speise.pdfÖffnen
PDFUebung_Wohnung.pdfÖffnen

Videos

DateinameAktion
VIDEOVideo_Fahrzeug_DAbspielen
VIDEOVideo_Mitarbeiter_DAbspielen
VIDEOVideo_Student_DAbspielen

Lernmaterialien

Streams

Java Streams sind eine Art, Daten (z. B. Listen) als “Fluss” zu verarbeiten – Schritt für Schritt, meist in kleinen, gut lesbaren Operationen wie filtern, umwandeln, sortieren und zusammenfassen.

Wichtig: Ein Stream ist keine Datenstruktur, sondern eine Verarbeitungspipeline über Daten.


Grundidee in einem Satz

Du nimmst eine Quelle (z. B. List<Person>), machst mehrere Verarbeitungsschritte hintereinander (z. B. filtermapsorted) und bekommst am Ende ein Ergebnis (z. B. Liste, Zahl, Boolean).

So ähnlich wie eine Produktionsstraße:

  • Rohmaterial rein (Quelle)

  • mehrere Stationen (Operationen)

  • fertiges Produkt raus (Ergebnis)


Warum Streams nutzen?

✅ Vorteile

  • Lesbarer als verschachtelte Schleifen (oft)

  • Weniger Boilerplate (weniger “Zähler”, “if”, “temporäre Listen”)

  • Deklarativ: Du beschreibst was passieren soll, nicht wie in Schleifen

  • Einfach parallelisierbar (optional) mit parallelStream()

⚠️ Typische Missverständnisse

  • Stream speichert die Elemente nicht neu, er verarbeitet sie nur.

  • Streams sind oft einmalig: Wenn du einen Stream „verbraucht“ hast, kannst du ihn nicht nochmal benutzen.


Die drei Bausteine eines Streams

(A) Quelle

Zum Beispiel:

  • liste.stream()

  • Arrays.stream(array)

  • Files.lines(path) (Dateizeilen als Stream)

  • Stream.of(...)

(B) Zwischenoperationen (Intermediate Operations)

Das sind Schritte, die wieder einen Stream zurückgeben, z. B.:

  • filter(...) → Elemente auswählen

  • map(...) → Elemente umwandeln

  • sorted(...) → sortieren

  • distinct() → Duplikate entfernen

  • limit(n) / skip(n) → begrenzen/überspringen

Wichtig: Zwischenoperationen werden meist lazy ausgeführt:
Sie laufen erst wirklich, wenn am Ende eine Endoperation kommt.

(C) Endoperation (Terminal Operation)

Die beendet die Pipeline und erzeugt ein Ergebnis:

  • collect(...) → z. B. in Liste/Set/Map sammeln

  • forEach(...) → ausführen (z. B. drucken)

  • count() → zählen

  • anyMatch/allMatch/noneMatch() → prüfen

  • reduce(...) → zusammenfassen (z. B. Summe)

  • findFirst() / findAny() → ein Element finden


Beispiele

Beispiel 1: Filtern + Sammeln

Wir wollen alle geraden Zahlen aus einer Liste:

List<Integer> zahlen = List.of(1,2,3,4,5,6);

List<Integer> gerade = zahlen.stream()
    .filter(z -> z % 2 == 0)
    .toList(); // ab Java 16

// Ergebnis: [2, 4, 6]

Beispiel 2: Umwandeln (map)

Wir wollen alle Namen in Großbuchstaben:

List<String> namen = List.of("Anna", "Bob", "Clara");

List<String> gross = namen.stream()
    .map(String::toUpperCase)
    .toList();

// ["ANNA", "BOB", "CLARA"]

Beispiel 3: Filtern + Sortieren + Ergebnis

Namen, die mit A anfangen, alphabetisch:

List<String> result = namen.stream()
    .filter(n -> n.startsWith("A"))
    .sorted()
    .toList();

Beispiel 4: Zusammenfassen (count, sum)

Wie viele sind länger als 3 Zeichen?

long anzahl = namen.stream()
    .filter(n -> n.length() > 3)
    .count();

Summe:

int summe = zahlen.stream()
    .mapToInt(Integer::intValue)
    .sum();

Student

sumAlterNameA()

    public int sumAlterNameA() {
        return students.stream()
                .filter(s -> s.getName().startsWith("A"))
                .filter(s -> s.getAlter() > 10)
                .map(s -> s.getAlter())
                .mapToInt(i -> i.intValue())
                .sum();
    }

Die Methode berechnet die Summe der Alter aller Students, die (1) mit “A” im Namen anfangen und (2) älter als 10 sind.

students.stream()

Erzeugt einen Stream aus der Collection students (z. B. List<Student>).
Ab jetzt wird jeder Student nacheinander durch die Pipeline “geschickt”.

.filter(s -> s.getName().startsWith("A"))

Lässt nur die Students durch, deren Name mit “A” beginnt.

  • Beispiel: "Anna" ✅, "Andreas" ✅, "Berta"

.filter(s -> s.getAlter() > 10)

Von den “A”-Students bleiben nur jene, deren Alter größer als 10 ist.

  • Alter 10 ❌ (weil > 10)

  • Alter 11 ✅

👉 Du könntest die zwei Filter auch in einen zusammenfassen:

.filter(s -> s.getName().startsWith("A") && s.getAlter() > 10)

Ist funktional identisch.

.map(s -> s.getAlter())

Jetzt wird aus jedem Student nur noch sein Alter gemacht.

  • Vorher: Stream

  • Nachher: Stream (oder Stream von dem Rückgabetyp von getAlter())

Beispiel: Student("Anna", 12)12

.mapToInt(i -> i.intValue())

Wandelt den Stream von Integer-Objekten in einen IntStream (primitive int) um.

  • i -> i.intValue() heißt: “mach aus dem Integer den primitiven int”

  • Kürzer ginge auch:

.mapToInt(Integer::intValue)

Warum nötig?
Weil sum() nur bei IntStream existiert (nicht bei Stream<Integer>).

.sum()

Addiert alle int-Werte im IntStream und liefert die Summe als int.

Angenommen:

  • Anna (12)

  • Anton (9)

  • Berta (20)

  • Andreas (15)

Pipeline:

  1. Name startet mit A → Anna(12), Anton(9), Andreas(15)

  2. Alter > 10 → Anna(12), Andreas(15)

  3. Alter extrahieren → 12, 15

  4. Summe → 27

alleNamen()

    public String[] alleNamen() {
        return students.stream()
                .map(s -> s.getName())
                .map(s -> s.toUpperCase())
                .distinct()
                .sorted()
                .toArray(size -> new String[size]);
    }

Diese Methode liefert ein String-Array mit allen Namen, dabei werden die Namen groß geschrieben, Duplikate entfernt und alphabetisch sortiert.

students.stream()

Startet einen Stream über alle students (z. B. List<Student>).

.map(s -> s.getName())

Macht aus jedem Student seinen Namen.

  • Vorher: Stream<Student>

  • Nachher: Stream<String>

.map(s -> s.toUpperCase())

Wandelt jeden Namen in Großbuchstaben um.

Beispiel:

  • "Anna""ANNA"

  • "Andreas""ANDREAS"

(Das ist nützlich, weil danach distinct() und sorted() case-unabhängig “einheitlicher” arbeiten.)

.distinct()

Entfernt Doppelungen (Duplikate) im Stream.

Wichtig: Bei Strings bedeutet das: gleiche Zeichenfolge (nach toUpperCase()!).

Beispiel:

  • "Anna" und "ANNA" würden nach Großschreibung beide "ANNA" sein → nur einmal übrig.

.sorted()

Sortiert die verbleibenden Namen aufsteigend (alphabetisch) nach natürlicher Ordnung (String-Vergleich).

Ergebnis z. B.:
["ANDREAS", "ANNA", "BERTA"] (je nach Inhalt)

.toArray(size -> new String[size])

Erzeugt am Ende ein String[] in genau der benötigten Größe.

Warum so?

  • toArray() ohne Argument gibt nur Object[] zurück.

  • Mit size -> new String[size] sagst du: “Baue mir ein String[] mit dieser Länge”.

In Java 17 ist die kürzere Variante üblich:

.toArray(String[]::new)

Gleiche Logik, etwas kürzer und lesbarer:

public String[] alleNamen() {
    return students.stream()
            .map(Student::getName)
            .map(String::toUpperCase)
            .distinct()
            .sorted()
            .toArray(String[]::new);
}

Lazy Execution

Streams sind “faul”:
Die Zwischenoperationen laufen erst, wenn eine Endoperation kommt.

Stream<Integer> s = zahlen.stream()
    .filter(z -> z % 2 == 0); // passiert noch nichts

// Erst jetzt wird wirklich gearbeitet:
long c = s.count();

Häufige Operatoren kurz erklärt

  • filter(x -> ...) → behält nur Elemente, die die Bedingung erfüllen

  • map(x -> ...) → macht aus jedem Element ein neues (Transformation)

  • flatMap(...) → „Listen von Listen“ zu einer flachen Liste machen

  • collect(Collectors.toList()) oder .toList() → Ergebnis einsammeln

  • reduce(...) → alles zu einem Wert zusammenfassen (z. B. Produkt, Summe)

  • peek(...) → zum Debuggen reinschauen (nicht als Logik missbrauchen)


Streams vs. Schleifen – wann was?

Streams sind super, wenn …

  • du Daten in mehreren Schritten verarbeiten willst (filter → map → collect)

  • du lesbaren Code möchtest

  • du Aggregationen brauchst (count/sum/grouping)

Schleifen sind oft besser, wenn …

  • du sehr komplexe Kontrolllogik hast (break/continue an vielen Stellen)

  • du extrem performancekritischen Code hast und jede Allokation vermeiden willst

  • du vieles mit Seiteneffekten machst (z. B. in mehrere Strukturen schreiben)


flatMap

1) flatMap ganz einfach

Problem

Du hast eine Liste von Listen, z. B. Klassen → Schüler:

List<List<String>> klassen = List.of(
    List.of("Anna", "Bob"),
    List.of("Clara"),
    List.of("David", "Eva")
);

Wenn du jetzt nur map benutzt:

List<Stream<String>> streams = klassen.stream()
    .map(List::stream)
    .toList();

Dann bekommst du eine Liste von Streams (also „verschachtelt“). Oft willst du aber eine einzige flache Liste aller Namen.

Lösung: flatMap

flatMap bedeutet: umwandeln und dabei “flachziehen”.

List<String> alleSchueler = klassen.stream()
    .flatMap(List::stream)  // macht aus List<List<String>> -> Stream<String>
    .toList();

System.out.println(alleSchueler);
// [Anna, Bob, Clara, David, Eva]

Merksatz:

  • map = 1 Element → 1 Element (oder 1 Element → 1 Liste/Stream, aber bleibt verschachtelt)

  • flatMap = 1 Element → viele Elemente, und am Ende alles in einem Stream

Mini-Beispiel mit Text

Wörter in einem Satz, aber flach als einzelne Wörter:

List<String> saetze = List.of("Hallo Welt", "Java Streams sind cool");

List<String> woerter = saetze.stream()
    .flatMap(s -> Arrays.stream(s.split(" ")))
    .toList();

// ["Hallo", "Welt", "Java", "Streams", "sind", "cool"]

Gruppierung

Im Student-Projekt passen Gruppierungen mit Streams richtig gut, weil du viele sinnvolle Schlüssel hast: Klasse, Typ (Tag/Abend), Semester, Alter, Matura, Anzahl Ferialpraxis.

import java.util.*;
import java.util.stream.Collectors;

Gruppieren nach Klasse: Map<String, List<Student>>

Alle Students je Klasse sammeln (z. B. “3AHIF” → Liste)

    public Map<String, List<Student>> gruppiereNachKlasse() {
        return students.stream()
                .collect(Collectors.groupingBy(s -> s.getKlasse()));
    }

Die Methode baut dir eine Map, in der jede Klasse (z. B. "3AHIF") der Schlüssel ist und als Wert die Liste aller Students dieser Klasse steht.

Rückgabetyp

Map<String, List<Student>>

  • Key (String): die Klasse, z. B. "3AHIF"

  • Value (List<Student>): alle Students, die zu dieser Klasse gehören

Also ungefähr so:

  • "3AHIF" → [Student1, Student2, ...]

  • "2BHIF" → [StudentX, StudentY, ...]

students.stream()

Erzeugt einen Stream über alle Students in deiner Collection students (z. B. eine List<Student>).

collect(...)

collect ist eine Endoperation: Sie sammelt die Stream-Elemente zu einem Ergebnis (hier: eine Map).

Collectors.groupingBy(...)

groupingBy macht genau das, was der Name sagt: gruppieren nach einem Schlüssel.

  • Für jeden Student wird ein Schlüssel berechnet.

  • Alle Students mit dem gleichen Schlüssel landen gemeinsam in einer Liste.

s -> s.getKlasse()

Das ist die Schlüsselfunktion: Für jeden Student wird seine Klasse als Gruppenschlüssel verwendet.

Du kannst das auch kürzer schreiben als Methodenreferenz:

.collect(Collectors.groupingBy(Student::getKlasse));

Mini-Beispiel

Angenommen students enthält:

  • Anna, Klasse "3AHIF"

  • Bob, Klasse "3AHIF"

  • Clara, Klasse "2BHIF"

Dann liefert die Methode:

  • "3AHIF" → [Anna, Bob]

  • "2BHIF" → [Clara]


Gruppieren nach Klasse, aber nur Namen: Map<String, List<String>>

    public Map<String, List<String>> namenNachKlasse() {
        return students.stream()
                .collect(Collectors.groupingBy(
                        s -> s.getKlasse(),
                        Collectors.mapping(s -> s.getName(), Collectors.toList())
                ));
    }

Diese Methode erstellt eine Map, die pro Klasse eine Liste der Namen enthält.

Also nicht Student-Objekte pro Klasse, sondern nur die Strings (Namen).

Rückgabetyp: Map<String, List<String>>

  • Key (String): Klasse (z. B. "3AHIF")

  • Value (List<String>): Namen aller Students in dieser Klasse

Beispiel:

  • "3AHIF"["Anna", "Bob", "Clara"]

  • "2BHIF"["David", "Eva"]

students.stream()

Startet den Stream über alle Student-Objekte.

collect(...)

Endoperation: sammelt zu einem Ergebnis (hier: eine Map).

Collectors.groupingBy( ... )

groupingBy gruppiert Elemente nach einem Schlüssel.

Es hat hier zwei Teile:

groupingBy(
   keyFunction,
   downstreamCollector
)

Teil A: Schlüssel (Key Function)

s -> s.getKlasse()

Für jeden Student wird die Klasse als Schlüssel berechnet.
Damit entscheidet sich, in welche Gruppe der Student kommt.

Teil B: “Downstream Collector” – was passiert innerhalb jeder Gruppe?

Hier steht:

Collectors.mapping(s -> s.getName(), Collectors.toList())

Das heißt: In jeder Klasse-Gruppe soll nicht der ganze Student gesammelt werden, sondern:

  1. mapping: wandle jedes Gruppenelement um
    s -> s.getName() macht aus Student einen String (den Namen)

  2. toList: sammle diese Namen in eine Liste

Kurz gesagt:

➡️ pro Klasse: Student → Name → Liste von Namen

Mit Methodenreferenzen:

public Map<String, List<String>> namenNachKlasse() {
    return students.stream()
            .collect(Collectors.groupingBy(
                    Student::getKlasse,
                    Collectors.mapping(Student::getName, Collectors.toList())
            ));
}

Anzahl pro Klasse (wie SQL GROUP BY klasse COUNT(*)): Map<String, Long>

public Map<String, Long> anzahlProKlasse() {
    return students.stream()
            .collect(Collectors.groupingBy(
                    Student::getKlasse,
                    Collectors.counting()
            ));
}

Guppieren nach Typ (TagStudent vs AbendStudent): Map<String, List<Student>>

Hier gruppieren wir nach einem selbst gewählten Label:

public Map<String, List<Student>> gruppiereNachTyp() {
    return students.stream()
            .collect(Collectors.groupingBy(s ->
                    (s instanceof AbendStudent) ? "AbendStudent" : "TagStudent"
            ));
}

Files

public class StudentException extends RuntimeException {
    public StudentException(String meldung) {
        super(meldung);
    }
}

Du baust sie nur dann zu RuntimeException um, wenn du willst, dass StudentException eine unchecked Exception ist – also nicht überall mit throws/try-catch “mitgeschleppt” werden muss.

Der Kern ist der Unterschied:

  • extends Exceptionchecked exception
    Java zwingt dich: entweder fangen oder weiterwerfen (try/catch oder throws).

  • extends RuntimeExceptionunchecked exception
    Java zwingt dich nicht – du kannst sie werfen, ohne Methodensignaturen anzupassen.

Bei Streams (z. B. forEach, map) kannst du nicht einfach eine checked Exception werfen, weil die Functional Interfaces (Consumer, Function, …) keine checked Exceptions in der Signatur haben.

public void importSchool(String filename) throws StudentException {
    if (filename == null) {
        throw new StudentException("Fehler: keine Datei!");
    }

    try (Stream<String> lines = Files.lines(Path.of(filename))) {
        lines.forEach(line -> {
            if (line.startsWith("A")) {
                aufnehmen(new AbendStudent(line));
            } else if (line.startsWith("T")) {
                aufnehmen(new TagStudent(line));
            }
        });
    } catch (java.nio.file.NoSuchFileException e) {
        throw new StudentException("Fehler: kann die Datei " + filename + " nicht finden!");
    } catch (IOException e) {
        throw new StudentException("Fehler: kann die Datei " + filename + " nicht lesen!");
    }
}

Die Methode liest eine Datei zeilenweise ein und erzeugt pro Zeile (je nach Anfangsbuchstabe) einen AbendStudent oder TagStudent, den sie dann mit aufnehmen(...) in deine School übernimmt.

public void importSchool(String filename) throws StudentException {
  • Methode heißt importSchool

  • bekommt den Dateinamen als String

  • kann eine StudentException werfen (deine eigene Exception-Klasse)

Null-Check

if (filename == null) {
    throw new StudentException("Fehler: keine Datei!");
}

Wenn filename null ist, ist das ein Programmfehler / falscher Aufruf.
Dann wird sofort abgebrochen und eine verständliche Fehlermeldung geworfen.

Datei als Stream lesen (try-with-resources)

try (Stream<String> lines = Files.lines(Path.of(filename))) {
  • Path.of(filename) macht aus dem String einen Path.

  • Files.lines(...) öffnet die Datei und liefert einen Stream<String>, also einen Stream aus Zeilen.

  • try-with-resources sorgt dafür, dass die Datei/der Stream am Ende automatisch geschlossen wird – auch wenn ein Fehler passiert.

Das ist die moderne Stream-Alternative zu FileReader + BufferedReader + while(readLine()).

Jede Zeile verarbeiten

lines.forEach(line -> {
    if (line.startsWith("A")) {
        aufnehmen(new AbendStudent(line));
    } else if (line.startsWith("T")) {
        aufnehmen(new TagStudent(line));
    }
});
  • forEach(...) ist die Endoperation: sie läuft über alle Zeilen.

  • Für jede Zeile (line) wird geprüft:

    • Beginnt sie mit "A"AbendStudent erzeugen

    • Beginnt sie mit "T"TagStudent erzeugen

  • new AbendStudent(line) / new TagStudent(line) bedeutet:
    Der Konstruktor liest vermutlich die Information aus der Zeile (z. B. Name, Alter, Klasse …).

  • aufnehmen(...) speichert den Student dann in deiner Sammlung students.

Wenn eine Zeile weder mit A noch mit T beginnt:
Passiert einfach nichts (die Zeile wird ignoriert).

Datei nicht gefunden

} catch (java.nio.file.NoSuchFileException e) {
    throw new StudentException("Fehler: kann die Datei " + filename + " nicht finden!");
}

Wenn die Datei nicht existiert (oder der Pfad falsch ist), kommt NoSuchFileException. Du wandelst sie in eine StudentException mit eigener Meldung um.

Andere Leseprobleme

catch (IOException e) {
    throw new StudentException("Fehler: kann die Datei " + filename + " nicht lesen!");
}

Das ist alles andere, was beim Lesen schiefgehen kann, z. B.:

  • keine Berechtigung

  • Datenträgerfehler

  • Datei ist gesperrt

  • etc.

Auch das wird in StudentException umgewandelt, damit dein Programm einheitlich mit Fehlern umgehen kann.

Warum ist das “Stream”-Code, obwohl es wie eine Schleife wirkt?

Weil:

  • Files.lines(...) liefert die Zeilen als Stream

  • forEach ist die “streamige” Art, über alle Elemente zu laufen

Das ist hier völlig ok, weil du Seiteneffekte hast (aufnehmen(...)).

Das hier ist identisch, aber kürzer:

try (Stream<String> lines = Files.lines(Path.of(filename))) {
    lines.forEach(line -> {
        if (line.startsWith("A")) aufnehmen(new AbendStudent(line));
        else if (line.startsWith("T")) aufnehmen(new TagStudent(line));
    });
}