CompletableFuture の「組み合わせ」をざっくり俯瞰する
CompletableFuture の本質は「非同期処理を“値”として扱い、それらをつなぎ合わせること」です。
単発で supplyAsync を使うだけだと「ただの非同期実行」で終わりますが、
本当におもしろくなるのは、複数の非同期処理を「順番につなぐ」「並列に走らせて結果を合わせる」といった“組み合わせ”をし始めてからです。
ここでは、代表的な組み合わせパターンを、
「順次」「並列」「エラー処理」という観点でかみ砕いていきます。
順次に処理をつなぐ:thenApply と thenCompose
thenApply:結果を「同期的に」変換する
thenApply は、
「前の非同期処理の結果を受け取って、普通の処理で変換する」ためのメソッドです。
CompletableFuture<Integer> future =
CompletableFuture.supplyAsync(() -> 10)
.thenApply(x -> x * 2); // 10 → 20 に変換
Integer result = future.join(); // 20
Javaここで起きていることは、
supplyAsyncで「10 を返す非同期処理」を作る- その結果を
thenApplyで受け取り、x * 2して返す
という「1 本の線の上での変換」です。thenApply の中で返すのは「普通の値」であり、
新しい非同期処理ではありません。
thenCompose:非同期処理を「フラットにつなぐ」
thenCompose は、
「前の結果を使ってさらに別の非同期処理を始める」ときに使います。
CompletableFuture<String> future =
CompletableFuture.supplyAsync(() -> "userId-123")
.thenCompose(id -> fetchUserNameAsync(id));
CompletableFuture<String> fetchUserNameAsync(String userId) {
return CompletableFuture.supplyAsync(() -> "UserName of " + userId);
}
Javaもしここで thenApply(fetchUserNameAsync) としてしまうと、
型は CompletableFuture<CompletableFuture<String>> になってしまいます。
thenCompose は、
「CompletableFuture<CompletableFuture<T>> を CompletableFuture<T> に“平らにする”」
イメージです。
つまり、
thenApplyは「値を変換する」thenComposeは「非同期処理をつなげて、ネストを潰す」
という役割分担になっています。
並列に走らせて結果を合わせる:thenCombine と allOf
thenCombine:2 つの非同期結果を「合成」する
thenCombine は、
「2 つの非同期処理を並列に走らせて、両方終わったら結果を合わせる」ためのメソッドです。
CompletableFuture<Integer> f1 =
CompletableFuture.supplyAsync(() -> {
sleep(1000);
return 10;
});
CompletableFuture<Integer> f2 =
CompletableFuture.supplyAsync(() -> {
sleep(800);
return 20;
});
CompletableFuture<Integer> combined =
f1.thenCombine(f2, (x, y) -> x + y);
Integer result = combined.join(); // 30
Javaここでのポイントは、
f1とf2は同時にスタートしている- 両方が終わったタイミングで
(x, y) -> x + yが呼ばれる
という「2 本の線が合流する」形になっていることです。
「A と B を並列にやって、両方の結果が揃ったら C をする」
というパターンにぴったりです。
allOf:複数の非同期処理の「完了待ち」
CompletableFuture.allOf は、
「複数の CompletableFuture が全部終わるのを待つ」ためのユーティリティです。
CompletableFuture<String> f1 = fetchAsync("A");
CompletableFuture<String> f2 = fetchAsync("B");
CompletableFuture<String> f3 = fetchAsync("C");
CompletableFuture<Void> all =
CompletableFuture.allOf(f1, f2, f3);
all.join(); // ここで f1, f2, f3 の完了を待つ
String r1 = f1.join();
String r2 = f2.join();
String r3 = f3.join();
JavaallOf 自体は CompletableFuture<Void> を返すので、
個々の結果は元の f1, f2, f3 から取り出します。
「とにかく全部終わるのを待ちたい」
「終わったあとでそれぞれの結果をまとめて処理したい」
というときに使います。
どれか一つ終わればいい:anyOf
anyOf:最初に終わったものだけを使う
CompletableFuture.anyOf は、
「複数の非同期処理のうち、最初に終わったものの結果だけを使う」ためのメソッドです。
CompletableFuture<String> slow = CompletableFuture.supplyAsync(() -> {
sleep(2000);
return "slow";
});
CompletableFuture<String> fast = CompletableFuture.supplyAsync(() -> {
sleep(500);
return "fast";
});
CompletableFuture<Object> any =
CompletableFuture.anyOf(slow, fast);
Object result = any.join(); // "fast"
Javaここでは、fast が先に終わるので、result は "fast" になります。
「複数のサーバーに同じリクエストを投げて、一番早い応答を採用する」
「メイン経路とフォールバック経路のどちらか早い方を使う」
といったパターンに応用できます。
anyOf の戻り値は CompletableFuture<Object> なので、
実際にはキャストやジェネリクスの工夫が必要になりますが、
「最初に終わったやつだけ欲しい」という意図を表現するにはとても便利です。
エラーを含めて「流れ」として扱う:exceptionally と handle
exceptionally:失敗したときの「代替値」を返す
exceptionally は、
「非同期処理が例外で失敗したときにだけ呼ばれるハンドラ」です。
CompletableFuture<Integer> future =
CompletableFuture.supplyAsync(() -> {
if (true) throw new RuntimeException("失敗");
return 10;
}).exceptionally(ex -> {
System.out.println("エラー: " + ex.getMessage());
return 0; // 代替値
});
Integer result = future.join(); // 0
Java成功した場合は exceptionally は呼ばれず、
失敗した場合だけ「ここでリカバリする」という形になります。
「失敗したらデフォルト値を返す」
「ログだけ出して、値は何かしら返す」
といったパターンに向いています。
handle:成功・失敗どちらも一箇所で扱う
handle は、
「成功時の結果と、失敗時の例外の両方を受け取って処理する」メソッドです。
CompletableFuture<Integer> future =
CompletableFuture.supplyAsync(() -> {
if (Math.random() < 0.5) {
return 10;
} else {
throw new RuntimeException("失敗");
}
}).handle((value, ex) -> {
if (ex != null) {
System.out.println("エラー: " + ex.getMessage());
return 0;
} else {
return value * 2;
}
});
Integer result = future.join();
Javahandle の引数は (結果, 例外) の 2 つで、
- 成功時:
結果に値が入り、例外はnull - 失敗時:
結果はnull(または未定義)、例外に Throwable
という形で渡されます。
「成功と失敗を一箇所でまとめて扱いたい」
「ログを出しつつ、成功時は変換、失敗時は代替値」
といった柔らかい制御ができます。
組み合わせの設計をどう考えるか
「線」と「合流」と「エラーの流れ」を意識する
CompletableFuture の組み合わせを設計するとき、
頭の中に「図」を描くと整理しやすくなります。
一本の線で順次つなぐところは thenApply / thenCompose。
複数の線を合流させるところは thenCombine / allOf / anyOf。
途中でエラーが起きたときの分岐は exceptionally / handle。
こうやって、
「ここは A → B → C と順番につなぐ」
「ここで A と B を並列に走らせて、結果を合わせて C に渡す」
「ここで失敗したら、このルートに流す」
といった“フロー”を言葉にできると、
コードも自然とそれに沿った形になります。
thenApply と thenCompose を意識的に使い分ける
特に重要なのは、
- 「ここはただの変換だから thenApply」
- 「ここはさらに非同期処理を始めるから thenCompose」
と意識して書き分けることです。
thenApply で CompletableFuture を返してしまうと、CompletableFuture<CompletableFuture<T>> という「二重の箱」になり、
扱いが一気に難しくなります。
「非同期処理をつなげるときは thenCompose」
という癖をつけておくと、
ネスト地獄を避けられます。
まとめ:CompletableFuture 組み合わせを自分の言葉で説明するなら
あなたの言葉でまとめると、こうなります。
「CompletableFuture の本質は、非同期処理を“値”として扱い、それらを組み合わせること。
一本の線で順次つなぐときは、値の変換なら thenApply、さらに非同期処理を始めるなら thenCompose。
複数の非同期処理を並列に走らせて結果を合わせるときは、2 つなら thenCombine、たくさんなら allOf、どれか一つでよければ anyOf。
エラーを流れの中で扱うには、失敗時だけを扱う exceptionally、成功・失敗両方を一箇所で扱う handle を使う。
大事なのは、コードを書く前に
『ここは順次? 並列? どこで合流? 失敗したらどこへ?』
という“フローの絵”を頭の中に描き、それを CompletableFuture の組み合わせで表現すること。」
