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, а с Java 9 — Map.ofNullable и методы Optional.
Ошибка №3: Слишком сложные вложения.
Если группировка становится слишком глубокой (3–4 уровня вложенности), возможно, стоит пересмотреть структуру данных или разбить задачу на более мелкие этапы.
Ошибка №4: Агрегирование не того уровня.
Иногда ошибочно помещают агрегирующий коллектор (averagingInt, counting) не внутрь нужного groupingBy, а снаружи — и получают неожиданный результат. Всегда внимательно расставляйте скобки!
Ошибка №5: Попытка изменить элементы внутри collect.
Не изменяйте исходные коллекции или объекты в процессе группировки — это может привести к трудноуловимым багам.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ