⏱️ Розділ 19 · Питання #5

Що роблять методи thenAccept() і thenRun()

Методи thenAccept() та thenRun() використовуються на завершальних етапах конвеєра CompletableFuture, коли проміжні обчислення завершені і потрібно виконати кінцеву дію (термінал...


🟢 Junior Level

Методи thenAccept() та thenRun() використовуються на завершальних етапах конвеєра CompletableFuture, коли проміжні обчислення завершені і потрібно виконати кінцеву дію (термінальну операцію або побічний ефект).

Головна відмінність між ними полягає в тому, чи отримують вони результат попереднього кроку:

  • thenAccept(Consumer<T> action): приймає результат обчислення попереднього етапу та обробляє його (наприклад, відправляє відповідь клієнту, виводить у консоль або зберігає в базу даних).
  • thenRun(Runnable action): просто виконує дію після завершення попереднього етапу, не маючи доступу до його результату.

Обидва методи повертають CompletableFuture<Void>, оскільки не генерують нового значення.

Наочний приклад

CompletableFuture<User> userFuture = CompletableFuture.supplyAsync(() -> database.findUser(42));

// 1. thenAccept: нам ПОТРІБЕН результат (користувач)
userFuture.thenAccept(user -> {
    System.out.println("Знайдено користувача: " + user.getName());
    emailService.sendWelcomeEmail(user.getEmail());
});

// 2. thenRun: результат НЕ ПОТРІБЕН, важливий сам факт закінчення операції
userFuture.thenRun(() -> {
    metrics.incrementCounter("users.fetch.completed");
    System.out.println("Операція пошуку в базі даних успішно завершена.");
});

🟡 Middle Level

Порівняльна таблиця методів-споживачів

В інтерфейсі CompletionStage є чітка ієрархія методів за типом функціонального інтерфейсу:

Метод Функціональний інтерфейс Вхідні дані Повернене значення Призначення
thenApply Function<T, U> Значення T CompletableFuture<U> Трансформація даних (map)
thenAccept Consumer<T> Значення T CompletableFuture<Void> Споживання результату (sink)
thenRun Runnable Немає аргументів CompletableFuture<Void> Побічний ефект / тригер

Продовження ланцюжка після Void

Поширена помилка новачків — вважати, що після thenAccept або thenRun ланцюжок продовжувати не можна:

CompletableFuture.supplyAsync(() -> "hello")
    .thenAccept(System.out::println) // повертає CompletableFuture<Void>
    .thenRun(() -> System.out.println("Ланцюжок продовжується!")) // цілком валідно!
    .thenApply(v -> {
        // v тут має тип Void і дорівнює null!
        return 123;
    })
    .thenAccept(System.out::println); // Надрукує 123

Хоча результат thenAccept дорівнює null (Void), успішне завершення цього етапу є тригером для виконання наступних ланок.

Асинхронні модифікації

Для кожного методу існують версії з постфіксом Async:

  • thenAcceptAsync(Consumer<T> action): виконує споживача у спільному пулі ForkJoinPool.commonPool().
  • thenAcceptAsync(Consumer<T> action, Executor executor): виконує споживача у виділеному пулі (критично для блокуючої відправки в сокет або запису у файл).
  • thenRunAsync(Runnable action, Executor executor): аналогічно для Runnable.

🔴 Senior Level

Внутрішня будова: вузли UniAccept та UniRun

Усередині CompletableFuture обидва методи реєструють спеціалізовані легковажні класи-обгортки в неблокуючому стеку stack (Treiber Stack):

// UniAccept успадковує Completion:
static final class UniAccept<T> extends UniCompletion<T, Void> {
    Consumer<? super T> fn;
    // У методі tryFire(mode) витягує результат src.result, 
    // кастує до T і викликає fn.accept(t), 
    // потім встановлює власний результат у NIL (null)
}

// UniRun взагалі не взаємодіє з результатом:
static final class UniRun<T> extends UniCompletion<T, Void> {
    Runnable fn;
    // У методі tryFire(mode) просто викликає fn.run(), 
    // ігноруючи src.result (за винятком перевірки на виняток),
    // і виставляє свій результат у NIL
}

Парні методи: *Both та *Either

У CompletionStage логіка thenAccept та thenRun дзеркально відтворюється для координації кількох етапів:

  1. Очікування обох етапів (AND):
    • thenAcceptBoth(stage, BiConsumer<T, U>): чекає на завершення обох ф’ючерів і передає обидва їхні результати споживачеві.
    • runAfterBoth(stage, Runnable): виконує дію, коли завершилися обидва ф’ючери, ігноруючи їхні результати.
  2. Очікування першого завершеного (OR / Race):
    • acceptEither(stage, Consumer<T>): споживає результат того ф’ючера, який завершився першим (обидва ф’ючери повинні повертати сумісний тип T).
    • runAfterEither(stage, Runnable): запускає Runnable негайно при завершенні будь-якого з двох ф’ючерів.
CompletableFuture<String> fastServer = pingServer("10.0.0.1");
CompletableFuture<String> slowServer = pingServer("10.0.0.2");

// Виконається для найшвидшого сервера:
fastServer.acceptEither(slowServer, winnerIp -> log.info("Першим відповів: {}", winnerIp));

4 Tricky Questions

1. У чому ключова різниця між .thenAccept(Consumer<T>) та .whenComplete(BiConsumer<T, Throwable>)?

Відповідь:

  1. Реакція на помилки: thenAccept викликається тільки при успішному завершенні ф’ючера. Якщо етап завершився з винятком, thenAccept пропускається. whenComplete викликається завжди — як при успіху, так і при падінні.
  2. Тип повернення: thenAccept повертає CompletableFuture<Void> (значення попереднього кроку втрачається). whenComplete повертає CompletableFuture<T> із тим самим типом і тим самим значенням (або винятком), що й вихідний етап, працюючи як прозорий слухач побічних ефектів (side-effect monitor).

2. Що станеться, якщо всередині лямбди в thenAccept() викинути необроблений виняток (наприклад, NullPointerException)?

Відповідь: Виняток буде перехоплено внутрішнім механізмом CompletableFuture і загорнуто в CompletionException. Повернутий методом thenAccept екземпляр CompletableFuture<Void> перейде в аварійний стан із цим винятком. Усі наступні етапи, навішані на цей Void-ф’ючер, будуть пропущені аж до першого обробника exceptionally або handle. Вихідний же батьківський ф’ючер залишиться успішно завершеним зі своїм значенням.


3. Якщо після .thenAccept(v -> ...) викликати .thenApply(v -> ...), що прийде на вхід параметру v у другій лямбді?

Відповідь: Параметр v гарантовано отримає значення null. Тип ф’ючера, що повертається після thenAccept, — CompletableFuture<Void>. У Java тип Void не має екземплярів, окрім літерала null. Тому аргумент завжди дорівнюватиме null, а спроба викликати на ньому методи (наприклад, v.toString()) призведе до NullPointerException.


4. Припустимо, батьківський ф’ючер впав з винятком. Чи викличеться Runnable всередині .thenRun()?

Відповідь: Ні, не викличеться. Метод thenRun(Runnable) реагує виключно на успішне завершення батьківського етапу. При виникненні винятку етап UniRun переходить у стан помилки з тим самим винятком без виконання Runnable.run(). Щоб гарантовано виконати код очищення або звільнення ресурсів за будь-якого підсумку, слід використовувати whenComplete((res, ex) -> cleanup()) або handle((res, ex) -> { cleanup(); return null; }).


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

Головне за 60 секунд

  • thenAccept(Consumer<T>) приймає значення і споживає його. Повертає CompletableFuture<Void>.
  • thenRun(Runnable) не приймає жодних аргументів, виконуючи дію за фактом завершення. Повертає CompletableFuture<Void>.
  • Обидва методи пропускаються, якщо попередній крок завершився з винятком.
  • При необхідності реакції на будь-який результат (успіх/помилка) без втрати значення використовується whenComplete.
  • Для паралельних сценаріїв існують парні аналоги: thenAcceptBoth / runAfterBoth (обидва завершилися) та acceptEither / runAfterEither (перший завершений).

Типові помилки на інтерв’ю

  • Вважати, що thenRun виконується завжди (як блок finally). Насправді при помилці він не виконується.
  • Плутати thenAccept та whenComplete: thenAccept скидає тип повернення у Void, а whenComplete зберігає вихідний тип T.
  • Намагатися передати аргумент у thenRun (він приймає Runnable, що не має параметрів).

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