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

В чому різниця між reduce() та collect()

Обидві операції є термінальними операціями редукції (згортання), що перетворюють потік елементів на єдиний результуючий об'єкт. Головна відмінність — у способі формування резуль...


🟢 Junior Level

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

Обидві операції є термінальними операціями редукції (згортання), що перетворюють потік елементів на єдиний результуючий об’єкт. Головна відмінність — у способі формування результату:

  • reduce() реалізує імутабельну редукцію (Immutable Reduction): на кожному кроці обчислень породжується новий об’єкт. Призначена для примітивів і незмінних типів даних (int, long, BigInteger, Optional).
  • collect() реалізує мутабельну редукцію (Mutable Reduction): елементи потоку послідовно додаються («накопичуються») в один наявний змінний контейнер (наприклад, ArrayList, HashSet, StringBuilder). Призначена для збирання елементів у колекції та складні агрегати.

Порівняльний приклад на Java 21

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

public class ReduceVsCollectBasicsDemo {

    public static void main(String[] args) {
        List<String> items = List.of("яблуко", "банан", "груша");

        // 1. Використання reduce() для обчислення скалярного значення (числа, рядки)
        int totalChars = items.stream()
            .mapToInt(String::length)
            .reduce(0, Integer::sum);
        System.out.println("Усього символів: " + totalChars); // 17

        // 2. Використання collect() для накопичення в змінний контейнер (List)
        List<String> upperList = items.stream()
            .map(String::toUpperCase)
            .collect(Collectors.toList());
        System.out.println("Список: " + upperList);

        // 3. Конкатенація рядків через collect(joining):
        String joined = items.stream()
            .collect(Collectors.joining(", ", "[", "]"));
        System.out.println("Рядок: " + joined); // [яблуко, банан, груша]
    }
}

🟡 Middle Level

Розбір сигнатур: BiFunction проти BiConsumer

Ключова архітектурна різниця закладена в сигнатурах методів інтерфейсу Stream<T>:

1. Сигнатура reduce():

T reduce(T identity, BinaryOperator<T> accumulator);
<U> U reduce(U identity, BiFunction<U, ? super T, U> accumulator, BinaryOperator<U> combiner);
  • Акумулятор є функцією (U, T) -> U: він приймає попереднє значення, новий елемент і повертає нове значення.

2. Сигнатура collect():

<R> R collect(Supplier<R> supplier, BiConsumer<R, ? super T> accumulator, BiConsumer<R, R> combiner);
<R, A> R collect(Collector<? super T, A, R> collector);
  • supplier створює контейнер (наприклад, ArrayList::new).
  • Акумулятор є консьюмером BiConsumer<R, T>: він приймає наявний контейнер R та елемент T, додаючи елемент на місці ((list, item) -> list.add(item)). Він повертає void.
  • combiner об’єднує два контейнери при паралельному виконанні (list1.addAll(list2)).

Чому збирання в колекції через reduce() — катастрофа

Спроба зібрати елементи в List через reduce() — класична помилка розробників:

// ❌ АНТИПАТЕРН: Спроба мутабельної редукції через reduce
List<String> broken = items.parallelStream().reduce(
    new ArrayList<>(), // Спільний identity!
    (list, item) -> { list.add(item); return list; },
    (l1, l2) -> { l1.addAll(l2); return l1; }
);

Два фатальні недоліки:

  1. Гонка даних у parallelStream: Об’єкт identity (new ArrayList<>()) створюється один раз. При паралельному виконанні кілька робочих потоків ForkJoinPool одночасно викликають .add() на одному й тому самому несинхронізованому ArrayList, викликаючи ArrayIndexOutOfBoundsException і втрату елементів.
  2. Квадратична складність $O(N^2)$ при створенні копій: Якщо намагатися створювати новий список на кожному кроці new ArrayList<>(list), копіювання елементів на кожній ітерації дає алгоритмічну складність $O(N^2)$ замість $O(N)$ і колосальне навантаження на збирач сміття (GC).

🔴 Senior Level

Механіка паралелізму: Tree Fold проти Concurrent Reduction

У паралельному режимі (parallelStream()) механізми роботи reduce() та collect() кардинально різняться.

1. Стандартний collect() та reduce():
   Потік 1 ──► Accumulator 1 ──┐
                                ├──► Combiner ──► Фінальний результат
   Потік 2 ──► Accumulator 2 ──┘

2. collect() з прапорцем Characteristics.CONCURRENT:
   Потік 1 ──┐
   Потік 2 ──┼──► ЄДИНИЙ ConcurrentMap (combiner взагалі не викликається!)
   Потік 3 ──┘
  1. reduce():
    • Завжди вимагає чистого імутабельного об’єднання.
    • Кожен потік обчислює значення у своєму контексті, а потім потоки об’єднуються викликами BinaryOperator combiner.
    • Не може працювати з єдиним спільним контейнером.
  2. collect() з Characteristics.CONCURRENT:
    • Якщо колектор має прапорці CONCURRENT та UNORDERED (наприклад, Collectors.toConcurrentMap()), рантайм ReduceOps створює рівно один екземпляр контейнера на весь паралельний стрім.
    • Усі потоки пулу ForkJoinPool пишуть в один потокобезпечний контейнер (наприклад, ConcurrentHashMap).
    • Метод combiner взагалі не викликається, що виключає витрати пам’яті на проміжні контейнери та їхнє злиття.

Аналіз продуктивності: Конкатенація рядків

Порівняння створення рядка з $N = 100\,000$ елементів:

Метод Реалізація Часова складність Навантаження на пам’ять (Eden GC)
reduce((a, b) -> a + b) Створення нового String на кожному кроці $O(N^2)$ Гігантське (мільйони проміжних String)
collect(Collectors.joining()) Накопичення в єдиний буфер StringBuilder $O(N)$ Мінімальне (один розширюваний масив byte[])

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

30-секундна відповідь

«reduce() призначений для імутабельної редукції (числа, логічні операції, примітивні типи), де на кожному кроці породжується нове незмінне значення через BiFunction. collect() призначений для мутабельної редукції (колекції List, Set, Map, рядки), де елементи додаються в наявний контейнер через BiConsumer. Використання reduce() для мутації колекцій призводить до гонок даних у parallelStream через спільний identity та падіння швидкості до $O(N^2)$. У collect() прапорець CONCURRENT дозволяє кільком потокам писати в єдину ConcurrentMap без фази злиття через combiner.»


Зведена матриця відмінностей

Критерій reduce() collect()
Тип редукції Імутабельна (породжує нові значення) Мутабельна (накопичує в контейнер)
Сигнатура акумулятора BiFunction<U, T, U> (повертає U) BiConsumer<R, T> (повертає void)
Основне застосування Сума, добуток, мін/макс, boolean List, Set, Map, StringBuilder
Складність при конкатенації $O(N^2)$ $O(N)$
Поведінка identity в parallel Поділяється між завданнями (небезпечно мутувати) supplier створює окремий контейнер на завдання
Прапорець CONCURRENT Не підтримується Підтримується (збирання в єдиний concurrent-контейнер)

Топ-4 каверзних запитання на інтерв’ю

1. У чому принципова різниця між функціональними типами акумуляторів у reduce та collect?

  • У reduce() акумулятор є чистою функцією BiFunction<U, T, U> (або BinaryOperator<T>), яка приймає поточний агрегований стан, новий елемент і повертає новий стан.
  • У collect() акумулятор є процедурою BiConsumer<R, T>, яка приймає наявний контейнер та елемент, модифікуючи стан контейнера на місці (повертає void).

2. Чому конкатенація рядків через reduce("", (a, b) -> a + b) має складність $O(N^2)$, а через collect(joining()) — $O(N)$?

  • Рядки в Java є незмінними (immutable). При reduce на кроці $i$ створюється новий рядок довжиною $i$, що вимагає копіювання всіх попередніх символів. Сумарна кількість операцій: $\sum_{i=1}^N i = \frac{N(N+1)}{2} = O(N^2)$.
  • Collectors.joining() усередині використовує StringBuilder із розширюваним масивом символів/байтів, куди символи дописуються в кінець за амортизований час $O(1)$, що дає загальну складність $O(N)$.

3. Як прапорець Characteristics.CONCURRENT у Collector змінює алгоритм паралельного виконання стріма?

  • При звичайному collect() (або reduce) кожен потік ForkJoinPool створює свій власний незалежний акумулятор, а потім дерево завдань попарно об’єднує їх методом combiner().
  • За наявності прапорця CONCURRENT (і UNORDERED) рантайм викликає supplier() рівно один раз. Усі паралельні потоки викликають accumulator() над одним і тим самим спільним потокобезпечним об’єктом (наприклад, ConcurrentHashMap), а метод combiner() узагалі не викликається.

4. Що станеться, якщо в parallelStream.reduce() мутувати об’єкт identity?

  • Відбудеться руйнування багатопотокового контракту і гонка даних (Race Condition). Посилання на переданий об’єкт identity передається в усі гілки дерева завдань. Якщо потоки почнуть викликати мутуючі методи (наприклад, list.add()), станеться пошкодження внутрішніх полів об’єкта, втрата елементів або винятки на кшталт ArrayIndexOutOfBoundsException.

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

  • 🚩 Спроба збирання стріма в List через reduce(new ArrayList<>(), (l, x) -> { l.add(x); return l; }).
  • 🚩 Конкатенація рядків через reduce("", (a, b) -> a + b) на великих обсягах даних замість Collectors.joining().
  • 🚩 Твердження, що reduce() та collect() повністю взаємозамінні.
  • 🚩 Забування про примітивні стріми: використання reduce(0, Integer::sum) замість mapToInt().sum().

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