1. Вкладений groupingBy: синтаксис і принцип роботи
У житті рідко достатньо згрупувати дані лише за однією ознакою. Наприклад, якщо у вас є список працівників компанії, часто хочеться дізнатися не просто, скільки людей у кожному відділі, а й скільки в кожному відділі на кожній посаді. Або, якщо у вас є база студентів, — то скільки студентів на кожному курсі за кожною спеціальністю.
Вкладені групування дозволяють будувати такі «мапи в мапах» — наприклад, структури «відділ → посада → список працівників» або «курс → спеціальність → список студентів».
Без Stream API такі завдання вирішувалися б кількома вкладеними циклами та ручною побудовою Map всередині Map. З потоками та колекторами це робиться у один‑два рядки.
Вкладене групування — це коли другим аргументом методу Collectors.groupingBy ви передаєте ще один колектор, наприклад, ще один groupingBy. У результаті ви отримуєте мапу, де значенням для кожного ключа є ще одна мапа.
Загальний шаблон
Map<Ключ1, Map<Ключ2, List<T>>> result =
stream.collect(Collectors.groupingBy(
объект -> ключ1,
Collectors.groupingBy(объект -> ключ2)
));
Приклад: працівники за відділом і посадою
Припустімо, у нас є клас:
class Employee {
private String name;
private String department;
private String position;
private int salary;
// ... конструктори, геттери, toString
}
Список працівників:
List<Employee> employees = List.of(
new Employee("Іван", "ІТ", "Розробник", 120_000),
new Employee("Марія", "ІТ", "Тестувальник", 90_000),
new Employee("Петро", "HR", "Менеджер", 80_000),
new Employee("Ольга", "ІТ", "Розробник", 130_000),
new Employee("Світлана", "HR", "Рекрутер", 70_000)
);
Групуємо за відділом і посадою:
Map<String, Map<String, List<Employee>>> grouped = employees.stream()
.collect(Collectors.groupingBy(
Employee::getDepartment,
Collectors.groupingBy(Employee::getPosition)
));
Що отримали?
Мапа, де ключ — відділ, значення — мапа (ключ — посада, значення — список працівників).
Візуалізація структури
ІТ:
Розробник: [Іван, Ольга]
Тестувальник: [Марія]
HR:
Менеджер: [Петро]
Рекрутер: [Світлана]
2. Групування з агрегуванням: комбінуємо groupingBy і агрегатори
Вкладені групування не обмежуються лише збиранням списків! Можна одразу агрегувати дані всередині кожної підгрупи.
Приклад: максимальна зарплата за відділом
Map<String, Optional<Employee>> maxSalaryByDept = employees.stream()
.collect(Collectors.groupingBy(
Employee::getDepartment,
Collectors.maxBy(Comparator.comparingInt(Employee::getSalary))
));
Тут для кожного відділу ми отримуємо працівника з максимальною зарплатою (результат загорнуто в Optional, оскільки відділ може виявитися порожнім).
Вкладене групування + агрегування
Припустімо, хочемо дізнатися максимальну зарплату за кожною посадою в кожному відділі:
Map<String, Map<String, Optional<Employee>>> maxSalaryByDeptAndPos = employees.stream()
.collect(Collectors.groupingBy(
Employee::getDepartment,
Collectors.groupingBy(
Employee::getPosition,
Collectors.maxBy(Comparator.comparingInt(Employee::getSalary))
)
));
Що це означає?
- Для кожного відділу — мапа посад.
- Для кожної посади — працівник із максимальною зарплатою (або порожній Optional, якщо нікого немає).
3. Групування з перетворенням: mapping усередині groupingBy
Іноді потрібно не просто згрупувати обʼєкти, а отримати лише певні поля всередині груп.
Приклад: імена працівників за відділами
Map<String, List<String>> namesByDept = employees.stream()
.collect(Collectors.groupingBy(
Employee::getDepartment,
Collectors.mapping(Employee::getName, Collectors.toList())
));
Результат:
ІТ: [Іван, Марія, Ольга]
HR: [Петро, Світлана]
Вкладене mapping
Можна комбінувати mapping із вкладеним 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())
)
));
Результат:
ІТ:
Розробник: [Іван, Ольга]
Тестувальник: [Марія]
HR:
Менеджер: [Петро]
Рекрутер: [Світлана]
4. Групування + агрегування числових даних
Часто потрібно не просто згрупувати, а порахувати суму, середнє або кількість за групами.
Приклад: середня зарплата за відділом
Map<String, Double> avgSalaryByDept = employees.stream()
.collect(Collectors.groupingBy(
Employee::getDepartment,
Collectors.averagingInt(Employee::getSalary)
));
Вкладене агрегування
Середня зарплата за посадою в кожному відділі:
Map<String, Map<String, Double>> avgSalaryByDeptAndPos = employees.stream()
.collect(Collectors.groupingBy(
Employee::getDepartment,
Collectors.groupingBy(
Employee::getPosition,
Collectors.averagingInt(Employee::getSalary)
)
));
5. partitioningBy + агрегування
Іноді зручно ділити колекцію на дві групи за булевою ознакою, а всередині — теж агрегувати.
Приклад: скільки працівників із зарплатою понад 100_000 у кожному відділі
Map<String, Map<Boolean, Long>> countByDeptAndSalary = employees.stream()
.collect(Collectors.groupingBy(
Employee::getDepartment,
Collectors.partitioningBy(
e -> e.getSalary() > 100_000,
Collectors.counting()
)
));
Результат:
Для кожного відділу — мапа: true/false → кількість працівників.
6. Практичні завдання: застосовуємо вкладені групування
Завдання 1. Студенти за курсом і спеціальністю
class Student {
private String name;
private int course;
private String speciality;
private double grade;
// ... геттери, конструктор
}
List<Student> students = ... // припустімо, уже є
Map<Integer, Map<String, List<Student>>> byCourseAndSpec = students.stream()
.collect(Collectors.groupingBy(
Student::getCourse,
Collectors.groupingBy(Student::getSpeciality)
));
Завдання 2. Середній бал за курсом
Map<Integer, Double> avgGradeByCourse = students.stream()
.collect(Collectors.groupingBy(
Student::getCourse,
Collectors.averagingDouble(Student::getGrade)
));
Завдання 3. Лише імена за групами
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. Корисні нюанси
Як читати та витягувати дані з вкладених Map
Працювати з вкладеними мапами спочатку може бути незвично. Ось базовий приклад:
for (var deptEntry : grouped.entrySet()) {
String dept = deptEntry.getKey();
Map<String, List<Employee>> byPosition = deptEntry.getValue();
System.out.println("Відділ: " + dept);
for (var posEntry : byPosition.entrySet()) {
String pos = posEntry.getKey();
List<Employee> emps = posEntry.getValue();
System.out.println(" Посада: " + pos + " -> " + emps);
}
}
Схема вкладеного групування
Map<Відділ, Map<Посада, List<Employee>>>
│
├── "ІТ"
│ ├── "Розробник" → [Іван, Ольга]
│ └── "Тестувальник" → [Марія]
└── "HR"
├── "Менеджер" → [Петро]
└── "Рекрутер" → [Світлана]
Таблиця: що отримаємо після різних комбінацій
| Колектор | Результат |
|---|---|
|
|
|
|
|
|
|
|
|
|
8. Типові помилки під час роботи з вкладеними групуваннями
Помилка № 1: Хибне розуміння структури вкладених Map.
Після вкладених групувань легко заплутатися, що саме лежить у значенні кожної мапи. Завжди дивіться на сигнатуру результату — IDE підкаже тип. Якщо не впевнені, виведіть результат на екран за допомогою System.out.println(grouped) або скористайтеся зневаджувачем.
Помилка № 2: NullPointerException під час витягування даних.
Якщо ключа немає (наприклад, у відділі немає працівників певної посади), Map.get(key) поверне null. Перевіряйте наявність ключа через containsKey або, починаючи з Java 8, використовуйте Map.getOrDefault, а також Optional.ofNullable та методи Optional.
Помилка № 3: Надто складні вкладення.
Якщо групування стає надто глибоким (3–4 рівні вкладеності), можливо, варто переглянути структуру даних або розбити завдання на дрібніші етапи.
Помилка № 4: Агрегування не на тому рівні.
Іноді помилково розміщують агрегувальний колектор (averagingInt, counting) не всередині потрібного groupingBy, а зовні — і отримують неочікуваний результат. Завжди уважно розставляйте дужки!
Помилка № 5: Спроба змінювати елементи всередині collect.
Не змінюйте вихідні колекції або обʼєкти в процесі групування — це може призвести до важковловимих помилок.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ