⏱️ Раздел 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() отменяет предыдущие шаги конвейера.

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