⏱️ Раздел 19 · Вопрос #7

В чём разница между handle(), exceptionally() и whenComplete()

Все три метода используются для реакции на завершение асинхронной стадии и обработки возможных ошибок в CompletableFuture, однако они имеют разное назначение, сигнатуры и влияни...


🟢 Junior Level

Все три метода используются для реакции на завершение асинхронной стадии и обработки возможных ошибок в CompletableFuture, однако они имеют разное назначение, сигнатуры и влияние на конвейер:

  1. exceptionally(Function<Throwable, T> fallback) (аналог блока catch):
    • Вызывается только при ошибке.
    • Перехватывает исключение и возвращает значение по умолчанию того же типа T.
    • Если ошибки не было — метод просто пропускается.
  2. handle(BiFunction<T, Throwable, U> fn) (полноценный обработчик try-catch-finally):
    • Вызывается всегда (и при успехе, и при ошибке).
    • Принимает два аргумента: результат и исключение.
    • Может изменить тип возвращаемого значения с T на U, а также поглотить ошибку или выбросить новую.
  3. whenComplete(BiConsumer<T, Throwable> action) (аналог блока finally / шпион):
    • Вызывается всегда.
    • Предназначен только для побочных эффектов (логирование, закрытие дескрипторов, сбор метрик).
    • Не может изменить результат и не поглощает ошибку: конвейер продолжает нести исходный результат или ошибку дальше.

Быстрое визуальное сравнение

CompletableFuture<String> source = fetchDataAsync();

// 1. exceptionally: вызывается ТОЛЬКО при сбое, тип String не меняется
source.exceptionally(ex -> "default_value");

// 2. handle: вызывается ВСЕГДА, может превратить String в Integer
source.handle((val, ex) -> ex != null ? -1 : val.length());

// 3. whenComplete: вызывается ВСЕГДА, ничего не возвращает, сохраняет исходный String
source.whenComplete((val, ex) -> {
    if (ex != null) log.error("Сбой", ex);
    else log.info("Успех: {}", val);
});

🟡 Middle Level

Сравнительная матрица функциональности

Критерий exceptionally handle whenComplete
Функциональный интерфейс Function<Throwable, ? extends T> BiFunction<? super T, Throwable, ? extends U> BiConsumer<? super T, ? super Throwable>
Когда вызывается? Только при ошибке Всегда (успех и ошибка) Всегда (успех и ошибка)
Может изменить значение? Да (возвращает fallback) Да (возвращает результат функции) ❌ Нет
Может изменить тип (T $\rightarrow$ U)? ❌ Нет (только T) ✅ Да (возвращает CompletableFuture<U>) ❌ Нет (сохраняет CompletableFuture<T>)
Поглощает ли ошибку? ✅ Да (если вернул значение) ✅ Да (если вернул значение) ❌ Нет (ошибка летит дальше)
Основное назначение Fallback при сбое Трансформация исхода (Map & Recover) Мониторинг / Clean-up (Spy)

Критическая ловушка: проверка на null в handle

В метод handle((result, ex) -> ...) всегда передаются два аргумента, но их состояние зависит от исхода:

  • При успешном завершении: ex == null, а result содержит значение (которое само по себе может быть null!).
  • При аварийном завершении: ex != null, а result == null.
// ❌ ОШИБКА: предположение, что если result == null, то это ошибка
cf.handle((result, ex) -> {
    if (result == null) { // ЛОЖНОЕ СРАБАТЫВАНИЕ: задача могла законно вернуть null!
        return fallback;
    }
    return result;
});

// ✅ ПРАВИЛЬНО: проверять ТОЛЬКО ex != null
cf.handle((result, ex) -> {
    if (ex != null) {
        log.warn("Операция завершилась сбоем: {}", ex.getMessage());
        return fallback;
    }
    return result;
});

🔴 Senior Level

Внутреннее устройство и поведение при выбросе исключений из колбэка

Все три метода реализуются через соответствующие узлы UniCompletion:

  • UniExceptionally
  • UniHandle
  • UniWhenComplete

Поведение whenComplete при двойной ошибке

Что произойдёт, если исходный этап упал с исключением $E_1$, и сама лямбда whenComplete((r, ex) -> { throw new E2(); }) выбросила исключение $E_2$?

CompletableFuture<String> failed = CompletableFuture.failedFuture(new IOException("E1: Network down"));

CompletableFuture<String> monitored = failed.whenComplete((r, ex) -> {
    throw new IllegalStateException("E2: Metric service failed");
});
  1. Главным исключением этапа monitored станет $E_2$ (IllegalStateException).
  2. Исходное исключение $E_1$ (IOException) не теряется: рантайм вызывает E2.addSuppressed(E1)!
  3. Если же исходный этап был успешен, но whenComplete выбросил исключение, этап переходит в статус аварийного с ошибкой из whenComplete.

Замена exceptionally на exceptionallyCompose (Java 12+)

Метод exceptionally имеет существенный архитектурный недостаток: его лямбда синхронна. Если для восстановления после сбоя требуется сделать ещё один асинхронный сетевой вызов (например, сходить в запасной дата-центр), exceptionally заставит заблокировать поток или породит вложенный фьючер CompletableFuture<CompletableFuture<T>>.

В Java 12 добавлен метод exceptionallyCompose:

// Асинхронное восстановление (Monadic catch / flatMap на ошибку)
primaryCluster.queryAsync(data)
    .exceptionallyCompose(ex -> {
        log.error("Основной кластер недоступен, переключаемся на резервный...", ex);
        return secondaryCluster.queryAsync(data); // Возвращает CompletableFuture<Result>
    });

4 Tricky Questions

1. Может ли метод .exceptionally() изменить возвращаемый тип с CompletableFuture<String> на CompletableFuture<Integer>? Как решить эту задачу, если тип изменить необходимо?

Ответ: Нет, метод exceptionally() не может изменить тип. Его сигнатура: exceptionally(Function<Throwable, ? extends T> fn). Функция обязана вернуть значение исходного типа T или его подтипа. Если при ошибке требуется изменить возвращаемый тип конвейера (например, вернуть числовой HTTP-код или обёртку DTO), необходимо использовать метод handle(), сигнатура которого принимает BiFunction<? super T, Throwable, ? extends U> и возвращает CompletableFuture<U>.

2. Что придёт на вход лямбде в .handle((result, ex) -> ...), если исходный CompletableFuture<Void> успешно завершился?

Ответ: Оба аргумента — и result, и ex — придут равными null!

  • ex == null, так как операция завершилась успешно без исключения.
  • result == null, так как тип Void имеет единственное значение в Java — литерал null. Именно поэтому единственным надёжным индикатором успеха или сбоя в handle и whenComplete является проверка ex != null.

3. Почему для сбора метрик и аудита в production-коде рекомендуется использовать whenComplete, а не handle?

Ответ:

  1. Сохранение сигнатуры типа: whenComplete гарантирует, что тип данных T и само значение не изменятся случайно из-за ошибки в коде обработчика.
  2. Невозможность случайного поглощения ошибки: handle по умолчанию поглощает ошибку, если не выбросить новое исключение из лямбды вручную (throw new CompletionException(ex)). Разработчик может забыть это сделать и вернуть null, превратив критическую аварию в незаметный Success(null). whenComplete физически не может поглотить ошибку — она гарантированно полетит дальше.

4. В каком потоке будет выполнен колбэк в handle, exceptionally и whenComplete?

Ответ: Аналогично всем синхронным методам CompletionStage:

  • Если на момент регистрации колбэка предыдущий этап уже был завершён, колбэк выполнится синхронно в вызывающем потоке.
  • Если предыдущий этап ещё вычисляется, колбэк выполнится в том потоке, который завершит этот этап (поток из пула воркеров). Если логика обработки ошибки или логирования блокирующая или тяжелая, необходимо явно использовать их асинхронные модификации: whenCompleteAsync(action, executor), handleAsync(fn, executor) или exceptionallyAsync(fn, executor) (Java 12+).

🎯 Шпаргалка для интервью

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

  • exceptionally: вызывается только при ошибке, тип T не меняется, возвращает запасное значение (fallback).
  • handle: вызывается всегда, может менять тип (T $\rightarrow$ U), может поглотить ошибку или выбросить новую.
  • whenComplete: вызывается всегда, тип T и результат не меняются (side-effect spy). Ошибку не поглощает.
  • При двойном сбое в whenComplete ошибка из обработчика становится основной, а исходная ошибка сохраняется в suppressed.
  • Начиная с Java 12, для неблокирующего восстановления доступен exceptionallyCompose.

Типичные ошибки на интервью

  • Полагать, что whenComplete перехватывает и гасит ошибку (ошибка обязательно передаётся дальше).
  • Проверять result == null вместо ex != null внутри handle.
  • Пытаться вернуть другой тип данных из exceptionally.

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