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

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

Ключова відмінність полягає в структурі значення, що повертається функцією-мапером, та результуючій розмірності потоку:


🟢 Junior Level

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

Ключова відмінність полягає в структурі значення, що повертається функцією-мапером, та результуючій розмірності потоку:

  • map(Function<T, R>) — реалізує перетворення «один до одного» ($1 \to 1$). Функція приймає елемент типу T і повертає один об’єкт типу R. Якщо функція повертає колекцію або список, результуючий потік буде вкладеним (Stream<List<R>>).
  • flatMap(Function<T, Stream<R>>) — реалізує перетворення «один до багатьох» (або «один до нуля») ($1 \to N$). Функція приймає елемент типу T і повертає цілий підпотік Stream<R>, після чого flatMap об’єднує («сплощує») усі вкладені елементи підпотоків в один плаский Stream<R>.

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

import java.util.List;

public class MapVsFlatMapDemo {

    public record Developer(String name, List<String> languages) {}

    public static void main(String[] args) {
        List<Developer> team = List.of(
            new Developer("Олексій", List.of("Java", "Kotlin")),
            new Developer("Марія", List.of("Go", "Python", "Java"))
        );

        // 1. Використання map(): отримуємо стрім списків Stream<List<String>>
        List<List<String>> listOfLists = team.stream()
            .map(Developer::languages)
            .toList();
        System.out.println("map(): " + listOfLists);
        // [[Java, Kotlin], [Go, Python, Java]]

        // 2. Використання flatMap(): отримуємо плаский стрім рядків Stream<String>
        List<String> flatLanguages = team.stream()
            .flatMap(dev -> dev.languages().stream())
            .distinct()
            .toList();
        System.out.println("flatMap(): " + flatLanguages);
        // [Java, Kotlin, Go, Python]
    }
}

🟡 Middle Level

Порівняльний аналіз характеристик

Критерій map() flatMap()
Сигнатура аргументу Function<? super T, ? extends R> Function<? super T, ? extends Stream<? extends R>>
Результуючий тип Stream<R> Stream<R>
Кардинальність елементів Завжди суворо зберігається ($N_{out} = N_{in}$) Може змінюватися ($N_{out} \ge 0$, залежить від підпотоків)
Повернення null з лямбди Допустимо: значення null передається далі по конвеєру Неприпустимо: негайно кидає NullPointerException
Схлопування вкладеності Ні (зберігає вкладені колекції) Так (розгортає вкладені стріми в один рівень)
Прапорець SIZED потоку Зберігається (розмір стріма відомий заздалегідь) Втрачається (розмір скидається в невідомий)

Різниця в поведінці з Optional

Обидва методи присутні й у класі java.util.Optional:

import java.util.Optional;

public class OptionalMapVsFlatMapDemo {

    public record Address(String city) {}
    public record User(String name, Optional<Address> address) {}

    public static void main(String[] args) {
        Optional<User> user = Optional.of(new User("Іван", Optional.of(new Address("Київ"))));

        // 1. Використання map(): призводить до вкладеності Optional<Optional<Address>>
        Optional<Optional<Address>> nested = user.map(User::address);

        // 2. Використання flatMap(): "розгортає" вкладений контейнер до Optional<Address>
        Optional<Address> flat = user.flatMap(User::address);

        // Отримання міста ланцюжком безпечних викликів
        String city = user
            .flatMap(User::address)
            .map(Address::city)
            .orElse("Не вказано");

        System.out.println("Місто: " + city); // Київ
    }
}

🔴 Senior Level

Архітектурні відмінності: Функтор проти Монади

У концепціях функціонального програмування та теорії категорій:

  • map представляє операцію функтора (fmap): застосовує чисту функцію $A \to B$ над значенням всередині контексту $F[A]$, зберігаючи сам контекст $F[B]$.
  • flatMap представляє операцію монадичного зв’язування (bind або оператор >>= у Haskell): бере значення з монадичного контексту $M[A]$ і застосовує функцію $A \to M[B]$. Потім відбувається сплощення монади (операція join: $M[M[B]] \to M[B]$).
Functor (map):
[ T ] ───────── map(T -> R) ─────────> [ R ]

Monad (flatMap):
[ T ] ─ flatMap(T -> Stream<R>) ─> [[ R1, R2 ], [ R3 ]] ─ join ─> [ R1, R2, R3 ]

Внутрішня будова та вплив на прапорці оптимізації (Stream Flags)

Кожна проміжна операція в AbstractPipeline або зберігає, або модифікує бітову маску характеристик стріма:

  1. Поведінка map():
    • Зберігає прапорець StreamOpFlag.IS_SIZED (якщо вихідне джерело знало свій точний розмір, downstream-операції, такі як toArray() або toList(), можуть одразу виділити масив точного розміру без динамічного розширення).
    • Скидає прапорці SORTED та DISTINCT, оскільки трансформація значень може порушити порядок сортування і породити однакові елементи.
  2. Поведінка flatMap():
    • Завжди скидає прапорці SIZED і SUBSIZED. Стрім більше не знає, скільки елементів у ньому міститься.
    • Термінальні операції змушені використовувати динамічні буфери, що розширюються (SpinedBuffer), які виділяють чанки пам’яті в купі ($2^k$).

Продуктивність, GC Overhead та порівняння з mapMulti

Створення тимчасових об’єктів у flatMap:

  • На кожен вхідний елемент створюється екземпляр Stream, вузол конвеєра та внутрішній ітератор/сплітератор.
  • Хоча сучасні JIT-компілятори (C2 / Graal) активно застосовують Escape Analysis та скалярне заміщення (Scalar Replacement), об’єкти Stream містять віртуальні виклики та замикання, через що часто втікають (escape) у Young Generation.
  • У критично навантажених шляхах виконання (десятки мільйонів елементів на секунду) flatMap призводить до помітних пауз GC.

У Java 16 додано метод Stream.mapMulti(), який вирішує цю проблему шляхом прямого імперативного проштовхування значень у downstream-споживач:

// flatMap: алокує тисячі Stream-об'єктів
stream.flatMap(e -> e.getItems().stream()).toList();

// mapMulti (Java 16+): нульові алокації проміжних стрімів
stream.<Item>mapMulti((e, consumer) -> {
    for (Item item : e.getItems()) {
        consumer.accept(item);
    }
}).toList();

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

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

«map виконує перетворення елементів «один до одного», зберігаючи початкову кількість елементів і прапорець SIZED. flatMap виконує перетворення «один до багатьох», приймаючи функцію, що повертає стрім, і схлопуючи підпотоки в єдиний плаский потік. В Optional метод flatMap усуває вкладеність Optional<Optional<T>>. У критичних за алокаціями сценаріях Java 16+ рекомендовано замінювати flatMap на mapMulti, оскільки flatMap алокує окремий об’єкт Stream на кожен елемент і скидає оптимізаційні прапорці розміру.»


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

Параметр map() flatMap()
Семантика Трансформація значення ($1 \to 1$) Трансформація зі сплощенням ($1 \to N$)
Застосування Гетери, конвертація в DTO, розрахунок формул Розпакування списків, графів, розбір токенів рядків
Optional поведінка Залишає вкладеність Optional<T> Схлопує до одного Optional<T>
Реакція на null Передає null вниз по конвеєру Викидає NullPointerException
Прапорець SIZED Зберігається Втрачається (розмір невідомий)
Альтернатива (Java 16+) — Stream.mapMulti()

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

1. У яких ситуаціях map() зберігає оптимізаційні характеристики потоку, а flatMap() завжди їх скидає?

  • map() зберігає прапорець SIZED (розмір потоку не змінюється), що дозволяє термінальним операціям (toArray, toList) виділити масив точного розміру без реалокацій.
  • flatMap() завжди скидає прапорець SIZED в 0, оскільки функція може повернути потік будь-якого розміру. Це перемикає рантайм на буфери змінної місткості (SpinedBuffer) і погіршує балансування завдань у паралельних потоках ForkJoinPool.

2. Що станеться, якщо функція всередині map() поверне null, і що — якщо всередині flatMap()?

  • У map(): значення null стане звичайним елементом стріма і піде далі по конвеєру. Якщо наступна операція не очікує null (наприклад, виклик методу), впаде вона, але сам map() відпрацює штатно.
  • У flatMap(): повернення null замість об’єкта Stream призведе до негайного викидання NullPointerException безпосередньо в рантаймі методу flatMap. Для пропуску елемента потрібно повертати Stream.empty().

3. Як концепція монади співвідноситься з Optional.map() та Optional.flatMap()?

  • Optional є монадою. Метод map() застосовує звичайну функцію до запакованого значення і заново запаковує результат в Optional (поведінка функтора). Якщо функція сама повертає Optional, отримуємо Optional<Optional<T>>.
  • Метод flatMap() приймає функцію, що повертає монаду (T -> Optional<R>), і застосовує монадичне зв’язування (bind), не створюючи подвійний контейнер.

4. Чому flatMap у parallelStream може працювати повільніше, ніж звичайний послідовний цикл for?

  • По-перше, скидання прапорця SIZED ламає ефективне ділення Spliterator.trySplit() на рівні частини.
  • По-друге, алокація проміжних стрімів для кожного елемента перевантажує збирач сміття.
  • По-третє, об’єднання розрізнених результуючих структур із паралельних гілок вимагає створення проміжних вузлів дерева та блокувань/злиттів, зводячи нанівець виграш від багатопотоковості при дрібних завданнях.

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

  • 🚩 Використання flatMap, коли мапер повертає одиничне значення: stream.flatMap(x -> Stream.of(x.getName())) замість stream.map(User::getName). Це створює безглузде навантаження на пам’ять.
  • 🚩 Повернення null з лямбди flatMap замість Stream.empty(), що викликає раптовий NPE у продакшені.
  • 🚩 Отримання Stream<List<T>> і спроба запустити на ньому термінальну операцію замість сплощення структури через flatMap.
  • 🚩 Використання вкладених Optional<Optional<T>> в API через помилковий виклик map замість flatMap.

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