У чому різниця між thenApply() і thenCompose()
Обидва методи — thenApply() та thenCompose() — використовуються для зв'язування етапів асинхронної обробки в конвеєр (пайплайн), але вони принципово відрізняються сигнатурою фун...
🟢 Junior Level
Обидва методи — thenApply() та thenCompose() — використовуються для зв’язування етапів асинхронної обробки в конвеєр (пайплайн), але вони принципово відрізняються сигнатурою функції, яку приймають, та способом розпакування результату:
thenApply(Function<T, U> fn): приймає синхронну функцію, яка перетворює значення типуTна значення типуU. ПовертаєCompletableFuture<U>.thenCompose(Function<T, CompletionStage<U>> fn): приймає функцію, яка сама повертає новий асинхронний етап (CompletionStage<U>). ПовертаєCompletableFuture<U>, сплющуючи (розгортаючи, unwrapping) вкладеність.
Пряма аналогія з Java Stream API та Optional
| Контекст | Перетворення без сплющення (1:1) | Перетворення зі сплющенням вкладеності |
|---|---|---|
Stream<T> |
stream.map(x -> R) $\rightarrow$ Stream<R> |
stream.flatMap(x -> Stream<R>) $\rightarrow$ Stream<R> |
Optional<T> |
opt.map(x -> R) $\rightarrow$ Optional<R> |
opt.flatMap(x -> Optional<R>) $\rightarrow$ Optional<R> |
CompletableFuture<T> |
cf.thenApply(x -> R) $\rightarrow$ CompletableFuture<R> |
cf.thenCompose(x -> CompletableFuture<R>) $\rightarrow$ CompletableFuture<R> |
Приклад у коді
// Початковий етап: отримання ID користувача
CompletableFuture<String> userIdFuture = CompletableFuture.supplyAsync(() -> "usr_42");
// 1. Використання thenApply: функція повертає звичайне значення (Integer)
CompletableFuture<Integer> idLengthFuture = userIdFuture.thenApply(id -> id.length());
// 2. Помилка: спроба використати thenApply з асинхронним методом призводить до "матрьошки"
// remoteService.fetchUser(id) повертає CompletableFuture<User>
CompletableFuture<CompletableFuture<User>> nestedFuture =
userIdFuture.thenApply(id -> remoteService.fetchUser(id)); // ❌ Незручно: вкладений ф'ючер!
// 3. Виправлення: thenCompose автоматично розкриває внутрішній ф'ючер
CompletableFuture<User> flatFuture =
userIdFuture.thenCompose(id -> remoteService.fetchUser(id)); // ✅ Плаский CompletableFuture<User>
🟡 Middle Level
Коли використовувати thenApply, а коли thenCompose
thenApply— для швидких локальних синхронних трансформацій даних:- Парсинг рядків, мапінг у DTO, конвертація типів, математичні операції, фільтрація.
fetchOrderFromDb(orderId) .thenApply(OrderEntity::toDto) .thenApply(dto -> dto.withTax(0.20));
- Парсинг рядків, мапінг у DTO, конвертація типів, математичні операції, фільтрація.
thenCompose— для організації послідовних залежних асинхронних операцій:- Коли результат кроку А необхідний для формування параметрів запиту кроку Б (наприклад, спочатку отримуємо користувача за токеном, потім за його
accountIdзапитуємо список рахунків із мікросервісу).authService.authenticate(token) // -> CompletableFuture<User> .thenCompose(user -> billingService.getAccount(user.accountId())) // -> CompletableFuture<Account> .thenCompose(account -> cardService.getCards(account.cardId())); // -> CompletableFuture<List<Card>>
- Коли результат кроку А необхідний для формування параметрів запиту кроку Б (наприклад, спочатку отримуємо користувача за токеном, потім за його
Небезпека послідовного виклику незалежних завдань
Дуже поширена помилка проєктування — використання thenCompose там, де завдання не залежать одне від одного:
// ❌ АНТИПАТЕРН: завдання незалежні, але виконуються послідовно (затримка = t1 + t2)
CompletableFuture<Profile> profile = fetchUser(userId)
.thenCompose(user -> fetchCompany(companyId).thenApply(company -> new Profile(user, company)));
// ✅ ПРАВИЛЬНО: запустити обидва завдання паралельно через thenCombine (затримка = max(t1, t2))
CompletableFuture<User> userFuture = fetchUser(userId);
CompletableFuture<Company> companyFuture = fetchCompany(companyId);
CompletableFuture<Profile> profile = userFuture.thenCombine(
companyFuture,
(user, company) -> new Profile(user, company)
);
🔴 Senior Level
Внутрішня реалізація: UniApply vs UniCompose
Під капотом CompletableFuture обидва методи реєструють спеціальні вузли в неблокуючому стеку залежностей (Treiber Stack):
// Метод thenApply породжує вузол UniApply:
public <U> CompletableFuture<U> thenApply(Function<? super T,? extends U> fn) {
return uniApplyStage(null, fn);
}
// Метод thenCompose породжує вузол UniCompose:
public <U> CompletableFuture<U> thenCompose(Function<? super T, ? extends CompletionStage<U>> fn) {
return uniComposeStage(null, fn);
}
- Механіка
UniApply:- При завершенні джерела вузол витягує результат
T. - Синхронно викликає
fn.apply(T). - Отриманий результат
Uатомарно записується у вихідний ф’ючер черезcompleteValue(U).
- При завершенні джерела вузол витягує результат
- Механіка
UniCompose(двофазна підписка):- При завершенні джерела викликається функція
fn.apply(T), яка повертає внутрішнійCompletionStage<U> inner. - Якщо
innerуже завершений, його результат одразу копіюється у вихідний ф’ючер. - Якщо
innerще виконується, вихідний ф’ючер підписується (relay) на завершенняinnerчерез спеціальний слухачUniRelay. - Коли
innerнарешті завершується, потік виконанняinnerвикликає завершення зовнішнього ф’ючера.
- При завершенні джерела викликається функція
У якому потоці завершується thenCompose?
Це ключове питання багатопотокової координації:
CompletableFuture<User> cf1 = fetchUserAsync(); // Виконується в пулі DatabasePool
CompletableFuture<Order> cf2 = cf1.thenCompose(user -> fetchOrderAsync(user)); // fetchOrderAsync в HttpPool
Підсумковий CompletableFuture<Order> завершиться в потоці, який завершить fetchOrderAsync (HttpPool), а не в потоці DatabasePool! Це означає, що будь-які подальші колбеки (thenAccept, thenApply), навішані на результуючий ф’ючер без суфікса Async, продовжать виконуватися в потоках HttpPool.
4 Tricky Questions
1. Що станеться, якщо функція, передана в thenCompose(), поверне null замість екземпляра CompletionStage?
Відповідь:
Результуючий CompletableFuture негайно завершиться аварійно з винятком NullPointerException, загорнутим у CompletionException.
Специфікація CompletionStage суворо вимагає повернення не-null екземпляра етапу. Метод не може продовжити конвеєр при поверненні null, оскільки йому немає на що підписатися.
2. Що робить метод thenComposeAsync(fn, executor): він асинхронно виконує саму функцію fn чи внутрішній ф’ючер, який вона повертає?
Відповідь:
Метод thenComposeAsync відправляє в переданий executor тільки виклик самої функції-фабрики fn (тобто етап обчислення fn.apply(x)).
Те, де і як виконуватиметься повернутий цією функцією внутрішній CompletableFuture<U>, визначається виключно реалізацією самого цього внутрішнього ф’ючера (його власним екзекутором).
3. Якщо перший етап завершився помилкою, чи викличеться функція всередині thenCompose? А якщо перший етап успішний, але внутрішній ф’ючер завершився з винятком?
Відповідь:
- Якщо перший етап завершився винятком: функція всередині
thenComposeвзагалі не буде викликана. Помилка першого етапу негайно передасться результуючому ф’ючеру (коротке замикання / short-circuit). - Якщо перший етап успішний, функція всередині
thenComposeповертаєCompletableFuture, який згодом падає з помилкою: виняток цього внутрішнього ф’ючера транслюється в результуючийCompletableFuture. В обох випадках кінцевий споживач побачитьCompletionException.
4. Якщо скасувати результуючий ф’ючер через composedCf.cancel(true), чи скасується батьківський ф’ючер або внутрішній ф’ючер?
Відповідь:
Ні, ані батьківський, ані внутрішній ф’ючер не скасуються!
У CompletableFuture скасування поширюється тільки вниз ланцюжком (downstream), але ніколи не передається вгору до джерел (upstream). Виклик composedCf.cancel(true) лише переведе сам composedCf у стан CancellationException. Фонові операції в батьківському та внутрішньому ф’ючерах продовжать виконуватися до кінця.
🎯 Шпаргалка для інтерв’ю
Головне за 60 секунд
thenApply— аналогmap: приймаєT -> U, повертаєCompletableFuture<U>. Використовується для синхронних обчислень та конвертацій.thenCompose— аналогflatMap: приймаєT -> CompletableFuture<U>, повертаєCompletableFuture<U>. Використовується для послідовних залежних асинхронних операцій, запобігаючи вкладеностіCompletableFuture<CompletableFuture<U>>.- Для незалежних паралельних завдань замість
thenComposeслід використовуватиthenCombineабоallOf. - Якщо функція всередині
thenComposeповернеnull, ланцюжок впаде зNullPointerException.
Типові помилки на інтерв’ю
- Використовувати
thenApplyдля методів, що повертаютьCompletableFuture, отримуючи вкладені ф’ючери, які вимагають подвійногоjoin(). - Використовувати
thenComposeдля незалежних операцій, перетворюючи паралельну обробку на послідовну і подвоюючи затримку (latency). - Вважати, що
composedFuture.cancel()скасовує попередні кроки конвеєра.