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

У чому різниця між thenCombine() і thenCompose()

Обидва методи зв'язують два обчислення CompletableFuture, але служать абсолютно різним цілям:


🟢 Junior Level

Обидва методи зв’язують два обчислення CompletableFuture, але служать абсолютно різним цілям:

  • thenCombine() використовується для паралельного об’єднання двох незалежних завдань. Завдання виконуються одночасно, а їхні результати об’єднуються третьою функцією (аналог zip).
  • thenCompose() використовується для послідовного зв’язування двох залежних завдань. Друге завдання стартує лише тоді, коли завершилося перше, використовуючи його результат як вхідний параметр (аналог flatMap).

Наочне порівняння

// 1. thenCombine: завдання НЕ ЗАЛЕЖАТЬ одне від одного (виконуються ПАРАЛЕЛЬНО)
CompletableFuture<Double> usdRateFuture = CompletableFuture.supplyAsync(() -> getUsdRate());
CompletableFuture<Double> eurRateFuture = CompletableFuture.supplyAsync(() -> getEurRate());

CompletableFuture<String> ratesReport = usdRateFuture.thenCombine(
    eurRateFuture,
    (usd, eur) -> "USD: " + usd + ", EUR: " + eur
);
// Затримка = max(час_usd, час_eur)

// 2. thenCompose: завдання 2 НЕ МОЖЕ розпочатися без завдання 1 (виконуються ПОСЛІДОВНО)
CompletableFuture<User> userFuture = CompletableFuture.supplyAsync(() -> fetchUser(userId));

CompletableFuture<List<Order>> ordersFuture = userFuture.thenCompose(
    user -> fetchOrdersAsync(user.getAccountId()) // Потрібен accountId з user!
);
// Затримка = час_user + час_orders

🟡 Middle Level

Порівняльна матриця

Характеристика thenCombine thenCompose
Функціональна абстракція Аплікативний функтор (zipWith / liftA2) Монада (bind / flatMap)
Сигнатура аргументу CompletionStage<U>, BiFunction<T, U, V> Function<T, CompletionStage<U>>
Залежність даних Завдання незалежні Завдання 2 залежить від результату Завдання 1
Порядок виконання Конкурентно / Паралельно Суворо послідовно
Підсумковий час (Latency) $\max(T_1, T_2) + T_{\text{combiner}}$ $T_1 + T_2$
Парні аналоги thenAcceptBoth, runAfterBoth —

Типова помилка: випадкова серіалізація паралельних викликів

Часта помилка розробників — використання thenCompose там, де виклики незалежні. Це перетворює паралельну обробку на послідовну, подвоюючи час відповіді:

// ❌ АНТИПАТЕРН: погода та затори не залежать одне від одного, але виконуються послідовно!
CompletableFuture<Dashboard> bad = fetchWeatherAsync()
    .thenCompose(weather -> fetchTrafficAsync()
        .thenApply(traffic -> new Dashboard(weather, traffic)));
// Підсумковий час = 200 мс + 200 мс = 400 мс

// ✅ ПРАВИЛЬНО: запустити обидва запити одночасно
CompletableFuture<Weather> weatherF = fetchWeatherAsync();
CompletableFuture<Traffic> trafficF = fetchTrafficAsync();

CompletableFuture<Dashboard> good = weatherF.thenCombine(
    trafficF, 
    (weather, traffic) -> new Dashboard(weather, traffic)
);
// Підсумковий час = max(200 мс, 200 мс) = 200 мс!

🔴 Senior Level

Поведінка при помилках: ізоляція завдань

Фундаментальна архітектурна відмінність криється в поведінці у разі збоїв:

  1. У thenCompose (Fail-Early):
    • Якщо перший ф’ючер падає з винятком, функція створення другого ф’ючера ніколи не викликається.
    • Другий мережевий запит навіть не буде відправлений.
  2. У thenCombine (Background Leaks):
    • Обидва ф’ючери cf1 і cf2 запускаються паралельно у фонових пулах.
    • Якщо cf1 падає через 10 мс, результуючий ф’ючер thenCombine негайно завершується помилкою CompletionException.
    • Але cf2 продовжує працювати у фоні до кінця! Жодного автоматичного скасування cf2 рантайм thenCombine не робить.

Проблема збереження проміжного контексту в thenCompose

Коли в конвеєрі з 3 кроків останньому кроку потрібні результати всіх попередніх:

// Рішення через запис у record-кортеж (без вкладеного callback hell):
fetchUserAsync(id)
    .thenCompose(user -> fetchAccountAsync(user).thenApply(acc -> new UserAccount(user, acc)))
    .thenCompose(ua -> fetchCardsAsync(ua.acc()).thenApply(cards -> new Profile(ua.user(), cards)));

4 Tricky Questions

1. Яким категоріям функціонального програмування відповідають методи thenCompose і thenCombine?

Відповідь:

  • thenCompose реалізує інтерфейс Монади (Monad): операцію bind (або flatMap). Він приймає функцію $T \to M[U]$ і розгортає вкладений контекст $M[M[U]] \to M[U]$.
  • thenCombine реалізує інтерфейс Аплікативного функтора (Applicative Functor): операцію liftA2 / zipWith. Він бере два незалежні контексти $M[T]$ і $M[U]$ та об’єднує їх за допомогою чистої бі-функції $(T, U) \to V$ у $M[V]$.

2. Якщо в cf1.thenCombine(cf2, (a, b) -> a + b) перший ф’ючер cf1 падає з винятком, що станеться з виконанням cf2?

Відповідь: Ф’ючер cf2 продовжить своє виконання у фоні до кінця. Результуючий ф’ючер негайно завершиться з помилкою, але thenCombine не скасовує другий паралельний ф’ючер автоматично. Якщо cf2 виконував дорогу операцію запису в БД або надсилання листа, вона буде завершена, якщо ви вручну не скасуєте її через обробники помилок.

3. Чи можна виразити thenCombine через thenCompose? Які будуть наслідки?

Відповідь: Так, синтаксично можна:

// Якщо cf1 та cf2 вже запущені паралельно:
cf1.thenCompose(r1 -> cf2.thenApply(r2 -> combine(r1, r2)));

Якщо cf2 був створений до виклику thenCompose, обчислення йтимуть паралельно. Але якщо фабрика другого ф’ючера викликана всередині лямбди:

cf1.thenCompose(r1 -> fetchCf2().thenApply(r2 -> combine(r1, r2)));

Обчислення стане суворо послідовним, і час очікування зросте з $\max(t_1, t_2)$ до $t_1 + t_2$.

4. В якому потоці виконається функція-комбінатор (a, b) -> ... у методі thenCombine?

Відповідь: Функція-комбінатор виконається в тому потоці, який завершив останній (найповільніший) із двох ф’ючерів. Якщо до моменту виклику thenCombine обидва ф’ючери вже були завершені, функція виконається синхронно у викликаючому потоці. Для явного перенесення виконання комбінатора у виділений пул використовується thenCombineAsync(cf2, fn, executor).


🎯 Шпаргалка для інтерв’ю

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

  • thenCombine = паралельно, незалежно, об’єднання двох готових результатів (Applicative / zip). Latency = $\max(t_1, t_2)$.
  • thenCompose = послідовно, залежно, другий ф’ючер стартує за результатом першого (Monad / flatMap). Latency = $t_1 + t_2$.
  • При помилці в thenCompose друге завдання навіть не стартує.
  • При помилці в thenCombine друге завдання продовжує працювати у фоні (немає авто-скасування).
  • Переплутати thenCombine і thenCompose для незалежних завдань означає подвоїти затримку (Latency) сервісу.

Типові помилки на інтерв’ю

  • Використовувати thenCompose для незалежних запитів, руйнуючи паралелізм.
  • Вважати, що thenCombine скасовує другий ф’ючер при збої першого.
  • Плутати вхідні сигнатури: thenCombine приймає BiFunction, thenCompose приймає Function, що повертає CompletionStage.

Пов’язані питання