CodeGym /Kursy /JAVA 25 SELF /Zaawansowana agregacja: zagnieżdżone grupowania

Zaawansowana agregacja: zagnieżdżone grupowania

JAVA 25 SELF
Poziom 31 , Lekcja 3
Dostępny

1. Zagnieżdżone groupingBy: składnia i zasada działania

W praktyce rzadko wystarcza pogrupować dane tylko według jednego kryterium. Na przykład, jeśli masz listę pracowników firmy, to często chcesz wiedzieć nie tylko, ile osób jest w każdym dziale, ale ile jest w każdym dziale na każdym stanowisku. Albo, jeśli masz bazę studentów — ilu studentów jest na każdym roku w każdej specjalności.

Zagnieżdżone grupowania pozwalają budować takie „mapy w mapach” — strukturę „dział → stanowisko → lista pracowników” lub „rok → specjalność → lista studentów”.

Bez Stream API takie zadania byłyby rozwiązywane kilkoma zagnieżdżonymi pętlami i ręczną budową Map wewnątrz Map. Z strumieniami i kolektorami robi się to w jednym–dwóch wierszach.

Zagnieżdżone grupowanie polega na tym, że jako drugi argument metody Collectors.groupingBy przekazujesz kolejny kolektor, na przykład kolejne groupingBy. W rezultacie otrzymujesz mapę, w której wartością dla każdego klucza będzie kolejna mapa.

Ogólny wzorzec

Map<Klucz1, Map<Klucz2, List<T>>> result = 
    stream.collect(Collectors.groupingBy(
        obiekt -> klucz1,
        Collectors.groupingBy(obiekt -> klucz2)
    ));

Przykład: pracownicy według działu i stanowiska

Załóżmy, że mamy klasę:

class Employee {
    private String name;
    private String department;
    private String position;
    private int salary;
    // ... konstruktory, gettery, toString
}

Lista pracowników:

List<Employee> employees = List.of(
    new Employee("Ivan", "IT", "Programista", 120_000),
    new Employee("Mariya", "IT", "Tester", 90_000),
    new Employee("Pyotr", "HR", "Menedżer", 80_000),
    new Employee("Olga", "IT", "Programista", 130_000),
    new Employee("Svetlana", "HR", "Rekruter", 70_000)
);

Grupujemy według działu i stanowiska:

Map<String, Map<String, List<Employee>>> grouped = employees.stream()
    .collect(Collectors.groupingBy(
        Employee::getDepartment,
        Collectors.groupingBy(Employee::getPosition)
    ));

Co otrzymaliśmy?
Mapa, w której kluczem jest dział, a wartością — mapa (kluczem jest stanowisko, wartością — lista pracowników).

Wizualizacja struktury

IT:
  Programista: [Ivan, Olga]
  Tester: [Mariya]
HR:
  Menedżer: [Pyotr]
  Rekruter: [Svetlana]

2. Grupowanie z agregacją: łączymy groupingBy i agregatory

Zagnieżdżone grupowania nie ograniczają się do zbierania list! Można od razu agregować dane wewnątrz każdej podgrupy.

Przykład: maksymalne wynagrodzenie w każdym dziale

Map<String, Optional<Employee>> maxSalaryByDept = employees.stream()
    .collect(Collectors.groupingBy(
        Employee::getDepartment,
        Collectors.maxBy(Comparator.comparingInt(Employee::getSalary))
    ));

Tutaj dla każdego działu otrzymujemy pracownika z maksymalnym wynagrodzeniem (wynik jest opakowany w Optional, ponieważ dział może okazać się pusty).

Zagnieżdżone grupowanie + agregacja

Załóżmy, że chcemy znać maksymalne wynagrodzenie na każdym stanowisku w każdym dziale:

Map<String, Map<String, Optional<Employee>>> maxSalaryByDeptAndPos = employees.stream()
    .collect(Collectors.groupingBy(
        Employee::getDepartment,
        Collectors.groupingBy(
            Employee::getPosition,
            Collectors.maxBy(Comparator.comparingInt(Employee::getSalary))
        )
    ));

Co to oznacza?

  • Dla każdego działu — mapa stanowisk.
  • Dla każdego stanowiska — pracownik z maksymalnym wynagrodzeniem (albo pusty Optional, jeśli nikogo nie ma).

3. Grupowanie z przekształceniem: mapping wewnątrz groupingBy

Czasem trzeba nie tylko pogrupować obiekty, ale uzyskać w grupach wyłącznie określone pola.

Przykład: imiona pracowników w działach

Map<String, List<String>> namesByDept = employees.stream()
    .collect(Collectors.groupingBy(
        Employee::getDepartment,
        Collectors.mapping(Employee::getName, Collectors.toList())
    ));

Wynik:

IT: [Ivan, Mariya, Olga]
HR: [Pyotr, Svetlana]

Zagnieżdżone mapping

Można łączyć mapping z zagnieżdżonym groupingBy:

Map<String, Map<String, List<String>>> namesByDeptAndPos = employees.stream()
    .collect(Collectors.groupingBy(
        Employee::getDepartment,
        Collectors.groupingBy(
            Employee::getPosition,
            Collectors.mapping(Employee::getName, Collectors.toList())
        )
    ));

Wynik:

IT:
  Programista: [Ivan, Olga]
  Tester: [Mariya]
HR:
  Menedżer: [Pyotr]
  Rekruter: [Svetlana]

4. Grupowanie + agregacja danych liczbowych

Często potrzebne jest nie tylko grupowanie, ale też policzenie sumy, średniej lub liczności w grupach.

Przykład: średnie wynagrodzenie w dziale

Map<String, Double> avgSalaryByDept = employees.stream()
    .collect(Collectors.groupingBy(
        Employee::getDepartment,
        Collectors.averagingInt(Employee::getSalary)
    ));

Zagnieżdżona agregacja

Średnie wynagrodzenie na stanowisku w każdym dziale:

Map<String, Map<String, Double>> avgSalaryByDeptAndPos = employees.stream()
    .collect(Collectors.groupingBy(
        Employee::getDepartment,
        Collectors.groupingBy(
            Employee::getPosition,
            Collectors.averagingInt(Employee::getSalary)
        )
    ));

5. partitioningBy + agregacja

Czasem wygodnie jest podzielić kolekcję na dwie grupy według predykatu boolowskiego, a w środku — także agregować.

Przykład: ilu pracowników z wynagrodzeniem powyżej 100_000 w każdym dziale

Map<String, Map<Boolean, Long>> countByDeptAndSalary = employees.stream()
    .collect(Collectors.groupingBy(
        Employee::getDepartment,
        Collectors.partitioningBy(
            e -> e.getSalary() > 100_000,
            Collectors.counting()
        )
    ));

Wynik:
Dla każdego działu — mapa: true/false → liczba pracowników.

6. Zadania praktyczne: stosujemy zagnieżdżone grupowania

Zadanie 1. Studenci według roku i specjalności

class Student {
    private String name;
    private int course;
    private String speciality;
    private double grade;
    // ... gettery, konstruktor
}

List<Student> students = ... // załóżmy, że już jest

Map<Integer, Map<String, List<Student>>> byCourseAndSpec = students.stream()
    .collect(Collectors.groupingBy(
        Student::getCourse,
        Collectors.groupingBy(Student::getSpeciality)
    ));

Zadanie 2. Średnia ocena na roku

Map<Integer, Double> avgGradeByCourse = students.stream()
    .collect(Collectors.groupingBy(
        Student::getCourse,
        Collectors.averagingDouble(Student::getGrade)
    ));

Zadanie 3. Tylko imiona w grupach

Map<Integer, Map<String, List<String>>> namesByCourseAndSpec = students.stream()
    .collect(Collectors.groupingBy(
        Student::getCourse,
        Collectors.groupingBy(
            Student::getSpeciality,
            Collectors.mapping(Student::getName, Collectors.toList())
        )
    ));

7. Przydatne niuanse

Jak czytać i wyciągać dane z zagnieżdżonych Map

Praca z zagnieżdżonymi mapami na początku może być nietypowa. Oto podstawowy przykład:

for (var deptEntry : grouped.entrySet()) {
    String dept = deptEntry.getKey();
    Map<String, List<Employee>> byPosition = deptEntry.getValue();
    System.out.println("Dział: " + dept);
    for (var posEntry : byPosition.entrySet()) {
        String pos = posEntry.getKey();
        List<Employee> emps = posEntry.getValue();
        System.out.println("  Stanowisko: " + pos + " -> " + emps);
    }
}

Schemat zagnieżdżonego grupowania

Map<Dział, Map<Stanowisko, List<Employee>>>
      │
      ├── "IT"
      │      ├── "Programista" → [Ivan, Olga]
      │      └── "Tester"      → [Mariya]
      └── "HR"
             ├── "Menedżer"    → [Pyotr]
             └── "Rekruter"    → [Svetlana]

Tabela: co otrzymamy po różnych kombinacjach

Kolektor Wynik
groupingBy(Employee::getDepartment)
Map<String, List<Employee>>
groupingBy(Employee::getDepartment, averagingInt(...))
Map<String, Double>
groupingBy(Employee::getDepartment, groupingBy(...))
Map<String, Map<String, List<Employee>>>
groupingBy(..., mapping(..., toList()))
Map<..., List<...>>
groupingBy(..., groupingBy(..., mapping(..., toList())))
Map<..., Map<..., List<...>>>

8. Typowe błędy przy pracy z zagnieżdżonymi grupowaniami

Błąd nr 1: Nieprawidłowe rozumienie struktury zagnieżdżonych Map.
Po zagnieżdżonych grupowaniach łatwo się pogubić, co dokładnie leży w wartości każdej mapy. Zawsze patrz na sygnaturę wyniku — IDE podpowie typ. Jeśli nie masz pewności, wypisz wynik na ekran za pomocą System.out.println(grouped) lub użyj debuggera.

Błąd nr 2: NullPointerException przy pobieraniu danych.
Jeśli klucza nie ma (np. w dziale nie ma pracowników danego stanowiska), Map.get(key) zwróci null. Sprawdzaj obecność klucza przez containsKey lub, od Java 8, możesz użyć Map.getOrDefault, a od Java 9 — Map.ofNullable i metod Optional.

Błąd nr 3: Zbyt głębokie zagnieżdżenia.
Jeśli grupowanie staje się zbyt głębokie (3–4 poziomy zagnieżdżenia), być może warto przemyśleć strukturę danych lub podzielić zadanie na mniejsze etapy.

Błąd nr 4: Agregacja na niewłaściwym poziomie.
Czasem błędnie umieszcza się agregujący kolektor (averagingInt, counting) nie wewnątrz właściwego groupingBy, lecz na zewnątrz — i pojawia się nieoczekiwany wynik. Zawsze uważnie stawiaj nawiasy!

Błąd nr 5: Próba modyfikacji elementów podczas collect.
Nie modyfikuj źródłowych kolekcji ani obiektów w trakcie grupowania — może to prowadzić do trudnych do wykrycia błędów.

Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION