У чому різниця між handle(), exceptionally() і whenComplete()
Усі три методи використовуються для реакції на завершення асинхронної стадії та обробки можливих помилок у CompletableFuture, проте вони мають різне призначення, сигнатури та вп...
🟢 Junior Level
Усі три методи використовуються для реакції на завершення асинхронної стадії та обробки можливих помилок у CompletableFuture, проте вони мають різне призначення, сигнатури та вплив на конвеєр:
exceptionally(Function<Throwable, T> fallback)(аналог блокуcatch):- Викликається тільки при помилці.
- Перехоплює виняток і повертає значення за замовчуванням того самого типу
T. - Якщо помилки не було — метод просто пропускається.
handle(BiFunction<T, Throwable, U> fn)(повноцінний обробникtry-catch-finally):- Викликається завжди (і при успіху, і при помилці).
- Приймає два аргументи: результат та виняток.
- Може змінити тип значення, що повертається з
TнаU, а також поглинути помилку або викинути нову.
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:
UniExceptionallyUniHandleUniWhenComplete
Поведінка 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");
});
- Головним винятком етапу
monitoredстане $E_2$ (IllegalStateException). - Початковий виняток $E_1$ (
IOException) не втрачається: рантайм викликаєE2.addSuppressed(E1)! - Якщо ж вихідний етап був успішним, але
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?
Відповідь:
- Збереження сигнатури типу:
whenCompleteгарантує, що тип данихTі саме значення не зміняться випадково через помилку в коді обробника. - Неможливість випадкового поглинання помилки:
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.