1. Вступ
У реальному житті майже завжди доводиться групувати дані. Наприклад, розділити студентів за курсами, розбити товари за категоріями, відокремити повнолітніх від дітей тощо.
Без Stream API такі завдання розв’язували вручну: перебирання колекції, перевірка ознаки, додавання до потрібного списку у Map. Ось типовий «по‑старому» код:
Map<String, List<Employee>> byDepartment = new HashMap<>();
for (Employee employee : employees) {
String dept = employee.getDepartment();
byDepartment.computeIfAbsent(dept, k -> new ArrayList<>()).add(employee);
}
Працює, але виглядає так, ніби ви вручну розкладаєте макарони по банках. А якщо потрібно згрупувати за кількома ознаками? Або ще й підрахувати суми у групах? Код розростається й стає нечитаємим.
Stream API і спеціальні колектори (groupingBy, partitioningBy) дають змогу зробити це в один‑два рядки — і при цьому виглядати як справжній Java‑чарівник.
2. Колектор groupingBy: групування за ознакою
Основна ідея
groupingBy — це колектор, який перетворює потік елементів на Map, де ключ — результат функції‑ознаки, а значення — список елементів, що відповідають цій ознаці.
Сигнатура:
Collectors.groupingBy(Function<T, K>)
- T — тип елемента в потоці,
- K — тип ключа (групи), який повертає функція.
Простий приклад: групування співробітників за відділом
Припустімо, у нас є клас:
public class Employee {
private final String name;
private final String department;
// ... конструктор і гетери
public Employee(String name, String department) {
this.name = name;
this.department = department;
}
public String getName() { return name; }
public String getDepartment() { return department; }
}
І колекція співробітників:
List<Employee> employees = List.of(
new Employee("Аліса", "IT"),
new Employee("Боб", "HR"),
new Employee("Клара", "IT"),
new Employee("Денис", "Finance"),
new Employee("Єва", "HR")
);
Групуємо за відділом:
Map<String, List<Employee>> byDepartment = employees.stream()
.collect(Collectors.groupingBy(Employee::getDepartment));
Що отримали?
- Ключ: назва відділу (String).
- Значення: список співробітників цього відділу (List<Employee>).
Виводимо результат:
byDepartment.forEach((dept, emps) -> {
System.out.println(dept + ": " +
emps.stream().map(Employee::getName).toList());
});
Виведення:
IT: [Аліса, Клара]
HR: [Боб, Єва]
Finance: [Денис]
Як це працює під капотом?
Спочатку для кожного елемента потоку обчислюється ключ (наприклад, getDepartment()). Якщо такий ключ уже є в Map, елемент додається до відповідного списку. Якщо ключа немає — створюється новий список.
Аналогія
Уявіть, що ви сортуєте листи по папках: для кожного листа дивитеся, чи вже є папка з потрібною назвою, якщо ні — заводите нову й кладете лист туди.
3. Колектор partitioningBy: поділ на дві групи
Іноді потрібно не стільки «групувати за значенням», скільки просто розбити колекцію на дві частини за логічною ознакою (true/false). Наприклад, співробітників із зарплатою вище чи нижче певного порога, студентів — на «склав/не склав».
Для цього є особливий колектор — partitioningBy.
Сигнатура:
Collectors.partitioningBy(Predicate<T>)
Predicate — функція, що повертає булеве значення.
Приклад: поділ співробітників за рівнем зарплати
Припустімо, у нас є:
public class Employee {
private final String name;
private final int salary;
// ... конструктор і гетери
public Employee(String name, int salary) {
this.name = name;
this.salary = salary;
}
public String getName() { return name; }
public int getSalary() { return salary; }
}
І список:
List<Employee> employees = List.of(
new Employee("Аліса", 120_000),
new Employee("Боб", 80_000),
new Employee("Клара", 150_000),
new Employee("Денис", 95_000)
);
Розділимо на «заможних» і «скромних»:
Map<Boolean, List<Employee>> partitioned = employees.stream()
.collect(Collectors.partitioningBy(e -> e.getSalary() > 100_000));
- true — співробітники із зарплатою більше 100_000.
- false — інші.
Друкуємо:
System.out.println("Заможні: " +
partitioned.get(true).stream().map(Employee::getName).toList());
System.out.println("Скромні: " +
partitioned.get(false).stream().map(Employee::getName).toList());
Виведення:
Заможні: [Аліса, Клара]
Скромні: [Боб, Денис]
Коли використовувати partitioningBy, а коли groupingBy?
- Якщо груп більше, ніж дві — використовуйте groupingBy.
- Якщо лише дві групи за булевою ознакою — використовуйте partitioningBy: це швидше й зрозуміліше.
4. Вкладені групування: групування за кількома ознаками
Іноді потрібно згрупувати не лише за однією ознакою, а й «вкласти» одне групування в інше. Наприклад, згрупувати співробітників спочатку за відділом, а всередині відділу — за посадою.
Приклад:
Припустімо, у нашому класі Employee є ще поле position:
public class Employee {
private final String name;
private final String department;
private final String position;
// ... конструктор і гетери
}
Вкладене групування:
Map<String, Map<String, List<Employee>>> byDeptAndPosition = employees.stream()
.collect(Collectors.groupingBy(Employee::getDepartment,
Collectors.groupingBy(Employee::getPosition)));
- Зовнішній ключ — відділ.
- Внутрішній ключ — посада.
- Значення — список співробітників.
Як звернутися до співробітників із IT‑відділу, які обіймають посаду «Developer»?
List<Employee> itDevs = byDeptAndPosition
.getOrDefault("IT", Map.of())
.getOrDefault("Developer", List.of());
Візуальна схема
Map<Department, Map<Position, List<Employee>>>
└─ "IT"
├─ "Developer" -> [Аліса, Клара]
└─ "QA" -> [Борис]
└─ "HR"
└─ "Recruiter" -> [Денис]
5. Практичні приклади групування
Приклад 1: Групування рядків за довжиною
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));
Виведення:
3: [cat, dog, bee, ant]
7: [dolphin]
8: [elephant]
Приклад 2: Групування чисел за парністю
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]}
Приклад 3: Використання partitioningBy для рядків
Розділимо рядки на ті, що починаються з «A», і всі інші:
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: " + byA.get(true)); // [Alice, Anna]
System.out.println("Інші: " + byA.get(false)); // [Bob, Charlie]
6. Корисні нюанси
Як використовувати результат групування
Часто після групування хочеться не просто отримати Map, а якось обробити її:
- Перебрати всі групи та вивести інформацію.
- Знайти групу з максимальною кількістю елементів.
- Для кожної групи порахувати, наприклад, суму чи середнє — про це докладніше у наступній лекції.
Приклад: вивести кількість співробітників у кожному відділі
byDepartment.forEach((dept, emps) ->
System.out.println(dept + ": " + emps.size() + " співробітників"));
Порівняння з ручною реалізацією
Для закріплення: ось як виглядало б групування за відділом «по‑старому»:
Map<String, List<Employee>> byDepartment = new HashMap<>();
for (Employee e : employees) {
String dept = e.getDepartment();
byDepartment.computeIfAbsent(dept, k -> new ArrayList<>()).add(e);
}
А ось за допомогою Stream API:
Map<String, List<Employee>> byDepartment = employees.stream()
.collect(Collectors.groupingBy(Employee::getDepartment));
Висновок: менше коду, менше помилок, читабельність вища.
Поради
Якщо потрібно згрупувати не в List, а, наприклад, у Set — використовуйте другим параметром Collectors.toSet():
Collectors.groupingBy(Employee::getDepartment, Collectors.toSet())
Можна одразу агрегувати: наприклад, отримати кількість співробітників у кожному відділі:
Collectors.groupingBy(Employee::getDepartment, Collectors.counting())
Але про це — у наступній лекції!
Після partitioningBy завжди будуть два ключі: true і false. Навіть якщо одна з груп порожня.
Після вкладених групувань структура стає «деревом»: Map всередині Map і так далі.
7. Типові помилки під час групування та partitioningBy
Помилка № 1: Неправильний тип результату. Початківці часто очікують, що результат groupingBy — це просто List<T>, а не Map<K, List<T>>. У результаті намагаються викликати методи списку й отримують помилку компіляції. Пам’ятайте: групування — це завжди Map!
Помилка № 2: NullPointerException під час звернення до неіснуючої групи. Якщо ви намагаєтеся отримати список за неіснуючим ключем — отримаєте null. Використовуйте getOrDefault(key, List.of()) або перевіряйте наявність ключа через containsKey.
Помилка № 3: Використання partitioningBy для завдань із кількома групами. partitioningBy — тільки для двох груп (true/false). Якщо груп більше — використовуйте groupingBy.
Помилка № 4: Модифікація колекцій усередині потоку. Не намагайтеся змінювати вихідні колекції або Map усередині потоку — це призведе до неочікуваних помилок. Усю обробку виконуйте через Stream API та колектори.
Помилка № 5: Неочевидна структура вкладених групувань. Після вкладеного groupingBy результат — це Map всередині Map. Не забудьте коректно витягати дані (наприклад, через getOrDefault), інакше отримаєте ClassCastException.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ