C#の例外処理:.NET 10で検証したtry・catch・finally

C#のtry・catch・finallyから、例外フィルターwhen、throw;とthrow ex;のスタックトレースの違い、using宣言、ThrowIfNullまで.NET 10で検証。平方根の誤解の訂正と例外のコストの実測つき。

2026年11月改訂: この記事は2021年に韓国語で書いた例外処理の講座を、日本語で書き直したものです。元の4つの例を .NET 10.0.14(C# 14)の既定のコンソールプロジェクトでコンパイル・実行し直しました。どれも動きましたが、直すべき点がありました。①導入部の「負の数の平方根を求めると例外になる」は誤りです。Math.Sqrt(-1) は NaN を返すだけで例外は出ません。②finally の例は null 許容の警告 CS8600 が2つ出ます。今は using 宣言でずっと短く書けます。③throw の例は基底クラスの Exception をそのまま投げ、割られる数としては問題のない a = 0 まで弾いていました。④元記事のコンソールのスクリーンショット(Windows の韓国語メッセージとローカルのパスが写ったもの)は削除し、.NET 10 の実際の出力に置き換えました。さらに例外フィルター when、throw; と throw ex; のスタックトレースの違い、ExceptionDispatchInfo、ThrowIfNull 系のヘルパー、例外のコストの実測を加えています。

C# で try/catch は書けるけれど、どこで捕まえ、どう再スローすればログに原因が残るのか、例外を普通の分岐代わりに使ってよいのかが気になる人向けの記事です。先に結論を書くと、処理できる具体的な例外だけを捕まえ、再スローは throw;、リソースの後始末は using、想定内の失敗は TryParse のような戻り値で扱うのが基本です。以下、それぞれを実際の出力で確かめます。

例外はいつ発生するのか

例外は、実行中に通常の流れを続けられない事態が起きたことを知らせる仕組みです。誰も捕まえなければプロセスは終了します。整数を0で割るだけの2行のプログラムは、.NET 10 では次のように終わります。

int a = 5, b = 0;
Console.WriteLine(a / b);
Unhandled exception. System.DivideByZeroException: Attempted to divide by zero.
   at Program.<Main>$(String[] args) in …/Program.cs:line 2

macOS のシェルで見た終了コードは134でした。.NET(Core)以降、ランタイムの例外メッセージは OS の言語に関係なく英語で出ます。.NET Framework 時代の日本語メッセージで検索しても見つからないので、ログを調べるときに覚えておくと便利です。

元記事は例外の例として「0で割る、負の数の平方根、空の引数」を挙げていましたが、実際に動かすと結果は違いました。

Console.WriteLine($"Math.Sqrt(-1)   = {Math.Sqrt(-1)}");
Console.WriteLine($"5.0 / 0         = {5.0 / 0}");
Console.WriteLine($"int.MaxValue + 1 = {Max() + 1}");
try { Console.WriteLine(checked(Max() + 1)); }
catch (OverflowException e) { Console.WriteLine($"checked: {e.GetType().Name}: {e.Message}"); }
try { int zero = 0; Console.WriteLine(5 / zero); }
catch (DivideByZeroException e) { Console.WriteLine($"5 / 0: {e.GetType().Name}: {e.Message}"); }
static int Max() => int.MaxValue;
Math.Sqrt(-1)   = NaN
5.0 / 0         = ∞
int.MaxValue + 1 = -2147483648
checked: OverflowException: Arithmetic operation resulted in an overflow.
5 / 0: DivideByZeroException: Attempted to divide by zero.

double の演算は IEEE 754 に従って NaN や無限大を返すだけで、例外は投げません。整数のオーバーフローも既定(unchecked)では黙って負の数に回り込みます。例外を期待して try で囲んだのに何も捕まらないというバグはここから生まれます。浮動小数点の結果は double.IsNaN や double.IsFinite で自分で確かめましょう。

try〜catch と catch の順番

try ブロックの中で例外が出ると、型の合う最初の catch が受け取ります。元記事の例(整数の割り算を3回)は .NET 10 でも警告なしでコンパイルできました。

static int Division(int a, int b)
{
    try
    {
        return a / b;
    }
    catch (DivideByZeroException e)
    {
        Console.WriteLine(e.Message);
        return -1;
    }
}
// Main: Division(5, 0)、Division(10, 2)、Division(49, 7) を出力
Attempted to divide by zero.
-1
5
7

catch は複数書けて、上から順に照合されます。そのため広い型を先に書くとコンパイラーが止めます。

try { int z = 0; _ = 1 / z; }
catch (Exception e) { Console.WriteLine(e.Message); }
catch (DivideByZeroException e) { Console.WriteLine("never"); }
Program.cs(3,8): error CS0160: 前の catch 句はこれ、またはスーパー型 ('Exception') の例外のすべてを既にキャッチしました

具体的な型を上に、Exception は一番下(できればプログラム最上位のログ出力箇所1か所だけ)に置きます。元記事の説明どおり、合う catch がなければ例外は呼び出し元のメソッドへ伝わり、最後まで誰も捕まえなければプロセスが終了します。

フローチャート:try ブロックが実行される。例外が出なければ finally を実行し、try 文の後ろへ進む。例外が出たら catch 句を上から順に、型と when フィルターで照合する。合えばその catch ブロックを実行し、finally を経て後ろへ進む(catch 内で throw; した場合を除く)。合う catch がなければ、finally を実行したうえで例外が呼び出し元へ伝わる。
try ブロックのあと制御がどこへ進むか。finally はどの経路でも実行される。合う catch がなく例外がメソッドの外へ出ていく場合も同じ。

例外フィルター when:条件が合うときだけ捕まえる

C# 6 から catch (型 e) when (条件) で条件を付けられます。同じ HttpRequestException でもステータスコードによって扱いを変える例です。

foreach (var code in new[] { 404, 503 })
{
    try
    {
        Fetch(code);
    }
    catch (HttpRequestException e) when (e.StatusCode == System.Net.HttpStatusCode.ServiceUnavailable)
    {
        Console.WriteLine($"{code}: retry later");
    }
    catch (HttpRequestException e) when (Log(e))   // Log は false を返す:記録だけして捕まえない
    {
    }
    catch (HttpRequestException e)
    {
        Console.WriteLine($"{code}: give up ({e.Message})");
    }
}

static void Fetch(int code) =>
    throw new HttpRequestException($"HTTP {code}", null, (System.Net.HttpStatusCode)code);

static bool Log(Exception e) { Console.WriteLine($"  log: {e.Message}"); return false; }
  log: HTTP 404
404: give up (HTTP 404)
503: retry later

503 は1つ目のフィルターで捕まりました。404 は1つ目を通過できず、Log が記録して false を返したので3つ目の catch に進みました。フィルターの利点は、条件が偽ならスタックを巻き戻さないことです。catch の中で if で調べて再スローするのと違い、処理しない例外は元の状態のまま残るので、クラッシュダンプも役に立つままです。

finally と、代わりに使う using 宣言

finally ブロックは例外の有無にかかわらず必ず実行されるので、ファイルや接続を閉じる場所として使われてきました。元記事のファイルコピーの例(A.txt を B.txt に追記)はそのまま動き、A.txt がなければ Could not find file '…/A.txt'. と表示しました。ただし今の既定プロジェクトでは警告が2つ出ます。

Program.cs(14,36): warning CS8600: Null リテラルまたは Null の可能性がある値を Null 非許容型に変換しています。
Program.cs(15,36): warning CS8600: Null リテラルまたは Null の可能性がある値を Null 非許容型に変換しています。

StreamReader sreader = null; と null を入れておき finally で確かめる書き方が原因です。C# 8 の using 宣言を使えば null チェックも finally も要りません。囲んでいるブロックの終わりで、宣言と逆の順に Dispose が呼ばれ、例外が出ても同じです。

File.WriteAllLines("A.txt", ["line 1", "line 2"]);
Console.WriteLine($"copied {Copy("A.txt", "B.txt")} lines");
try { Copy("missing.txt", "B.txt"); }
catch (FileNotFoundException e) { Console.WriteLine($"not found: {Path.GetFileName(e.FileName)}"); }
Console.WriteLine(Order());

static int Copy(string from, string to)
{
    using var reader = new StreamReader(from);              // メソッドの終わりで
    using var writer = new StreamWriter(to, append: true);  // 逆順に Dispose
    int n = 0;
    while (reader.ReadLine() is { } line) { writer.WriteLine(line); n++; }
    return n;
}

static string Order()
{
    try { Console.WriteLine("try"); return "return value"; }
    finally { Console.WriteLine("finally (runs before the caller sees the return)"); }
}
copied 2 lines
not found: missing.txt
try
finally (runs before the caller sees the return)
return value

catch (Exception e) ですべてを受けていた元記事と違い、ここでは FileNotFoundException だけを捕まえています。権限エラーやディスクの故障まで「ファイルがない」と同じ1行のメッセージで飲み込まないためです。Order() の出力のとおり、try の中で return しても finally は呼び出し元が値を受け取る前に実行されます。

throw:引数チェックは ThrowIf 系のヘルパーで

元記事の throw の例は、0以下の値が来ると new Exception(...) で「0または負の数は許可しません」という例外を投げ、出力も元記事と同じでした(5、7、メッセージ)。しかし基底の Exception 型を投げると、受け取る側は catch (Exception) でほかの例外までまとめて捕まえるしかありません。また a = 0 は0を割るだけなので問題ないのに弾いていました。引数チェックには .NET 6〜8 で追加された静的ヘルパーを使います。

Console.WriteLine(Division(10, 2));
Console.WriteLine(Division(49, 7));
foreach (var (a, b) in new[] { (5, -1), (5, 0) })
{
    try { Console.WriteLine(Division(a, b)); }
    catch (ArgumentOutOfRangeException e) { Console.WriteLine($"{e.GetType().Name}: {e.Message}"); }
}
try { Greet(null); }
catch (ArgumentNullException e) { Console.WriteLine($"{e.GetType().Name}: {e.Message}"); }

static int Division(int a, int b)
{
    ArgumentOutOfRangeException.ThrowIfNegative(a);
    ArgumentOutOfRangeException.ThrowIfNegativeOrZero(b);
    return a / b;
}

static string Greet(string? name)
{
    ArgumentNullException.ThrowIfNull(name);
    return "Hello " + name;
}
5
7
ArgumentOutOfRangeException: b ('-1') must be a non-negative and non-zero value. (Parameter 'b')
Actual value was -1.
ArgumentOutOfRangeException: b ('0') must be a non-negative and non-zero value. (Parameter 'b')
Actual value was 0.
ArgumentNullException: Value cannot be null. (Parameter 'name')

引数名('b'、'name')を渡していないのにメッセージに入っているのは [CallerArgumentExpression] のおかげです。ThrowIfNull は .NET 6、ThrowIfNegative と ThrowIfNegativeOrZero は .NET 8 から使えます。

throw; と throw ex;、そして ExceptionDispatchInfo

捕まえた例外をログに残して再スローするとき、throw; と throw ex; では結果が変わります。同じ int.Parse("abc") の失敗を3通りで再スローし、スタックトレースを表示しました(Debug ビルド)。

using System.Runtime.ExceptionServices;

Show("throw;", Demo.Rethrow);
Show("throw ex;", Demo.RethrowEx);
Show("ExceptionDispatchInfo", Demo.Deferred);

static void Show(string title, Action a)
{
    try { a(); }
    catch (Exception e) { Console.WriteLine($"--- {title}\n{e.StackTrace}\n"); }
}

static class Demo
{
    static void Parse() => int.Parse("abc");

    public static void Rethrow()
    {
        try { Parse(); }
        catch (FormatException) { throw; }
    }

    public static void RethrowEx()
    {
        try { Parse(); }
        catch (FormatException ex) { throw ex; }
    }

    public static void Deferred()
    {
        ExceptionDispatchInfo? captured = null;
        try { Parse(); }
        catch (FormatException ex) { captured = ExceptionDispatchInfo.Capture(ex); }
        Console.WriteLine("(cleanup, then rethrow)");
        captured?.Throw();
    }
}
--- throw;
   at System.Number.ThrowFormatException[TChar](ReadOnlySpan`1 value)
   at System.Int32.Parse(String s)
   at Demo.Parse() in Program.cs:line 15
   at Demo.Rethrow() in Program.cs:line 19
   at Program.<<Main>$>g__Show|0_0(String title, Action a) in Program.cs:line 9

--- throw ex;
   at Demo.RethrowEx() in Program.cs:line 26
   at Program.<<Main>$>g__Show|0_0(String title, Action a) in Program.cs:line 9

(cleanup, then rethrow)
--- ExceptionDispatchInfo
   at System.Number.ThrowFormatException[TChar](ReadOnlySpan`1 value)
   at System.Int32.Parse(String s)
   at Demo.Parse() in Program.cs:line 15
   at Demo.Deferred() in Program.cs:line 32
--- End of stack trace from previous location ---
   at Demo.Deferred() in Program.cs:line 35
   at Program.<<Main>$>g__Show|0_0(String title, Action a) in Program.cs:line 9

(パスは Program.cs に縮めています。)throw ex; のトレースは再スローした26行目から始まり、実際に失敗した int.Parse と Demo.Parse() が消えています。本番のログから原因を追えなくなる典型的なミスです。既定のアナライザーもビルド時に警告します。

Program.cs(26,38): warning CA2200: キャッチされた例外が再スローされると、スタック情報が変更されます (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2200)

ExceptionDispatchInfo.Capture(ex).Throw() は、catch ブロックの外で、あとから再スローしたいときに使います。後始末を済ませてから投げる場合や、別スレッドで捕まえた例外を元のスレッドで投げる場合です。元のトレースのあとに --- End of stack trace from previous location --- が付き、再スローした位置が続きます。await が失敗した Task の例外を投げ直すときも、内部ではこの仕組みを使っています。

例外を制御フローに使わない(実測)

「ユーザーが数字以外を入力した」のような想定内の失敗を例外で処理すると、どれだけ損をするのか。不正な文字列 "abc" と正しい文字列 "123" をそれぞれ10万回パースしました。計測方法:.NET 10.0.14、Release ビルド、Stopwatch、10万回、ウォームアップ1回のあと3回計測、Apple Silicon Mac(M1 Pro)。

// 不正な入力を例外で処理
for (int i = 0; i < N; i++) { try { s += int.Parse(bad); } catch (FormatException) { s--; } }
// 不正な入力を戻り値で処理
for (int i = 0; i < N; i++) { if (int.TryParse(bad, out int v)) s += v; else s--; }
// 同じ2つのループを正しい入力 "123" でも計測
ケース 1回目 2回目 3回目
不正な値:Parse + catch 292.67 ms 264.28 ms 262.12 ms
不正な値:TryParse 0.50 ms 0.45 ms 0.45 ms
正しい値:try 内の Parse 0.65 ms 0.62 ms 0.61 ms
正しい値:TryParse 0.66 ms 0.61 ms 0.61 ms
10万回のパースにかかった時間の棒グラフ。不正な入力を int.Parse と catch で処理:273.0ms。不正な入力を int.TryParse で処理:0.47ms。正しい入力を try 内の int.Parse で処理:0.63ms。正しい入力を int.TryParse で処理:0.63ms。
10万回のパース、計測3回の平均。.NET 10.0.14、Release、Stopwatch、Apple Silicon Mac(M1 Pro)。コストは例外を投げることから生じ、try ブロック自体ではない。

例外を1回投げて捕まえるのに平均約 2.7µs、TryParse の約580倍でした。呼び出しスタックの浅いこの例でこれなので、深いスタックではさらに増えます。一方、例外が出ないときの try ブロック自体のコストは計測できませんでした(0.63ms 対 0.63ms)。つまり try を置くのはほぼタダで、高いのは投げることです。1秒に数回しか起きない本物のエラーなら2.7µsは問題になりませんが、入力チェック、ファイルの存在確認、辞書の検索のように失敗がよくある経路では、TryParse、File.Exists、TryGetValue のような Try パターンを使いましょう。

まとめ:場面ごとの選び方

場面 おすすめ
想定内の失敗(入力形式、キーがない) TryParse・TryGetValue などの Try パターン
不正な引数 ArgumentNullException.ThrowIfNull、ArgumentOutOfRangeException.ThrowIfNegative など
特定の条件の例外だけ処理 catch (T e) when (条件)
ログを残して再スロー throw;(throw ex; は使わない、CA2200)
catch の外であとから再スロー ExceptionDispatchInfo.Capture(ex).Throw()
ファイル・接続の後始末 using var 宣言(finally と null チェックの代わり)
浮動小数点の異常値 例外は出ないので double.IsNaN/IsFinite で確認
catch (Exception) プログラム最上位のログ出力箇所1か所だけ

イベントハンドラーの中で出た例外がマルチキャストのチェーンでどう扱われるかは、デリゲートとイベント と Func と Action デリゲート のマルチキャストの罠の節を参照してください。

検証環境:.NET SDK 10.0.403 / ランタイム 10.0.14、C# 14、macOS(Apple Silicon、M1 Pro)。コンパイラーのメッセージは SDK の日本語表示、ランタイムの例外メッセージは英語のままです。

よくある質問

catch の中の throw; と throw ex; は何が違う?

throw; は元のスタックトレースを保ち、throw ex; はその行で新たに投げたかのようにトレースを上書きします。.NET 10 で確かめると、throw ex; の方は実際に失敗した int.Parse と Parse() のフレームが消え、ビルド時に warning CA2200 も出ました。再スローには throw; を使います。

負の数の平方根や、実数の0除算で例外は出る?

出ません。Math.Sqrt(-1) は NaN、5.0 / 0 は無限大(∞)を返し、例外にはなりません。DivideByZeroException は整数の除算でだけ、OverflowException は checked コンテキストでだけ発生します。

例外を投げて捕まえるコストはどのくらい?

Release ビルド、Apple Silicon Mac で、不正な文字列10万件を int.Parse + catch で処理すると平均273.0ms(1回約2.7µs)、int.TryParse では0.47msでした。例外が出ない場合、try ブロックの有無で差はありませんでした(どちらも0.63ms)。

  • #C#
  • #例外処理
  • #.NET
  • #エラー処理

t.laboratory

実際に作って運用しているアプリ・サーバー・サービスで起きた問題と、その解決に使ったコードを記録する技術ブログです。

運営者情報 · アプリ

↑