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

Як зібрати Stream в Map

Зібрати Stream у Map можна двома основними колекторами:


🟢 Junior Level

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

Зібрати Stream у Map можна двома основними колекторами:

  1. Collectors.toMap() — використовується для відображення «один-до-одного» ($1 \to 1$), де кожен елемент стріму проектується рівно в один запис Map.Entry<K, V>. Якщо у стрімі зустрічаються дубльовані ключі без зазначення функції вирішення колізій (merge function), метод викидає виняток IllegalStateException.
  2. Collectors.groupingBy() — використовується для відображення «один-до-багатьох» ($1 \to N$), групуючи елементи за спільним ключем класифікатора у структуру Map<K, List<V>>.

Приклади використання на Java 21

import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;

public class StreamToMapDemo {

    public record User(int id, String name, String department) {}

    public static void main(String[] args) {
        List<User> users = List.of(
            new User(1, "Олексій", "IT"),
            new User(2, "Марія", "HR"),
            new User(3, "Іван", "IT")
        );

        // 1. Базова збірка toMap: ID -> Name
        Map<Integer, String> idToName = users.stream()
            .collect(Collectors.toMap(User::id, User::name));
        System.out.println("ID -> Ім'я: " + idToName);

        // 2. Збірка об'єкта цілком через Function.identity(): ID -> User
        Map<Integer, User> idToUser = users.stream()
            .collect(Collectors.toMap(User::id, Function.identity()));
        System.out.println("ID -> Об'єкт: " + idToUser.keySet());

        // 3. Групування: Department -> List<User>
        Map<String, List<User>> byDept = users.stream()
            .collect(Collectors.groupingBy(User::department));
        System.out.println("IT відділ: " + byDept.get("IT").size() + " ос.");
    }
}

🟡 Middle Level

3 перевантаження Collectors.toMap()

Перевантаження Аргументи Поведінка при колізії ключів Тип поверненої Map
2 аргументи keyMapper, valueMapper 💥 Викидає IllegalStateException HashMap (за замовчуванням)
3 аргументи keyMapper, valueMapper, mergeFunction ✅ Викликає функцію злиття HashMap (за замовчуванням)
4 аргументи keyMapper, valueMapper, mergeFunction, mapFactory ✅ Викликає функцію злиття Заданий фабрикою (наприклад, TreeMap::new, LinkedHashMap::new)

Приклади вирішення колізій та збереження порядку:

import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.stream.Collectors;

public class ToMapAdvancedDemo {

    public record Book(String isbn, String title, int year) {}

    public static void main(String[] args) {
        List<Book> books = List.of(
            new Book("123", "Java Core v1", 2020),
            new Book("123", "Java Core v2", 2023), // Дублікат ISBN!
            new Book("456", "Spring Boot", 2022)
        );

        // 1. З mergeFunction: при дублікаті залишаємо новішу книгу (найсвіжіший рік)
        Map<String, Book> latestBooks = books.stream()
            .collect(Collectors.toMap(
                Book::isbn,
                b -> b,
                (existing, replacement) -> replacement.year() > existing.year() ? replacement : existing
            ));

        System.out.println("ISBN 123 версія: " + latestBooks.get("123").title()); // Java Core v2

        // 2. З фабрикою: збір у LinkedHashMap для збереження порядку вставки
        Map<String, String> orderedMap = books.stream()
            .collect(Collectors.toMap(
                Book::isbn,
                Book::title,
                (oldVal, newVal) -> newVal,
                LinkedHashMap::new
            ));

        // 3. Збірка в TreeMap для автоматичного сортування за ключами:
        Map<String, String> sortedMap = books.stream()
            .collect(Collectors.toMap(
                Book::isbn,
                Book::title,
                (oldVal, newVal) -> newVal,
                TreeMap::new
            ));
    }
}

🔴 Senior Level

Пастка NullPointerException при value == null

Один із найпідступніших багів у Java Stream API:

List<User> list = List.of(new User(1, null, "IT"));

// 💥 Викине NullPointerException, хоча звичайна HashMap дозволяє null!
Map<Integer, String> map = list.stream()
    .collect(Collectors.toMap(User::id, User::name));

Чому це відбувається:

У вихідному коді OpenJDK метод Collectors.toMap() реалізований через виклик Map.merge(key, value, mergeFunction):

// Вихідний код Map.java:
default V merge(K key, V value, BiFunction<? super V, ? super V, ? extends V> remappingFunction) {
    Objects.requireNonNull(remappingFunction);
    Objects.requireNonNull(value); // <-- ТУТ ВИКИДАЄТЬСЯ NPE!
    ...
}

Метод Map.merge розглядає значення null як індикатор видалення запису з мапи, тому прямо забороняє передачу null.

Рішення для Senior: Якщо значення можуть бути null:

// Спосіб 1: Збірка через mutable collect безпосередньо в HashMap
Map<Integer, String> safeMap = list.stream()
    .collect(HashMap::new, (m, u) -> m.put(u.id(), u.name()), HashMap::putAll);

// Спосіб 2: Обгортання в Optional
Map<Integer, Optional<String>> optMap = list.stream()
    .collect(Collectors.toMap(User::id, u -> Optional.ofNullable(u.name())));

Архітектурні оптимізації: EnumMap та попереднє виділення ємності

  1. Оптимізація для Enum-ключів: Якщо ключем є Enum, звичайна HashMap витрачає зайву пам’ять на об’єкти Node та обчислення хешів. Використання EnumMap забезпечує прямий доступ за масивом ординалів ($O(1)$) з мінімальним memory footprint:
    Map<Role, List<User>> usersByRole = users.stream()
        .collect(Collectors.groupingBy(User::role, () -> new EnumMap<>(Role.class), Collectors.toList()));
    
  2. Запобігання рехешуванню (Presizing HashMap): При відомому розмірі стріму $N$:
    int capacity = (int) Math.ceil(list.size() / 0.75f) + 1;
    Map<Integer, User> preSized = list.stream()
        .collect(Collectors.toMap(User::id, Function.identity(), (a, b) -> a, () -> new HashMap<>(capacity)));
    

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

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

«Зібрати Stream у Map можна через Collectors.toMap() (відображення 1-в-1) або Collectors.groupingBy() (відображення 1-до-багатьох). Базовий toMap(key, value) при дублікаті ключа викидає IllegalStateException, тому в реальному коді завжди слід передавати 3-й аргумент mergeFunction ((v1, v2) -> v2). Для контролю типу мапи (наприклад, LinkedHashMap або TreeMap) використовується 4-те перевантаження з mapFactory. Головна пастка toMap: він використовує Map.merge(), який категорично забороняє значення null і кидає NullPointerException.»


Зведена таблиця параметрів toMap

Кількість параметрів Приклад виклику Призначення
2 параметри .toMap(User::id, User::name) Швидкий збір унікальних ключів; падає при дублікатах
3 параметри .toMap(User::id, User::name, (o, n) -> n) Production-стандарт: безпечне злиття дублікатів
4 параметри .toMap(k, v, merge, LinkedHashMap::new) Збереження порядку вставки елементів
4 параметри .toMap(k, v, merge, TreeMap::new) Автоматичне сортування записів за ключами

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

1. Чому Collectors.toMap() викидає NullPointerException, якщо valueMapper повертає null, навіть якщо цільова HashMap дозволяє null?

  • Всередині реалізації Collectors.toMap() акумуляція елементів відбувається через метод Map.merge(k, v, mergeFunction). Специфікація методу Map.merge явно вимагає Objects.requireNonNull(value), оскільки за контрактом Map.merge передача null означає видалення ключа з таблиці.

2. Що станеться при дублюванні ключа в Collectors.toMap(keyMapper, valueMapper)?

  • Буде викинуто виняток IllegalStateException: Duplicate key <key> (attempted merging values <v1> and <v2>). Для запобігання помилці необхідно використовувати перевантаження з трьома аргументами, передавши BinaryOperator<U> mergeFunction.

3. У чому різниця між Collectors.toMap() та Collectors.toConcurrentMap() у parallelStream()?

  • toMap у паралельному стрімі створює незалежні мапи в кожному потоці й потім об’єднує їх через Map.putAll() у дереві ForkJoinPool.
  • toConcurrentMap() має характеристику CONCURRENT: рантайм створює один єдиний екземпляр ConcurrentHashMap, і всі потоки пишуть у нього напряму без фази злиття через combiner.

4. Як зберегти порядок елементів при збиранні стріму в Map?

  • За замовчуванням Collectors.toMap() використовує стандартну HashMap, яка не зберігає порядок обходу. Щоб зберегти порядок вставки (Encounter Order), потрібно використовувати 4-параметричне перевантаження з фабрикою: Collectors.toMap(keyMapper, valueMapper, mergeFunction, LinkedHashMap::new).

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

  • 🚩 Забування mergeFunction при зборі даних із зовнішніх API або БД (призводить до падіння сервісу з IllegalStateException).
  • 🚩 Повернення null із лямбди valueMapper у toMap() (негайний NPE).
  • 🚩 Спроба використовувати toMap() замість groupingBy(), коли одному ключу має відповідати кілька значень.
  • 🚩 Використання звичайної HashMap замість EnumMap, коли ключі є елементами enum.

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