⏱️ Розділ 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. Чому для збору метрик та аудиту в продакшн-коді рекомендується використовувати 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.

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