Що роблять методи 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 дзеркально відтворюється для координації кількох етапів:
- Очікування обох етапів (AND):
thenAcceptBoth(stage, BiConsumer<T, U>): чекає на завершення обох ф’ючерів і передає обидва їхні результати споживачеві.runAfterBoth(stage, Runnable): виконує дію, коли завершилися обидва ф’ючери, ігноруючи їхні результати.
- Очікування першого завершеного (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>)?
Відповідь:
- Реакція на помилки:
thenAcceptвикликається тільки при успішному завершенні ф’ючера. Якщо етап завершився з винятком,thenAcceptпропускається.whenCompleteвикликається завжди — як при успіху, так і при падінні. - Тип повернення:
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, що не має параметрів).