C# 6(2015年リリース)で catch ブロックに when キーワードが追加され、例外の型だけでなく条件式でもキャッチ対象を絞れるようになりました。これは「例外フィルター(exception filter)」と呼ばれる機能で、CLR 自体は以前から対応していましたが、長らく VB.NET と F# だけで使えた機能が C# にも開放された形です。
この記事では when の動作と実用例を中心に解説し、System.Exception の基礎記事では触れなかった AggregateException と ExceptionDispatchInfo もあわせて紹介します。
catch の when キーワード(例外フィルター)
基本構文
when は catch の直後に条件式を置く構文です(HttpRequestException.StatusCode プロパティは .NET 5 以降で利用可)。
try
{
await httpClient.GetAsync(url);
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.ServiceUnavailable)
{
// 503 Service Unavailable のときだけここに入る
Console.WriteLine("サービスが一時停止中です。");
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.TooManyRequests)
{
// 429 Too Many Requests のときだけここに入る
Console.WriteLine("レート制限に達しました。");
}
catch (HttpRequestException ex)
{
// それ以外の HttpRequestException
Console.WriteLine($"通信エラー: {ex.Message}");
}
条件式が false のブロックは素通りして次の catch を探します。
同じ例外型に対して複数の catch ブロックを並べられるのは when がある場合のみです(when なしで同型の catch を並べるとコンパイルエラー)。
when を使わないと書きにくいケース
when がない時代は、catch ブロックの中で条件分岐して、該当しない場合に throw; で再スローする書き方が一般的でした。
// when を使わない書き方
catch (HttpRequestException ex)
{
if (ex.StatusCode == HttpStatusCode.ServiceUnavailable)
{
Console.WriteLine("サービスが一時停止中です。");
}
else
{
throw; // 該当しないので再スロー
}
}
この書き方には次の問題があります。
- 一度 catch に入った時点でスタックが巻き戻され、デバッガーで例外発生地点を見失う
- 「再スローしたかどうか」の見通しが悪く、
throw ex;と書いてしまう事故が起きやすい
when を使うと、条件に合わない例外は最初からキャッチされていないのと同じ扱いになり、これらの問題を回避できます。
スタックトレースが保持される
when の最大の利点は、条件が false の場合にスタックが巻き戻らない点にあります。
例外が発生すると .NET ランタイムは catch を探して上位フレームへ移動しますが、スタックの巻き戻しは「マッチするブロックが決まってから」行われます。when の条件評価は巻き戻し前に行われるため、条件が合わなかった例外のスタックトレースはそのまま保持されます。
デバッガーの「例外が投げられた瞬間を捕捉する(第一チャンス例外)」機能も、when フィルタリング中は正確な発生地点を指したままです。
ログを残してキャッチしないパターン
when の条件式でメソッド呼び出しができることを利用した実用パターンがあります。
try
{
DoWork();
}
catch (Exception ex) when (LogException(ex))
{
// 条件が常に false なので、このブロックは実行されない
}
bool LogException(Exception ex)
{
logger.LogError(ex, "DoWork で例外が発生しました。");
return false; // 常に false → このブロックではキャッチしない
}
when の条件が false なので実際にはキャッチされず、例外はそのまま上位へ伝播します。スタックが巻き戻る前にログを残せるため、発生地点の情報が最も正確な状態で記録されます。
when の注意点
- 条件式の中で例外が発生した場合、その例外は無視されて条件が
false扱いになる(元の例外探索が継続される) - 複雑な条件を書くと可読性が落ちる。状態を
whenの外に出すことも検討する - ログ以外の副作用(状態変更など)を
when式に持ち込むのは避ける when式は例外発生のたびに評価される。重い処理を入れない
AggregateException — 複数例外の束
Task や Parallel クラスを使う並列処理では、複数の例外が同時発生しうる場面があります。
このとき .NET は AggregateException に包んで一つの例外として伝播します。
InnerExceptions(複数形)
System.Exception の InnerException(単数形)とは別に、AggregateException は InnerExceptions(複数形、IReadOnlyList<Exception>)プロパティを持ちます。
try
{
Task.WaitAll(task1, task2, task3);
}
catch (AggregateException ae)
{
foreach (Exception ex in ae.InnerExceptions)
{
Console.WriteLine($"[{ex.GetType().Name}] {ex.Message}");
}
}
Handle() — 処理できた例外だけを取り除く
Handle メソッドは例外ごとに処理済みかどうかを判定できます。
デリゲートが true を返した例外は処理済みとして取り除かれ、false を返したものは未処理として残ります。
catch (AggregateException ae)
{
ae.Handle(ex =>
{
if (ex is OperationCanceledException)
{
// キャンセルは想定内: 処理済みとしてマーク
return true;
}
return false; // 未処理 → 残りの未処理例外で新しい AggregateException が再スロー
});
}
未処理の例外が一つでもあれば、それらを束ねた新しい AggregateException が再スローされます。
Flatten() — 入れ子を平坦化する
並列タスクの中にさらに並列処理がある場合など、AggregateException がネストされることがあります。Flatten() を使うと内側の AggregateException を展開して一段にまとめられます。
catch (AggregateException ae)
{
foreach (Exception ex in ae.Flatten().InnerExceptions)
{
Console.WriteLine($"[{ex.GetType().Name}] {ex.Message}");
}
}
await を使うと自動展開される
Task.WaitAll や Task.Result では AggregateException がそのままスローされますが、await で非同期的に待つ場合は AggregateException が自動展開され、最初の InnerException が直接スローされます。
// Task.WaitAll → AggregateException がスローされる(InnerExceptions に全例外)
// await Task.WhenAll → 最初の InnerException が直接スローされる
複数タスクの失敗をすべて確認したい場合は、次のようにタスク変数の .Exception.InnerExceptions を参照します。
var whenAll = Task.WhenAll(task1, task2, task3);
try
{
await whenAll;
}
catch
{
// whenAll.Exception は AggregateException で全例外を保持している
foreach (Exception ex in whenAll.Exception!.InnerExceptions)
{
Console.WriteLine($"[{ex.GetType().Name}] {ex.Message}");
}
}
ExceptionDispatchInfo — スタックトレースを保持して後から再スロー
別スレッドや別のコンテキストで発生した例外を一時保存して後で再スローしたい場合、throw ex; では元のスタックトレースが失われます。ExceptionDispatchInfo を使うと保持できます。
using System.Runtime.ExceptionServices;
ExceptionDispatchInfo? captured = null;
var t = Task.Run(() =>
{
try
{
DoWork();
}
catch (Exception ex)
{
captured = ExceptionDispatchInfo.Capture(ex);
}
});
t.Wait();
// 元のスタックトレースを保持したまま再スロー
captured?.Throw();
Throw() を呼ぶと、スタックトレースには元の発生地点と現在の再スロー地点の両方が記録されます。throw capturedException; のように単純に投げ直した場合との差は大きく、原因解析の精度に直結します。
.NET 5 以降では ExceptionDispatchInfo.SetCurrentStackTrace(ex) も使えます。これは「現在の場所のスタックトレースを後付けで例外に紐付ける」機能で、まだスローしていない例外に対しても使えます。
throw ExceptionDispatchInfo.SetCurrentStackTrace(
new InvalidOperationException("再構築した例外"));
なお async/await や Task は内部的に ExceptionDispatchInfo を使って非同期境界を越えたスタックトレースを保持しています。明示的に使う場面は限られますが、スレッドや非同期境界をまたいで例外を伝播させる実装では有効です。
まとめ
whenは catch ブロックに条件を付ける機能。条件がfalseのときはスタックが巻き戻らないため、デバッグ情報が正確に保たれる- 常に
falseを返すwhen式でサイドエフェクト(ログ)だけ実行するパターンは、スタック保持ログとして有用 AggregateExceptionはTask/Parallelから来る複数例外の束。Handle()で選択的に処理し、Flatten()で入れ子を平坦化できるawaitはAggregateExceptionを自動展開するためTask.WaitAllとの挙動差に注意。全例外を見たい場合はTask.Exception.InnerExceptionsを参照するExceptionDispatchInfo.Capture/Throwでスタックトレースを保持したまま例外を後から再スローできる。.NET 5 以降はSetCurrentStackTraceも活用できる