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

Що таке lazy evaluation в Stream?

Кожна intermediate-операція створює об'єкт AbstractPipeline (внутрішній клас JDK, що пов'язує стадії конвеєра у двозв'язний список — кожен вузол зберігає посилання на попередню...

Мовні версії: English Russian Ukrainian

🟢 Junior Level

Lazy evaluation (ледачі обчислення) — це стратегія, при якій проміжні (intermediate) операції не виконуються одразу, а тільки записуються у «план». Реальна робота починається виключно при виклику terminal-операції — тієї, яка запускає конвеєр (наприклад, collect, forEach, count).

Ледачість — як обіцянка: стрим «обіцяє» виконати операції, але почне тільки коли ви запросите результат. Без terminal-операції стрим — просто опис плану.

// Ці операції НЕ виконуються одразу
Stream<String> stream = list.stream()
    .filter(s -> s.startsWith("A"))  // тільки записано в план
    .map(String::toUpperCase);        // тільки записано в план

// Тільки тут починається реальна робота
List<String> result = stream.collect(Collectors.toList());

Розбір прикладу: якщо list = ["Apple", "Banana", "Apricot"], то при виклику collect:

  1. Елемент "Apple" проходить filter (починається з “A” — так) → map"APPLE" → додано в результат
  2. Елемент "Banana" проходить filter (не починається з “A”) → відкидається, map НЕ викликається
  3. Елемент "Apricot" проходить filter (так) → map"APRICOT" → додано в результат

Навіщо це потрібно:

  • Економія ресурсів — не обробляємо дані, які не потрібні
  • Можливість працювати з нескінченними стримами

🟡 Middle Level

Внутрішній механізм: Pipeline та Sink

Кожна intermediate-операція створює об’єкт AbstractPipeline (внутрішній клас JDK, що пов’язує стадії конвеєра у двозв’язний список — кожен вузол зберігає посилання на попередню та наступну операцію). При виклику terminal-операції цей список перетворюється на ланцюжок Sink — об’єктів-обробників, кожен з яких приймає елемент, обробляє його та передає наступному Sink.

Що таке Spliterator: це ітератор з підтримкою паралельного розбиття (split). Він «протовхує» елементи через ланцюжок Sink по одному.

Horizontal vs Vertical Execution

Імперативний підхід (Horizontal):

filter для всіх → проміжний список → map для всіх → результат

Stream API (Vertical):

Елемент 1: filter → map → collect
Елемент 2: filter → map → collect
...

Переваги

  • Економія пам’яті: Не потрібні проміжні колекції
  • Short-circuiting (коротке замикання): .limit(1) зупинить обробку після першого елемента — конвеєр не буде запитувати решту даних. Аналог break у циклі.
  • Infinite Streams: Можна працювати з нескінченними стримами (Stream.generate())

🔴 Senior Level

Коли ледачість стрима — проблема

  1. Потрібно виконати побічний ефект (логування, метрики) — ледачість може пропустити операції при short-circuit
  2. I/O всередині пайплайну — з’єднання не відкриється до terminal-операції, що може здивувати
  3. Налагодження — стек-трейс починається від terminal-операції, не від місця створення стрима

Loop Fusion (Злиття циклів)

JIT-компілятор бачить вертикальне проходження і може об’єднати кілька лямбд в один високоефективний машинний код, оптимізований під кеш-лінії процесора.

Небезпеки ледачості

Приховані винятки:

Stream<Integer> stream = list.stream()
    .map(n -> 100 / n); // Ділення на нуль! Але помилка не тут...

stream.collect(toList()); // ...а тут — при terminal-операції

Side Effects Trap: Якщо peek() щось пише у БД, а terminal-операцію не викликали — запис не відбудеться.

Debugging

  • Breakpoint всередині лямбди ледачої операції не спрацює до виклику terminal
  • Якщо логи з peek() виводяться вперемішку (елемент 1 пройшов усе, потім елемент 2) — це візуальне доказ вертикального виконання

Діагностика

IntelliJ Stream Debugger дозволяє побачити «карту» проходження елементів через вертикальний конвеєр.

Коли НЕ використовувати lazy evaluation

  • Коли потрібна рання валідація. Якщо map містить операцію, яка може впасти (ділення на нуль, парсинг), помилка проявиться тільки при terminal-операції — далеко від місця оголошення. Для раннього виявлення помилок використовуйте імперативний цикл.
  • Коли побічні ефекти очікувані на кожній стадії. Lazy evaluation робить порядок виконання неочевидним для розробника, звиклого до покрокового циклу. Якщо важливо, щоб кожна операція виконала свою дію до переходу до наступної — використовуйте звичайний for.
  • Для налагодження у production. Стек-трейс при помилці всередині ледачої лямбди вказує на terminal-операцію, а не на проблемний map/filter, що ускладнює діагностику.

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

Обов’язково знати:

  • Intermediate операції не виконуються до виклику terminal-операції — це і є lazy evaluation
  • Без terminal-операції стрим — просто опис плану обробки даних
  • Vertical execution: кожен елемент проходить весь конвеєр цілком, а не горизонтально
  • Loop Fusion: JIT може об’єднати кілька лямбд в один машинний код
  • Ледачість економить ресурси — не обробляються непотрібні дані
  • Short-circuit операції (limit) зупиняють конвеєр достроково
  • Винятки в ледачих операціях проявляються тільки при terminal-операції
  • Side effects через peek() не спрацюють без terminal-операції

Часті уточнюючі запитання:

  • Чому filter не виконується одразу? — Intermediate операції тільки записуються в план (Pipeline), реальна робота починається від terminal-операції (collect, forEach).
  • Що таке Loop Fusion? — JIT-оптимізація, що об’єднує кілька стадій конвеєра в один машинний код для ефективного використання кешу процесора.
  • Чи можна виконати стрим без terminal-операції? — Можна створити, але обробка не почнеться — елементи не будуть оброблені.
  • Чому стек-трейс вказує на terminal-операцію? — Тому що саме вона запускає конвеєр; реальна помилка знаходиться всередині лямбди intermediate-операції.

Червоні прапорці (НЕ говорити):

  • «Stream виконується одразу при створенні» — неправда, стрим ледачий
  • «Проміжні операції створюють проміжні колекції» — неправда, vertical execution обходиться без них
  • «Можна виконати один стрим двічі» — неправда, стрими одноразові (прапорець linkedOrConsumed)
  • «peek() завжди виконає побічний ефект» — неправда, тільки при виклику terminal-операції

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

  • [[22. Коли починається виконання операцій в Stream]]
  • [[24. Як працює коротке замикання (short-circuiting) в Stream]]
  • [[23. Що роблять операції distinct(), sorted(), limit(), skip()]]
  • [[26. Що роблять операції findFirst() та findAny()]]