⏱️ Розділ 19 · Питання #4

У чому різниця між 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

  1. thenApply — для швидких локальних синхронних трансформацій даних:
    • Парсинг рядків, мапінг у DTO, конвертація типів, математичні операції, фільтрація.
      fetchOrderFromDb(orderId)
        .thenApply(OrderEntity::toDto)
        .thenApply(dto -> dto.withTax(0.20));
      
  2. 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);
}
  1. Механіка UniApply:
    • При завершенні джерела вузол витягує результат T.
    • Синхронно викликає fn.apply(T).
    • Отриманий результат U атомарно записується у вихідний ф’ючер через completeValue(U).
  2. Механіка 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() скасовує попередні кроки конвеєра.

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