🌊 Раздел 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).

Связанные темы