⏱️ Раздел 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:

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

Связанные вопросы