🌊 Розділ 8 · Питання #5

Що робить операція collect()

Вона реалізує патерн мутабельної редукції (Mutable Reduction): замість того щоб створювати новий незмінний об'єкт під час обробки кожного елемента, collect() послідовно накопичу...


🟢 Junior Level

Коротка відповідь для співбесіди (30 секунд)

collect() — це ключова термінальна операція Stream API, яка запускає виконання всього конвеєра обробки та упаковує елементи потоку в підсумкову структуру даних (колекцію List, Set, Map, рядок або зведену статистику).

Вона реалізує патерн мутабельної редукції (Mutable Reduction): замість того щоб створювати новий незмінний об’єкт під час обробки кожного елемента, collect() послідовно накопичує значення в єдиний змінний контейнер (наприклад, ArrayList, StringBuilder або HashMap). Після виклику collect() стрім вважається спожитим і закривається.


Базові приклади на Java 21

import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;

public class CollectBasicsDemo {

    record Item(String name, String category, double price) {}

    public static void main(String[] args) {
        List<Item> items = List.of(
            new Item("Laptop", "Electronics", 1200.0),
            new Item("Phone", "Electronics", 800.0),
            new Item("Book", "Stationery", 25.0)
        );

        // 1. Збирання в Set (усунення дублікатів)
        Set<String> categories = items.stream()
            .map(Item::category)
            .collect(Collectors.toSet());

        // 2. Об'єднання рядків із роздільником та обрамленням
        String titles = items.stream()
            .map(Item::name)
            .collect(Collectors.joining(", ", "Items: [", "]"));
        System.out.println(titles); // Items: [Laptop, Phone, Book]

        // 3. Групування (аналог SQL GROUP BY)
        Map<String, List<Item>> byCategory = items.stream()
            .collect(Collectors.groupingBy(Item::category));

        // 4. Сучасне збирання в незмінний список (Java 16+)
        List<String> names = items.stream()
            .map(Item::name)
            .toList(); // Альтернатива Collectors.toList()
    }
}

🟡 Middle Level

Дві форми виклику методу collect()

  1. З використанням готового Collector:
    <R, A> R collect(Collector<? super T, A, R> collector)
    
  2. Низькорівнева трикомпонентна форма (Three-argument overload):
    <R> R collect(Supplier<R> supplier, 
                  BiConsumer<R, ? super T> accumulator, 
                  BiConsumer<R, R> combiner)
    

    Приклад ручного збирання в StringBuilder:

    String result = list.stream().collect(
        StringBuilder::new,           // supplier: створюємо контейнер
        StringBuilder::append,        // accumulator: додаємо елемент
        StringBuilder::append         // combiner: об'єднуємо дві гілки в parallelStream
    ).toString();
    

Анатомія інтерфейсу Collector<T, A, R>

Інтерфейс параметризований трьома типами:

  • T — тип вхідних елементів стріма.
  • A — тип проміжного акумулятора (контейнера).
  • R — підсумковий тип результату.
                  ┌─── supplier(): () -> A
                  │    Створює новий порожній накопичувач (напр. ArrayList::new)
                  │
                  ├─── accumulator(): (A, T) -> void
                  │    Додає вхідний елемент у накопичувач (напр. List::add)
Collector<T,A,R> ─┤
                  ├─── combiner(): (A, A) -> A
                  │    Зливає два накопичувачі при паралельному обчисленні
                  │
                  ├─── finisher(): A -> R
                  │    Фінальне перетворення (напр. Collections::unmodifiableList)
                  │
                  └─── characteristics(): Set<Characteristics>
                       Прапорці оптимізації (IDENTITY_FINISH, CONCURRENT, UNORDERED)

Чому collect() ефективніший за reduce() для колекцій?

  • Немутабельна редукція (reduce): На кожному кроці конкатенує дані в новий об’єкт. Для списку це означає копіювання всіх раніше накопичених елементів: \(1 + 2 + 3 + \dots + N = O(N^2)\) Крім того, це створює мільйони проміжних масивів у купі (Heap), перевантажуючи GC.
  • Мутабельна редукція (collect): Модифікує один і той самий існуючий контейнер ArrayList викликом add(). Складність додавання амортизована: \(O(N)\)

Групування даних (groupingBy) та вкладені колектори

Метод Collectors.groupingBy — це один із найпотужніших інструментів у Java, що дозволяє створювати багаторівневі агрегації:

// Підрахунок кількості товарів у кожній категорії
Map<String, Long> countByCategory = items.stream()
    .collect(Collectors.groupingBy(
        Item::category, 
        Collectors.counting() // Downstream колектор
    ));

// Пошук найдорожчого товару в кожній категорії
Map<String, Optional<Item>> mostExpensiveByCategory = items.stream()
    .collect(Collectors.groupingBy(
        Item::category,
        Collectors.maxBy(Comparator.comparingDouble(Item::price))
    ));

🔴 Senior Level

Прапорці оптимізації Characteristics

Конвеєр використовує характеристики колектора для виключення зайвих фаз обробки:

  1. IDENTITY_FINISH: Вказує, що метод finisher() являє собою тотожну функцію Function.identity(). JVM пропускає фінальний крок і виконує прямий каст акумулятора до повертаного типу: (R) accumulator.
  2. CONCURRENT: Вказує, що накопичувач є потокобезпечним (наприклад, ConcurrentHashMap або ConcurrentLinkedQueue).
    • У паралельних стрімах (parallelStream) JVM НЕ створює окремі акумулятори для кожного потоку ядра і НЕ викликає combiner().
    • Усі паралельні потоки пишуть безпосередньо в один спільний розділюваний контейнер, що дає колосальний приріст продуктивності під час збирання в багатопотокових середовищах.
  3. UNORDERED: Вказує, що операція збирання не зберігає порядок елементів джерела (прискорює паралельну обробку).

Підводні камені Collectors.toMap()

Метод Collectors.toMap таїть у собі дві критичні вразливості:

1. Колізії ключів (Duplicate Keys)

// ❌ ПАДІННЯ В RUNTIME:
// Якщо у списку опиняться два користувачі з однаковим email:
// java.lang.IllegalStateException: Duplicate key user@test.com
Map<String, User> map = users.stream()
    .collect(Collectors.toMap(User::email, u -> u));

// ✅ ОБОВ'ЯЗКОВО: завжди передавати функцію вирішення конфліктів (Merge Function)
Map<String, User> safeMap = users.stream()
    .collect(Collectors.toMap(
        User::email, 
        u -> u, 
        (existing, replacement) -> existing // Зберігаємо перший або беремо новий
    ));

2. Небезпека Null-значень

Стандартний колектор Collectors.toMap() усередині використовує Map.merge(). Метод Map.merge() за специфікацією JDK викидає NullPointerException, якщо нове або існуюче значення дорівнює null! Якщо функція мапінгу значення може повернути null, Collectors.toMap() упаде навіть за відсутності колізій ключів.


Stream.toList() (Java 16+) vs Collectors.toList()

У сучасному коді Java 16+ рекомендується використовувати вбудований термінальний метод stream.toList():

Критерій stream.toList() (Java 16+) stream.collect(Collectors.toList())
Змінюваність Незмінний (Unmodifiable): виклик add() кидає UnsupportedOperationException Змінний: повертає стандартний ArrayList
Пам’ять та алокації Не створює об’єкт Collector, використовує прямий компактний масив Алокує Collector та обгортки конвеєра
Допустимість null Повністю підтримує null елементи Повністю підтримує null елементи
Синтаксис Лаконічний: .toList() Громіздкий: .collect(Collectors.toList())

🎯 Шпаргалка для інтерв’ю

Питання для швидкої перевірки

  1. Які 4 основні функції інтерфейсу Collector<T, A, R>? Відповідь: supplier() (створює порожній контейнер-накопичувач), accumulator() (додає елемент у контейнер), combiner() (зливає два контейнери під час паралельної обробки), finisher() (перетворює накопичувач на підсумковий результат).
  2. У чому різниця між Stream.toList() (Java 16+) та Collectors.toList()? Відповідь: stream.toList() повертає незмінний (unmodifiable) компактний список, працює швидше і не створює допоміжних об’єктів колектора. Collectors.toList() повертає стандартний змінний ArrayList.
  3. Що станеться під час виклику Collectors.toMap() із дубльованими ключами, якщо не передано merge-функцію? Відповідь: Буде викинуто виняток IllegalStateException: Duplicate key .... Для запобігання збою необхідно використовувати трипараметричну версію toMap(keyMapper, valueMapper, mergeFunction).
  4. Що дає характеристика Characteristics.CONCURRENT у паралельному стрімі? Відповідь: Вона вказує, що контейнер акумулятора є потокобезпечним. Віртуальна машина не ділить контейнери по потоках і не викликає combiner(), а дозволяє всім потокам пулу паралельно писати в один спільний екземпляр (наприклад, ConcurrentHashMap).

Типові помилки та Red Flags

  • ❌ Твердження: “reduce() так само добрий для збирання елементів у List, як і collect()”: Призводить до алгоритмічної катастрофи $O(N^2)$ і копіювання колекцій у пам’яті.
  • ❌ Використання Collectors.toMap() без merge-функції в продакшені: Бомба сповільненої дії при отриманні неочікуваних дублікатів із бази або API.
  • ❌ Спроба викликати collect() на стрімі повторно: Стрім закривається назавжди після першого термінального виклику (IllegalStateException).
  • ❌ Використання collect(toList()).size() замість .count(): Безглузда алокація списку в пам’яті заради отримання його довжини.

Пов’язані теми