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

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

Вона приймає функціональний інтерфейс java.util.function.Function (метод R apply(T t)), застосовує передану функцію до кожного вхідного об'єкта і передає перетворений резу...


🟢 Junior Level

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

map(Function<? super T, ? extends R> mapper) — це проміжна (intermediate) лінива операція Stream API, яка перетворює кожен елемент потоку на інший елемент за принципом «один до одного» (1-to-1 mapping).

Вона приймає функціональний інтерфейс java.util.function.Function<T, R> (метод R apply(T t)), застосовує передану функцію до кожного вхідного об’єкта і передає перетворений результат далі по конвеєру. У результаті утворюється новий стрім Stream<R>. Найважливіша властивість: операція map ніколи не змінює загальну кількість елементів у потоці.


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

import java.util.List;

public class MapBasicsDemo {

    record User(Long id, String email, String role) {}
    record UserDto(Long id, String email) {}

    public static void main(String[] args) {
        List<User> users = List.of(
            new User(1L, "alice@example.com", "ADMIN"),
            new User(2L, "bob@example.com", "USER")
        );

        // 1. Трансформація сутностей у DTO
        List<UserDto> dtos = users.stream()
            .map(u -> new UserDto(u.id(), u.email()))
            .toList();

        System.out.println("DTOs: " + dtos);

        // 2. Вилучення однієї властивості з перетворенням типу (String -> Integer)
        List<String> words = List.of("Java", "Stream", "API");
        List<Integer> lengths = words.stream()
            .map(String::length)
            .toList();

        System.out.println("Lengths: " + lengths); // [4, 6, 3]
    }
}

🟡 Middle Level

Внутрішня будова: Sink.ChainedReference

Усередині бібліотеки java.util.stream операція map конструює вузол конвеєра, метод accept якого трансформує вхідне значення на льоту і відразу передає його наступній ланці:

// Спрощена реалізація всередині OpenJDK (ReferencePipeline.java):
@Override
public Sink<P_OUT> opWrapSink(int flags, Sink<P_OUT> downstream) {
    return new Sink.ChainedReference<P_OUT, P_OUT>(downstream) {
        @Override
        public void accept(P_OUT u) {
            // Застосовуємо функцію mapper і негайно проштовхуємо результат далі
            downstream.accept(mapper.apply(u));
        }
    };
}

Завдяки лінивості конвеєра трансформація відбувається по одному елементу за раз, без створення проміжних списків.


Спеціалізація під примітиви: Уникнення Autoboxing

Використання стандартного map() для числових перетворень призводить до створення мільйонів об’єктів-обгорток у купі Java:

// ❌ АНТИПАТЕРН: Створення 1 000 000 об'єктів Integer (24 байти кожен + посилання = ~32 МБ)
Stream<Integer> boxedStream = words.stream().map(String::length);

// ✅ ЕФЕКТИВНО: Перехід у примітивний IntStream (0 алокацій об'єктів обгорток)
IntStream primitiveStream = words.stream().mapToInt(String::length);
int totalCharacters = primitiveStream.sum();

Методи спеціалізації:

  • mapToInt(ToIntFunction) -> повертає IntStream
  • mapToLong(ToLongFunction) -> повертає LongStream
  • mapToDouble(ToDoubleFunction) -> повертає DoubleStream
  • Для зворотного переходу з примітивного потоку в посилальний використовується метод boxed() або mapToObj(IntFunction).

Порівняння: map() vs flatMap()

Критерій map() flatMap()
Сигнатура функції Function<T, R> Function<T, Stream<R>>
Співвідношення елементів Суворо 1 до 1 (на 1 вхідний елемент рівно 1 вихідний) 1 до багатьох (0..N) (на 1 вхідний елемент повертається цілий стрім)
Результат структури Зберігає вкладеність (наприклад, List<List<T>> -> Stream<List<T>>) «Сплющує» (flatten) вкладені структури в плаский потік Stream<T>

Вимога чистоти функцій (Purity & Side-Effect-Free)

Функція, що передається в map, має бути чистою:

  • Не мутувати переданий об’єкт (якщо потрібно змінити поле, створюйте новий об’єкт або DTO).
  • Не звертатися до змінного зовнішнього стану.
  • Не викликати побічних ефектів (I/O, запис у БД). Для виконання побічних дій призначений термінальний метод forEach().

🔴 Senior Level

Вплив на прапорці характеристик конвеєра (Stream Flags)

Операція map() має специфічний вплив на метадані конвеєра:

  1. Прапорець SIZED — ЗБЕРІГАЄТЬСЯ: Конвеєр гарантовано знає точну кількість елементів на виході, оскільки map не може ні додати, ні видалити елементи. Термінальні методи на зразок toList() можуть заздалегідь виділити масив точного розміру.
  2. Прапорці DISTINCT та SORTED — ЗАВЖДИ СКИДАЮТЬСЯ (NOT_DISTINCT, NOT_SORTED): Компілятор і середовище виконання не можуть гарантувати, що користувацька функція збереже унікальність і порядок:
    List<String> distinctWords = List.of("cat", "dog", "bat"); // Усі елементи унікальні
    distinctWords.stream()
        .distinct()             // Прапорець DISTINCT активний
        .map(String::length)    // Прапорець DISTINCT СКИДАЄТЬСЯ: результат [3, 3, 3] містить дублікати!
        .toList();
    

Обробка Checked Exceptions у лямбдах map

Інтерфейс java.util.function.Function не декларує перевірюваних винятків у методі R apply(T t). Якщо метод бізнес-логіки викидає checked-виняток (наприклад, IOException або SQLException), код не скомпілюється.

Патерни вирішення:

// 1. Загортання в Unchecked exception локально
stream.map(path -> {
    try {
        return Files.readString(path);
    } catch (IOException e) {
        throw new UncheckedIOException(e);
    }
});

// 2. Ідіоматичний функціональний адаптер (Try / Either / Result pattern)
public static <T, R> Function<T, R> unchecked(ThrowingFunction<T, R> f) {
    return t -> {
        try {
            return f.apply(t);
        } catch (Throwable e) {
            throw new RuntimeException(e);
        }
    };
}

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

Якщо функція мапера повертає null, map не відфільтрує його — null піде далі конвеєром як повноцінний елемент:

// ❌ ПРИХОВАНИЙ БАГ: NullPointerException на наступному етапі
list.stream()
    .map(User::getOptionalMiddleName) // може повернути null
    .map(String::toUpperCase)         // 💥 NullPointerException!
    .toList();

// ✅ БЕЗПЕЧНИЙ ПІДХІД:
list.stream()
    .map(User::getOptionalMiddleName)
    .filter(Objects::nonNull)
    .map(String::toUpperCase)
    .toList();

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

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

  1. Яке співвідношення елементів гарантує операція map()? Відповідь: Суворо «один до одного» (1-to-1). На кожен елемент вхідного потоку на виході формується рівно один елемент. Загальна кількість елементів потоку не змінюється.
  2. Як map() впливає на прапорці характеристик стріма (SIZED, SORTED, DISTINCT)? Відповідь: Прапорець SIZED завжди зберігається (розмір потоку відомий). Прапорці SORTED і DISTINCT завжди скидаються, оскільки довільна функція трансформації може порушити вихідний порядок сортування або породити однакові значення.
  3. Чому для числових полів переважніше використовувати mapToInt() замість map()? Відповідь: map() із посилальною функцією загортає кожне число в об’єкт Integer (Autoboxing), створюючи навантаження на пам’ять і збирач сміття (24 байти на об’єкт). mapToInt() повертає примітивний IntStream, що зберігає числа безпосередньо без алокації об’єктів у купі.
  4. Що станеться, якщо функція в map() поверне null? Відповідь: Стрім продовжить роботу, передавши null наступній стадії конвеєра. Якщо подальша операція спробує викликати метод у цього елемента, виникне NullPointerException.

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

  • ❌ Спроба використовувати map() для фільтрації з поверненням null: Для фільтрації потрібно використовувати filter(), інакше стрім наповниться null-значеннями.
  • ❌ Мутація полів вихідного об’єкта всередині map(): Порушує принцип чистоти функцій і призводить до race conditions у паралельних стрімах.
  • ❌ Виконання I/O-операцій (запити до бази, виклик REST) усередині map(): Операція map має бути легкою обчислювальною функцією в пам’яті.
  • ❌ Плутанина між map() та flatMap(): Нерозуміння різниці між 1-to-1 проекцією та сплющуванням вкладених колекцій (1-to-N).

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