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

Що таке операція peek() і коли її використовувати

Офіційна документація Java чітко фіксує її єдине легітимне призначення:


🟢 Junior Level

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

peek(Consumer<T>) — це проміжна (intermediate) операція Stream API, яка застосовує передану дію до кожного елемента, що проходить крізь неї, і без змін передає його далі вниз по конвеєру.

Офіційна документація Java чітко фіксує її єдине легітимне призначення:

«Цей метод існує головним чином для підтримки налагодження, дозволяючи спостерігати за елементами в процесі їхнього проходження через певний етап конвеєра обробки.»

Використовувати peek() для бізнес-логіки (мутація полів, запис у БД, надсилання сповіщень) суворо заборонено, оскільки через лінивість Stream API, операції короткого замикання (short-circuiting) та оптимізації JVM виклик peek() може бути виконаний не для всіх елементів або взагалі пропущений.


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

import java.util.List;

public class PeekBasicsDemo {

    public static void main(String[] args) {
        List<String> words = List.of("кіт", "слон", "бегемот", "тигр");

        List<String> result = words.stream()
            .filter(w -> w.length() > 3)
            .peek(w -> System.out.println("[DEBUG After filter]: " + w))
            .map(String::toUpperCase)
            .peek(w -> System.out.println("[DEBUG After map]: " + w))
            .toList();

        System.out.println("Підсумок: " + result);
    }
}

Виведення в консоль:

[DEBUG After filter]: слон
[DEBUG After map]: СЛОН
[DEBUG After filter]: бегемот
[DEBUG After map]: БЕГЕМОТ
[DEBUG After filter]: тигр
[DEBUG After map]: ТИГР
Підсумок: [СЛОН, БЕГЕМОТ, ТИГР]

🟡 Middle Level

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

У вихідному коді OpenJDK (ReferencePipeline.java) метод peek() конструює проміжну операцію, що повертає обгортку над приймачем даних (Sink):

// Спрощений фрагмент реалізації в OpenJDK:
@Override
public Sink<P_OUT> opWrapSink(int flags, Sink<P_OUT> sink) {
    return new Sink.ChainedReference<P_OUT, P_OUT>(sink) {
        @Override
        public void accept(P_OUT u) {
            action.accept(u);       // 1. Викликаємо користувацький Consumer
            downstream.accept(u);   // 2. Проштовхуємо елемент далі без змін
        }
    };
}

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

Критерій peek() map() forEach()
Тип операції Проміжна (Intermediate) Проміжна (Intermediate) Термінальна (Terminal)
Значення, що повертається Stream<T> (той самий тип) Stream<R> (новий тип) void
Призначення Налагодження, логування проходу Трансформація даних Завершення роботи, виведення даних
Гарантія виклику Ні (залежить від термінальної) Ні (залежить від термінальної) Так (поки конвеєр активний)

Антипатерн: Мутація об’єктів усередині peek()

// ❌ ГРУБИЙ АНТИПАТЕРН: Мутація сутностей усередині налагоджувального методу
users.stream()
    .peek(user -> user.setStatus("PROCESSED")) // Побічний ефект!
    .toList();

// ✅ ПРАВИЛЬНО: Чиста трансформація в новий об'єкт або виклик forEach()
List<User> processed = users.stream()
    .map(user -> user.withStatus("PROCESSED"))
    .toList();

Якщо мутувати об’єкт усередині peek(), код перестає бути чистою функцією, порушує детермінізм конвеєра та призводить до прихованих дефектів при багатопотоковій обробці в parallelStream().


🔴 Senior Level

Пастка оптимізації Stream Pruning у Java 9+ (JDK-8075056)

У Java 9+ термінальна операція .count() отримала глибоку оптимізацію: якщо джерело стріма має характеристику SIZED (наприклад, ArrayList, масив, List.of()) і в пайплайні немає операцій фільтрації, рантайм бере точний розмір безпосередньо зі сплітератора, узагалі не запускаючи обхід елементів:

import java.util.List;

public class PeekPruningDemo {

    public static void main(String[] args) {
        List<String> data = List.of("A", "B", "C");

        // ❌ НЕБЕЗПЕЧНО: Спроба виконати бізнес-логіку в peek()
        long count = data.stream()
            .peek(item -> System.out.println("Виконую аудит: " + item))
            .count();

        System.out.println("Усього елементів: " + count);
        // У Java 9+ виведення "Виконую аудит..." НЕ З'ЯВИТЬСЯ ЗОВСІМ!
        // JVM повернула 3 безпосередньо з data.size(), а peek() був видалений із конвеєра.
    }
}

Вплив операцій короткого замикання (Short-Circuiting)

Операції на кшталт findFirst(), findAny(), anyMatch(), limit() переривають конвеєр, щойно умову задоволено:

List<String> items = List.of("A", "B", "C", "D", "E");

// peek() спрацює лише для елементів "A" та "B"!
items.stream()
    .peek(x -> auditService.save(x)) // Елементи C, D, E ніколи не збережуться!
    .filter(x -> "B".equals(x))
    .findFirst();

Продуктивність у Highload та альтернативи налагодження

  1. Virtual Call Overhead: Кожен peek() додає віртуальний виклик Consumer.accept() на кожен елемент потоку, заважаючи C2-компілятору виконувати агресивний інлайнінг і векторизацію (SIMD).
  2. Production-практика: У промисловому коді peek() не повинен доходити до production-репозиторіїв (лінтери Checkstyle/SonarQube містять правило S3864: "peek" should be used only for debugging).
  3. IDE Stream Debugger: Для візуалізації конвеєра в IntelliJ IDEA є плагін Java Stream Debugger, що дозволяє покроково спостерігати вхід і вихід кожного етапу конвеєра без модифікації вихідного коду.

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

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

«peek() — проміжна операція, призначена виключно для налагодження. Вона приймає Consumer, виконує дію над елементом і пропускає його далі без змін. Головне правило: ніколи не використовувати peek() для бізнес-логіки. По-перше, стріми ліниві й не виконуються без термінальної операції. По-друге, short-circuiting операції переривають виклики peek() на середині. По-третє, починаючи з Java 9, операція stream.peek(...).count() на колекціях SIZED повністю відкидається оптимізатором JVM, і код у peek() узагалі не викликається.»


Зведення ризиків використання peek()

Сценарій Результат Чому так відбувається
stream.peek(...).count() (Java 9+) 🔴 Дія не виконається Stream Pruning оптимізація за розміром SIZED
stream.peek(...).findFirst() 🟡 Виконається частково Short-circuiting зупиняє стрім при першій знахідці
stream.peek(...); (без термінальної) 🔴 Дія не виконається Лінивість стрімів (без термінальної немає запуску)
stream.peek(u -> u.setX()).toList() 🔴 Антипатерн Порушення чистоти функцій, сайд-ефект у проміжному вузлі
stream.filter(...).peek(log).toList() 🟢 Коректно Тимчасове налагодження на етапі розробки

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

1. Чому в Java 9+ виклик stream.peek(System.out::println).count() нічого не виводить у консоль?

  • Це наслідок оптимізації JDK-8075056. Якщо термінальна операція — count(), а в конвеєрі немає проміжних операцій, що фільтрують або генерують елементи (filter, flatMap), і джерело стріма має характеристику SIZED, рантайм зчитує розмір безпосередньо через spliterator.getExactSizeIfKnown(), не запускаючи конвеєр і відкидаючи проміжні вузли.

2. У чому різниця між peek() та forEach()?

  • peek() — проміжна (intermediate) операція, яка повертає Stream<T> і призначена для пасивного спостереження за потоком у процесі його трансформації.
  • forEach() — термінальна (terminal) операція, яка повертає void, запускає конвеєр і призначена для виконання фінальної дії над елементами.

3. Чому статичні аналізатори (SonarQube правило S3864) позначають виклики peek() як помилку/code smell?

  • Розробники часто помилково використовують peek() замість map() для мутації сутностей або намагаються виконувати в ньому бізнес-дії (запис у БД, аудит). Через оптимізації рантайму, лінивість і паралелізм така поведінка нестабільна, тому лінтери забороняють знаходження peek() у production-коді.

4. Як поводиться peek() у паралельному стрімі (parallelStream())?

  • Виклики Consumer.accept() відбуваються в різних робочих потоках ForkJoinPool паралельно та в непередбачуваному порядку. Якщо всередині peek() виконується несинхронізований запис або логування без урахування багатопотоковості, виникнуть гонки даних і конкуренція за блокування апендерів логера.

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

  • 🚩 Використання peek() для збереження сутностей у базу даних або надсилання метрик.
  • 🚩 Очікування, що peek() гарантовано викличеться для кожного елемента перед count() або findFirst().
  • 🚩 Залишення налагоджувальних викликів peek(System.out::println) у production-коді.
  • 🚩 Зміна полів об’єктів усередині peek() замість створення нових DTO через map().

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