Что делают методы 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:
// 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, не имеющий параметров).