В чём разница между 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()отменяет предыдущие шаги конвейера.