1. Einleitung
Im echten Leben muss man nahezu immer Daten gruppieren. Zum Beispiel Studierende nach Kursen aufteilen, Waren nach Kategorien sortieren, Volljährige von Kindern trennen und so weiter.
Ohne Stream API wurden solche Aufgaben manuell gelöst: Sammlung durchlaufen, Merkmal prüfen, zum passenden Listeintrag in der Map hinzufügen. Typischer „oldschool“-Code:
Map<String, List<Employee>> byDepartment = new HashMap<>();
for (Employee employee : employees) {
String dept = employee.getDepartment();
byDepartment.computeIfAbsent(dept, k -> new ArrayList<>()).add(employee);
}
Funktioniert, sieht aber so aus, als würden Sie Nudeln per Hand in Gläser sortieren. Und wenn nach mehreren Merkmalen gruppiert werden soll? Oder zusätzlich Summen pro Gruppe berechnet? Der Code wächst und wird unlesbar.
Stream API und spezielle Collector (groupingBy, partitioningBy) erledigen das in ein, zwei Zeilen – und lassen Sie dabei wie einen Java-Zauberer aussehen.
2. Collector groupingBy: Gruppierung nach Merkmal
Grundidee
groupingBy ist ein Collector, der einen Stream von Elementen in eine Map verwandelt, bei der der Schlüssel das Ergebnis einer Klassifizierungsfunktion ist und der Wert die Liste der Elemente, die zu diesem Merkmal passen.
Signatur:
Collectors.groupingBy(Function<T, K>)
- T – Typ des Elements im Stream,
- K – Typ des Schlüssels (der Gruppe), den die Funktion zurückgibt.
Einfaches Beispiel: Mitarbeitende nach Abteilung gruppieren
Angenommen, wir haben eine Klasse:
public class Employee {
private final String name;
private final String department;
// ... Konstruktor und Getter
public Employee(String name, String department) {
this.name = name;
this.department = department;
}
public String getName() { return name; }
public String getDepartment() { return department; }
}
Und eine Sammlung von Mitarbeitenden:
List<Employee> employees = List.of(
new Employee("Alisa", "IT"),
new Employee("Bob", "HR"),
new Employee("Klara", "IT"),
new Employee("Denis", "Finance"),
new Employee("Eva", "HR")
);
Nach Abteilung gruppieren:
Map<String, List<Employee>> byDepartment = employees.stream()
.collect(Collectors.groupingBy(Employee::getDepartment));
Was erhalten wir?
- Schlüssel: Name der Abteilung (String).
- Wert: Liste der Mitarbeitenden dieser Abteilung (List<Employee>).
Ergebnis drucken:
byDepartment.forEach((dept, emps) -> {
System.out.println(dept + ": " +
emps.stream().map(Employee::getName).toList());
});
Ausgabe:
IT: [Alisa, Klara]
HR: [Bob, Eva]
Finance: [Denis]
Wie funktioniert das unter der Haube?
Zunächst wird für jedes Element im Stream der Schlüssel berechnet (z. B. getDepartment()). Existiert dieser Schlüssel bereits in der Map, wird das Element der entsprechenden Liste hinzugefügt. Wenn nicht, wird eine neue Liste angelegt.
Analogie
Stellen Sie sich vor, Sie sortieren Briefe in Ordner: Für jeden Brief prüfen Sie, ob es schon einen Ordner mit dem passenden Namen gibt; wenn nicht, legen Sie einen neuen an und legen den Brief dort ab.
3. Collector partitioningBy: Aufteilung in zwei Gruppen
Manchmal möchte man nicht „nach Wert gruppieren“, sondern eine Sammlung schlicht anhand eines logischen Kriteriums (true/false) in zwei Teile aufteilen. Zum Beispiel Mitarbeitende mit Gehalt über bzw. unter einer bestimmten Schwelle, Studierende – „bestanden/nicht bestanden“.
Dafür gibt es den speziellen Collector – partitioningBy.
Signatur:
Collectors.partitioningBy(Predicate<T>)
Predicate – eine Funktion, die einen booleschen Wert zurückgibt.
Beispiel: Mitarbeitende nach Gehaltsniveau aufteilen
Angenommen, wir haben:
public class Employee {
private final String name;
private final int salary;
// ... Konstruktor und Getter
public Employee(String name, int salary) {
this.name = name;
this.salary = salary;
}
public String getName() { return name; }
public int getSalary() { return salary; }
}
Und eine Liste:
List<Employee> employees = List.of(
new Employee("Alisa", 120_000),
new Employee("Bob", 80_000),
new Employee("Klara", 150_000),
new Employee("Denis", 95_000)
);
Aufteilen in „Besserverdienende“ und „Andere“:
Map<Boolean, List<Employee>> partitioned = employees.stream()
.collect(Collectors.partitioningBy(e -> e.getSalary() > 100_000));
- true – Mitarbeitende mit Gehalt über 100_000.
- false – die übrigen.
Ausgabe:
System.out.println("Besserverdienende: " +
partitioned.get(true).stream().map(Employee::getName).toList());
System.out.println("Andere: " +
partitioned.get(false).stream().map(Employee::getName).toList());
Ergebnis:
Besserverdienende: [Alisa, Klara]
Andere: [Bob, Denis]
Wann partitioningBy und wann groupingBy verwenden?
- Wenn es mehr als zwei Gruppen gibt – verwenden Sie groupingBy.
- Wenn es genau zwei Gruppen nach einem booleschen Kriterium gibt – verwenden Sie partitioningBy: das ist schneller und verständlicher.
4. Verschachtelte Gruppierungen: nach mehreren Merkmalen
Manchmal möchte man nicht nur nach einem Merkmal gruppieren, sondern eine Gruppierung in eine andere „verschachteln“. Zum Beispiel Mitarbeitende zunächst nach Abteilung und innerhalb der Abteilung nach Position.
Beispiel:
Angenommen, unsere Klasse Employee hat noch ein Feld position:
public class Employee {
private final String name;
private final String department;
private final String position;
// ... Konstruktor und Getter
}
Verschachtelte Gruppierung:
Map<String, Map<String, List<Employee>>> byDeptAndPosition = employees.stream()
.collect(Collectors.groupingBy(Employee::getDepartment,
Collectors.groupingBy(Employee::getPosition)));
- Äußerer Schlüssel – Abteilung.
- Innerer Schlüssel – Position.
- Wert – Liste der Mitarbeitenden.
Wie greift man auf Mitarbeitende der IT-Abteilung mit der Position „Developer“ zu?
List<Employee> itDevs = byDeptAndPosition
.getOrDefault("IT", Map.of())
.getOrDefault("Developer", List.of());
Visualisierung
Map<Department, Map<Position, List<Employee>>>
└─ "IT"
├─ "Developer" -> [Alisa, Klara]
└─ "QA" -> [Boris]
└─ "HR"
└─ "Recruiter" -> [Denis]
5. Praxisbeispiele der Gruppierung
Beispiel 1: Strings nach Länge gruppieren
List<String> words = List.of("cat", "dog", "elephant", "bee", "ant", "dolphin");
Map<Integer, List<String>> byLength = words.stream()
.collect(Collectors.groupingBy(String::length));
byLength.forEach((len, ws) -> System.out.println(len + ": " + ws));
Ausgabe:
3: [cat, dog, bee, ant]
8: [elephant, dolphin]
Beispiel 2: Zahlen nach Parität gruppieren
List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6);
Map<String, List<Integer>> byParity = numbers.stream()
.collect(Collectors.groupingBy(n -> n % 2 == 0 ? "even" : "odd"));
System.out.println(byParity);
// {odd=[1, 3, 5], even=[2, 4, 6]}
Beispiel 3: partitioningBy für Strings
Teilen wir die Strings in die mit „A“ beginnenden und alle übrigen:
List<String> names = List.of("Alice", "Bob", "Anna", "Charlie");
Map<Boolean, List<String>> byA = names.stream()
.collect(Collectors.partitioningBy(s -> s.startsWith("A")));
System.out.println("A-names: " + byA.get(true)); // [Alice, Anna]
System.out.println("Other: " + byA.get(false)); // [Bob, Charlie]
5. Nützliche Details
Wie man das Gruppierungsergebnis nutzt
Oft möchte man nach der Gruppierung nicht nur eine Map erhalten, sondern diese noch weiterverarbeiten:
- Alle Gruppen durchlaufen und Informationen ausgeben.
- Die Gruppe mit der größten Elementanzahl finden.
- Für jede Gruppe z. B. Summe oder Durchschnitt berechnen – dazu mehr in der nächsten Vorlesung.
Beispiel: Anzahl der Mitarbeitenden in jeder Abteilung ausgeben
byDepartment.forEach((dept, emps) ->
System.out.println(dept + ": " + emps.size() + " Mitarbeitende"));
Vergleich mit der manuellen Umsetzung
Zur Festigung: So sähe die Gruppierung nach Abteilung „auf die alte Tour“ aus:
Map<String, List<Employee>> byDepartment = new HashMap<>();
for (Employee e : employees) {
String dept = e.getDepartment();
byDepartment.computeIfAbsent(dept, k -> new ArrayList<>()).add(e);
}
Und so mit Stream API:
Map<String, List<Employee>> byDepartment = employees.stream()
.collect(Collectors.groupingBy(Employee::getDepartment));
Fazit: weniger Code, weniger Fehler, bessere Lesbarkeit.
Tipps
Wenn Sie nicht in eine List, sondern z. B. in ein Set gruppieren möchten – verwenden Sie als zweiten Parameter Collectors.toSet():
Collectors.groupingBy(Employee::getDepartment, Collectors.toSet())
Es lässt sich auch direkt aggregieren: zum Beispiel die Anzahl der Mitarbeitenden in jeder Abteilung erhalten:
Collectors.groupingBy(Employee::getDepartment, Collectors.counting())
Doch dazu mehr in der nächsten Vorlesung!
Nach partitioningBy gibt es immer zwei Schlüssel: true und false. Selbst wenn eine der Gruppen leer ist.
Nach verschachtelten Gruppierungen wird die Struktur zu einem „Baum“: Map innerhalb einer Map usw.
6. Typische Fehler bei Gruppierung und Partitioning
Fehler Nr. 1: Falscher Ergebnistyp. Anfänger erwarten oft, dass das Ergebnis von groupingBy einfach eine List<T> ist und nicht eine Map<K, List<T>>. Infolgedessen rufen sie Listenmethoden auf und erhalten einen Kompilierfehler. Merken Sie sich: Gruppierung liefert immer eine Map!
Fehler Nr. 2: NullPointerException beim Zugriff auf eine nicht vorhandene Gruppe. Wenn Sie versuchen, die Liste zu einem nicht existierenden Schlüssel zu holen, erhalten Sie null. Verwenden Sie getOrDefault(key, List.of()) oder prüfen Sie die Existenz des Schlüssels mit containsKey.
Fehler Nr. 3: partitioningBy für Aufgaben mit mehreren Gruppen verwenden. partitioningBy ist nur für zwei Gruppen (true/false). Wenn es mehr Gruppen gibt – verwenden Sie groupingBy.
Fehler Nr. 4: Kollektionen innerhalb eines Streams modifizieren. Versuchen Sie nicht, die Ausgangskollektionen oder die Map innerhalb des Streams zu verändern – das führt zu unerwarteten Fehlern. Führen Sie die gesamte Verarbeitung über die Stream API und die Collector durch.
Fehler Nr. 5: Nicht offensichtliche Struktur verschachtelter Gruppierungen. Nach einem verschachtelten groupingBy ist das Ergebnis eine Map innerhalb einer Map. Vergessen Sie nicht, die Daten korrekt zu entnehmen (z. B. über getOrDefault), sonst erhalten Sie eine ClassCastException.
GO TO FULL VERSION