JavaRush /Курси /JAVA 25 SELF /Методи groupingBy та partitioningBy (Collectors)

Методи groupingBy та partitioningBy (Collectors)

JAVA 25 SELF
Рівень 31 , Лекція 2
Відкрита

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.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ