1. 問題: 非同期コードにおける例外
通常(同期)コードでは簡単です。メソッド内で例外が発生すると呼び出しスタックを上方向へ「伝播」し、try-catch で捕捉できます。例えば:
try {
int x = 1 / 0;
} catch (ArithmeticException ex) {
System.out.println("ゼロ除算!");
}
非同期コードでは事情が複雑です。CompletableFuture.supplyAsync でタスクを実行すると、それは別スレッドで動きます。そこで例外が発生しても、メインスレッドには投げられません。代わりに例外は CompletableFuture の内部に「包まれ」、後で get() や join() を呼ぶと、ExecutionException として受け取ることになります。
CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> {
// おっと、ここでエラー!
return 1 / 0;
});
try {
Integer result = future.get(); // ここで例外がスローされる!
} catch (Exception ex) {
System.out.println("エラーが発生しました: " + ex.getMessage());
}
しかし get() を呼ばず(そもそもそれはあまり非同期的ではありません)に、thenApply などでチェーンを組む場合、エラーは「見えなくなる」ことがあります。したがって、非同期プログラミングでは CompletableFuture のチェーン内でエラーを捕捉・処理できることが非常に重要です。
2. exceptionally メソッド: エラーを処理して値を返す
exceptionally は、チェーンの前段で発生した例外を捕捉して処理し、代替の値を返すことができます。非同期データフローにおける catch のようなものです。
シグネチャ:
CompletableFuture<T> exceptionally(Function<Throwable, ? extends T> fn)
使用例
CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> {
System.out.println("危険な計算を実行中…");
if (Math.random() > 0.5) {
throw new RuntimeException("何かがおかしくなりました!");
}
return 42;
});
future = future.exceptionally(ex -> {
System.out.println("エラーが発生しました: " + ex.getMessage());
return 0; // 「安全な」値を返す
});
thenAccept の例
future.thenAccept(result -> System.out.println("結果: " + result));
出力(例):
危険な計算を実行中…
エラーが発生しました: 何かがおかしくなりました!
結果: 0
危険な計算を実行中…
結果: 42
重要: exceptionally は、チェーンのそれ以前で未処理の例外が発生した場合にのみ動作します。すべてが正常なら、そのまま結果を次へ「通過」させます。
3. handle メソッド: 結果とエラーを同時に扱う汎用ハンドラ
ときには結果とエラーの両方を同時に扱いたいことがあります。例えば、成功なら結果を返し、エラーなら代替値を返したり、エラーをログに記録したりします。
シグネチャ:
CompletableFuture<U> handle(BiFunction<? super T, Throwable, ? extends U> fn)
- 第1引数 — 結果(エラーがあれば null)
- 第2引数 — 例外(すべて正常なら null)
使用例
CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> {
if (Math.random() > 0.5) throw new RuntimeException("偶発的なエラー!");
return 100;
});
CompletableFuture<Integer> safeFuture = future.handle((result, ex) -> {
if (ex != null) {
System.out.println("エラーを検出しました: " + ex.getMessage());
return -1;
}
return result;
});
safeFuture.thenAccept(r -> System.out.println("最終結果: " + r));
出力:
エラーを検出しました: 偶発的なエラー!
最終結果: -1
最終結果: 100
handle は、タスクの結果が成功でも失敗でも動作させたいときに用いると便利です。常に呼び出され、2つの引数(正常時の結果/問題発生時の例外)を受け取る汎用的な最終処理器です。
このメソッドは、エラーを一元的にロギングしたい、チェーンを途切れさせずにデフォルト値を返したい、あるいは非同期シナリオを穏当に終了させたい場合に最適です。
例:
CompletableFuture<Integer> future = CompletableFuture
.supplyAsync(() -> 10 / 0) // ここでエラーが発生する
.handle((result, ex) -> {
if (ex != null) {
System.out.println("エラー: " + ex.getMessage());
return 0; // デフォルト値
}
return result;
});
System.out.println(future.join()); // 0 を出力
exceptionally がエラー時にのみ反応するのに対し、handle は常に実行され、1カ所で両方の結果を処理でき、チェーンの滑らかさを保てます。
4. whenComplete メソッド: 完了後の副作用
結果を変更せずに、タスク完了後に何らかの処理だけを実行したいことがあります。例えば、成功か失敗かに関わらず、タスクが完了したことをログに残す場合です。
シグネチャ:
CompletableFuture<T> whenComplete(BiConsumer<? super T, ? super Throwable> action)
- 第1引数 — 結果(エラー時は null)
- 第2引数 — 例外(成功時は null)
使用例
CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> {
if (Math.random() > 0.5) throw new RuntimeException("エラー!");
return 10;
});
future.whenComplete((result, ex) -> {
if (ex != null) {
System.out.println("実行時エラー: " + ex.getMessage());
} else {
System.out.println("正常に完了。結果: " + result);
}
});
重要な違い:
whenComplete は結果やエラーを変更せず、処理を実行するだけです。whenComplete の中で例外が発生した場合、その例外は既存のものに「付加」されます。
例: ログは取るが、介入しない
future
.whenComplete((res, ex) -> {
System.out.println("タスクが完了。エラーあり? " + (ex != null));
})
.thenAccept(r -> System.out.println("ユーザー向けの結果: " + r));
5. 実装上の注意点とニュアンス
ベストプラクティス: CompletableFuture でエラーを正しく処理するには
- 常に非同期タスクのチェーンにエラーハンドリング(exceptionally、handle、または whenComplete)を加えてください。そうしないとエラーが見落とされ、アプリケーションが予測不能に振る舞う恐れがあります。
- メインスレッドで get() や join() を try-catch なしで使わないでください。非同期コードを同期化してしまい、ブロッキングにつながります。
- エラー時に「フォールバック」値を返したい場合は、exceptionally または handle を使いましょう。
- 副作用(ログ記録、ユーザー通知)には whenComplete を使いましょう。
- チェーンでは組み合わせ可能です。例えば、先に exceptionally でエラー処理し、その後 whenComplete でログを取り、そのまま結果処理を続ける、といった流れです。
- 未処理のエラーは次の get()/join() に「伝搬」し、アプリケーションのクラッシュにつながる可能性があることを忘れないでください。
メソッドの配置順序
- exceptionally は、チェーンのそれ以前に発生したエラーのみを捕捉します。
- exceptionally の後の段階(例: thenApply)で再度エラーが起きた場合は、別途処理する必要があります。
- handle は汎用で、エラーの有無に関わらず常に実行されます。
メソッドの組み合わせ
CompletableFuture.supplyAsync(() -> {
// ...
})
.handle((result, ex) -> {
if (ex != null) return "エラー: " + ex.getMessage();
return result;
})
.whenComplete((res, ex) -> {
System.out.println("タスクが終了しました。結果: " + res);
});
エラーを処理しないとどうなる?
例外が未処理のままで get() や join() を呼ぶと、ExecutionException(または CompletionException)としてスローされ、アプリケーションがエラー終了する可能性があります。
6. CompletableFuture におけるエラー処理のよくある間違い
エラー No.1: エラーハンドリングがない。 exceptionally、handle、whenComplete のいずれも追加しないと、エラーは次の get()/join() まで「見えなくなり」、発生箇所から遠く離れた場所でようやく表面化します。
エラー No.2: メインスレッドで try-catch なしに get()/join() を使用する。 これは非同期コードを同期化し、ブロッキングや予期しないクラッシュにつながる可能性があります。
エラー No.3: ハンドラがどこで効くのかを誤解している。 exceptionally は自分より前のエラーのみを捕捉します。これ以降に発生したエラーはこのメソッドでは処理されません。
エラー No.4: エラーを処理するが値を返していない。 exceptionally や handle では必ず値を返してください。そうしないと次の段階が null(または値なし)を受け取ってしまいます。
エラー No.5: handle と whenComplete の混同。 handle は結果を変更できますが、whenComplete は(ログ記録などの)処理を実行するだけです。結果を変更したい場合は handle を使いましょう。
エラー No.6: エラー処理ロジックの重複。 多くの場合、1カ所に集約できます。例えば、中央の handle や共通ハンドラでまとめましょう。
GO TO FULL VERSION