⏱️ Раздел 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.

Связанные вопросы