C#の例外処理とは、実行中に起きた想定外の状況(例外)を try で囲んだ範囲で検知し、catch で対処し、finally で後始末をする仕組みです。 例外を上位に伝え直すときは throw; と書くと、どこで起きたかの情報(スタックトレース)を保ったまま渡せます。

この記事では、throw;・throw ex;・throw new X(..., ex) の3つで スタックトレースがどう変わるかを実際の出力で比較 します。あわせて、よく出る例外23パターンの「発生原因・再現コード・対処」一覧表、when句、using、async/awaitとAggregateException、ArgumentNullException.ThrowIfNull などを使った今の書き方による自作例外テンプレートを、.NET SDK 8.0.131(C# 12)で実行した結果 とともに紹介します。

例外処理の早見表

やりたいこと 書き方
例外を受け取って対処したい try { ... } catch (FormatException ex) { ... }
例外の有無にかかわらず後始末したい finally { ... } または using var x = ...;
例外を自分で発生させたい throw new InvalidOperationException("理由");
受けた例外をそのまま上に伝えたい catch (Exception) { ログ; throw; }
原因を残しつつ、別の例外に包みたい throw new XException("説明", ex);
条件に合うときだけ受けたい catch (ApiException ex) when (ex.Status == ...)
複数の型をまとめて受けたい catch (Exception ex) when (ex is FormatException or OverflowException)
引数がnullなら例外にしたい ArgumentNullException.ThrowIfNull(arg);
例外を使わずに判定したい int.TryParse、dict.TryGetValue、FirstOrDefault
非同期処理の例外を受けたい try { await FooAsync(); } catch (...) { }
独自の例外を定義したい class XException : Exception +コンストラクタ3つ

再スローの3つの書き方は、次のように使い分けます。

書き方 スタックトレース 例外の型 使う場面
throw; 元の発生場所から残る そのまま ログを取ってから上に伝える
throw ex; この行から始まり直す(元の場所が消える) そのまま 基本的に使わず throw; にする(警告 CA2200)
throw new X("説明", ex); 新しい例外はこの行から。元の情報は InnerException に残る 変わる 利用者に分かる言葉・型に言い換えて伝える

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「C#例外処理|try-catch-finallyとthrowの違い・例外一覧」

try-catch-finallyの基本と実行順

基本の形は次の3ブロックです。

try
{
    // 例外が起きるかもしれない処理
}
catch (FormatException ex)
{
    // FormatException が起きたときの対処
}
finally
{
    // 例外の有無にかかわらず、最後に必ず実行する後始末
}
ブロック 役割 省略
try 例外を検知したい範囲 できない
catch 例外の型ごとの対処。複数書ける finally があれば省略できる
finally 必ず実行する後始末(閉じる・解放する・元に戻す) catch があれば省略できる

return があっても finally は実行されます。次の Divide メソッドで順番を確かめます(この記事の後半「基本をまとめたコード」に全体があります)。

static int Divide(int a, int b)
{
    try
    {
        Console.WriteLine($"   try: {a} / {b}");
        return a / b;
    }
    catch (DivideByZeroException ex)
    {
        Console.WriteLine($"   catch: {ex.Message}");
        return -1;
    }
    finally
    {
        Console.WriteLine("   finally: return の前でも必ず実行");
    }
}

実行結果(.NET SDK 8.0.131):

== 1) try-catch-finally の実行順
   try: 10 / 2
   finally: return の前でも必ず実行
   戻り値: 5
   try: 10 / 0
   catch: Attempted to divide by zero.
   finally: return の前でも必ず実行
   戻り値: -1

例外がないときは try → finally、あるときは try → catch → finally の順です。どちらの場合も、呼び出し元に戻る前に finally が実行されます。

catchは上から順に、最初に合ったものだけ実行される

catch を複数書くと、上から順に型が合うかを調べ、最初に合った1つだけが実行されます。

foreach (var input in new[] { "12", "abc", "99999999999", null })
{
    try
    {
        ArgumentNullException.ThrowIfNull(input);
        Console.WriteLine($"   {int.Parse(input)}");
    }
    catch (FormatException) { Console.WriteLine("   FormatException: 数字で入力してください"); }
    catch (OverflowException) { Console.WriteLine("   OverflowException: 値が大きすぎます"); }
    catch (ArgumentException ex) { Console.WriteLine($"   {ex.GetType().Name}: {ex.ParamName} が null です"); }
}

実行結果(.NET SDK 8.0.131):

== 2) catch は上から順に、最初に合ったものだけ
   12
   FormatException: 数字で入力してください
   OverflowException: 値が大きすぎます
   ArgumentNullException: input が null です

ArgumentNullException は ArgumentException を継承しているので、catch (ArgumentException ex) で受け取れます。派生した型(具体的な型)を上に、基底の型(広い型)を下に書く のが決まりです。逆にすると、下の catch に届かなくなるためコンパイルエラー(CS0160)になります。

throw; と throw ex; と throw new でスタックトレースを比べる

再スローの書き方によって、例外が「どこで起きたか」の記録が変わります。次のコードをそのまま Program.cs に貼り、dotnet run で比べられます。

// Program.cs : throw; / throw ex; / throw new X(..., ex) のスタックトレースを比べる(.NET 8)
using System;

class Program
{
    static void Main()
    {
        Run("1) throw;", RethrowKeep);
        Run("2) throw ex;", RethrowReset);
        Run("3) throw new X(..., ex)", Wrap);
    }

    static void Run(string label, Action action)
    {
        Console.WriteLine($"===== {label}");
        try { action(); }
        catch (Exception ex)
        {
            Console.WriteLine($"{ex.GetType().Name}: {ex.Message}");
            Console.WriteLine(ex.StackTrace);
            if (ex.InnerException is { } inner)
            {
                Console.WriteLine($"  --- InnerException: {inner.GetType().Name}: {inner.Message}");
                Console.WriteLine(inner.StackTrace);
            }
        }
    }

    static void RethrowKeep()
    {
        try { LoadOrder(); }
        catch (Exception)
        {
            throw;              // 元のスタックトレースを保ったまま再スロー
        }
    }

    static void RethrowReset()
    {
        try { LoadOrder(); }
        catch (Exception ex)
        {
            throw ex;           // スタックトレースがこの行から始まり直す
        }
    }

    static void Wrap()
    {
        try { LoadOrder(); }
        catch (Exception ex)
        {
            throw new InvalidOperationException("注文の読み込みを完了できませんでした", ex);
        }
    }

    static void LoadOrder() => ParseQuantity("abc");

    static int ParseQuantity(string text) => int.Parse(text);
}

実行結果(.NET SDK 8.0.131。ファイルのパスは省略しています):

===== 1) throw;
FormatException: The input string 'abc' was not in a correct format.
   at System.Number.ThrowFormatException[TChar](ReadOnlySpan`1 value)
   at System.Int32.Parse(String s)
   at Program.ParseQuantity(String text) in Program.cs:line 58
   at Program.LoadOrder() in Program.cs:line 56
   at Program.RethrowKeep() in Program.cs:line 31
   at Program.Run(String label, Action action) in Program.cs:line 16
===== 2) throw ex;
FormatException: The input string 'abc' was not in a correct format.
   at Program.RethrowReset() in Program.cs:line 43
   at Program.Run(String label, Action action) in Program.cs:line 16
===== 3) throw new X(..., ex)
InvalidOperationException: 注文の読み込みを完了できませんでした
   at Program.Wrap() in Program.cs:line 52
   at Program.Run(String label, Action action) in Program.cs:line 16
  --- InnerException: FormatException: The input string 'abc' was not in a correct format.
   at System.Number.ThrowFormatException[TChar](ReadOnlySpan`1 value)
   at System.Int32.Parse(String s)
   at Program.ParseQuantity(String text) in Program.cs:line 58
   at Program.LoadOrder() in Program.cs:line 56
   at Program.Wrap() in Program.cs:line 49

結果を読み比べると、違いがはっきりします。

書き方 出力から分かること
throw; ParseQuantity(58行目)→ LoadOrder(56行目)と、原因の行までたどれる
throw ex; RethrowReset の43行目(throw ex; の行)から始まり、ParseQuantity と LoadOrder の行が消えている
throw new X(..., ex) 外側は52行目から。InnerException に元の FormatException と58行目までの記録が残る

throw ex; と書くと、ビルド時に次の警告も出ます。

Program.cs(43,13): warning CA2200: Re-throwing caught exception changes stack information

受けた例外をそのまま上に伝えるときは throw;、意味を言い換えて伝えるときは第2引数に元の例外を渡して throw new と覚えておくと、調査に必要な情報を残せます。throw new X("説明") のように元の例外を渡さずに包むと、原因の記録がなくなるので、第2引数の ex を忘れずに付けます。

よく出る例外の一覧(原因・再現コード・対処)

次の表は、それぞれの再現コードを実際に実行して、例外の型とメッセージを確かめたものです(.NET SDK 8.0.131)。

例外 発生原因 再現コード 対処
NullReferenceException nullの変数のメンバーを使った string? s = null; s!.Length ?.・?? を使う。null許容参照型を有効にして警告で気づく
ArgumentNullException 引数にnullが渡された ArgumentNullException.ThrowIfNull(name) 呼び出し側でnullを渡さない。受け取り側は冒頭で検証
ArgumentException 引数の内容が受け付けられない ArgumentException.ThrowIfNullOrWhiteSpace(" ", "title") 入力を検証してから渡す
ArgumentOutOfRangeException 引数が範囲外/List<T> の添字が範囲外 list[5](要素3個) Count を確認する。ElementAtOrDefault を使う
IndexOutOfRangeException 配列の添字が範囲外 arr[5](長さ3) Length を確認する。foreach で回す
InvalidOperationException オブジェクトの状態と操作が合わない new List<int>().First() FirstOrDefault や Any() で確認する
InvalidOperationException foreach 中にコレクションを変更した foreach (var x in list) list.Add(x); ToList() でコピーしてから回す
FormatException 文字列を数値や日付に変換できない int.Parse("abc") int.TryParse を使う
OverflowException 型の範囲を超えた int.Parse("99999999999") long を使う。TryParse で判定する
KeyNotFoundException Dictionary にないキーを [ ] で読んだ dict["zzz"] TryGetValue・GetValueOrDefault を使う
InvalidCastException 型変換(キャスト)できない (int)(object)"text" is パターンや as で確認する
DivideByZeroException 整数を0で割った 10 / zero 割る前に0かどうか調べる
FileNotFoundException ファイルがない File.ReadAllText("ないファイル") File.Exists で確認し、あわせて catch も書く
DirectoryNotFoundException 途中のフォルダーがない File.ReadAllText("ないフォルダー/a.txt") Directory.CreateDirectory で作る
UnauthorizedAccessException 権限がない/フォルダーをファイルとして開いた File.ReadAllText("/tmp") パスと権限を確認する
IOException 入出力全般(使用中、容量不足など)。上の2つの基底 ファイルを開いたまま別の処理で開く using で確実に閉じる。時間をおいて再試行する
ObjectDisposedException Dispose済みのオブジェクトを使った ms.Dispose(); ms.WriteByte(1); using の範囲の外で使わない
NotSupportedException その操作に対応していない 読み取り専用コレクションに Add 変更できるコピーを作る
NotImplementedException まだ書いていない処理 throw new NotImplementedException(); 処理を書く。仮置きの目印として使う
TimeoutException 時間内に終わらなかった throw new TimeoutException(); タイムアウト値の見直し、再試行
OperationCanceledException キャンセルが要求された token.ThrowIfCancellationRequested() 正常な中断として扱い、後始末をして終える
TaskCanceledException Taskがキャンセルされた(上の派生型) await Task.Delay(1000, キャンセル済みtoken) catch (OperationCanceledException) でまとめて受ける
JsonException JSONの形式が合わない JsonSerializer.Deserialize<int[]>("[1,") 入力を確認する。メッセージの行・位置を見る

実行で確かめたメッセージの例です(一部)。エラーメッセージで検索するときの手がかりになります。

NullReferenceException | Object reference not set to an instance of an object.
ArgumentNullException | Value cannot be null. (Parameter 'name')
ArgumentOutOfRangeException | Index was out of range. Must be non-negative and less than the size of the collection. (Parameter 'index')
IndexOutOfRangeException | Index was outside the bounds of the array.
InvalidOperationException | Sequence contains no elements
InvalidOperationException | Collection was modified; enumeration operation may not execute.
FormatException | The input string 'abc' was not in a correct format.
OverflowException | Value was either too large or too small for an Int32.
KeyNotFoundException | The given key 'zzz' was not present in the dictionary.
InvalidCastException | Unable to cast object of type 'System.String' to type 'System.Int32'.
DivideByZeroException | Attempted to divide by zero.
FileNotFoundException | Could not find file '/tmp/t192-not-exist.txt'.
ObjectDisposedException | Cannot access a closed Stream.
NotSupportedException | Collection is read-only.
TaskCanceledException | A task was canceled.
JsonException | Expected start of a property name or value, but instead reached end of data. Path: $[1] | LineNumber: 0 | BytePositionInLine: 2.

メッセージが英語か日本語かは、実行環境の言語設定によって変わります。なお、10.0 / 0 のような 浮動小数点数のゼロ除算は例外にならず、結果は Infinity になります(実行で確認)。

例外の継承関係

catch の順番を決めるときは、継承関係を知っておくと役立ちます。次の4つは実行して基底クラスを確かめました。

例外 基底クラス
TaskCanceledException OperationCanceledException
FileNotFoundException IOException
ArgumentNullException ArgumentException
KeyNotFoundException SystemException
flowchart TD
    E[Exception] --> S[SystemException]
    S --> A[ArgumentException]
    A --> AN[ArgumentNullException]
    A --> AO[ArgumentOutOfRangeException]
    S --> IO[IOException]
    IO --> FN[FileNotFoundException]
    IO --> DN[DirectoryNotFoundException]
    S --> OC[OperationCanceledException]
    OC --> TC[TaskCanceledException]
    S --> K[KeyNotFoundException]
    E --> AG[AggregateException]
    E --> C[自作の例外]

基本をまとめたコード(when句・using・async・自作例外)

ここからの説明で使うコードの全体です。dotnet new console -o exc3 で作った Program.cs に貼り付けて dotnet run で実行できます。

// Program.cs : try-catch-finally の基本・when 句・using・async/await・自作例外(.NET 8)
using System;
using System.IO;
using System.Linq;
using System.Net;
using System.Threading;
using System.Threading.Tasks;

// ---- 自作例外のテンプレート([Serializable] やシリアル化用コンストラクタは不要)
public class OrderNotFoundException : Exception
{
    public int OrderId { get; }

    public OrderNotFoundException() { }
    public OrderNotFoundException(string message) : base(message) { }
    public OrderNotFoundException(string message, Exception innerException) : base(message, innerException) { }
    public OrderNotFoundException(int orderId)
        : base($"注文 {orderId} が見つかりません") => OrderId = orderId;
}

class ApiException(HttpStatusCode status) : Exception($"API error {(int)status}")
{
    public HttpStatusCode Status { get; } = status;
}

class Resource(string name) : IDisposable
{
    public void Dispose() => Console.WriteLine($"   Dispose: {name}");
}

class Program
{
    static async Task Main()
    {
        Console.WriteLine("== 3) when 句:条件に合うときだけ catch する");
        foreach (var status in new[] { HttpStatusCode.NotFound, HttpStatusCode.ServiceUnavailable, HttpStatusCode.BadRequest })
        {
            try
            {
                try { throw new ApiException(status); }
                catch (ApiException ex) when (ex.Status == HttpStatusCode.NotFound) { Console.WriteLine("   404: 既定値を使います"); }
                catch (ApiException ex) when ((int)ex.Status >= 500) { Console.WriteLine($"   {(int)ex.Status}: 時間をおいて再試行します"); }
            }
            catch (ApiException ex) { Console.WriteLine($"   {(int)ex.Status}: 外側で捕捉しました"); }
        }
        try { int.Parse("x"); }
        catch (Exception ex) when (ex is FormatException or OverflowException) { Console.WriteLine($"   まとめて捕捉: {ex.GetType().Name}"); }
        try { int.Parse("x"); }
        catch (Exception ex) when (Log(ex)) { }
        catch (FormatException) { Console.WriteLine("   ログのあと、通常の catch に進みます"); }

        Console.WriteLine("== 4) using:例外が出ても Dispose される");
        try
        {
            using var a = new Resource("A");
            using (var b = new Resource("B"))
            {
                throw new InvalidOperationException("処理中に例外");
            }
        }
        catch (InvalidOperationException ex) { Console.WriteLine($"   catch: {ex.Message}"); }

        Console.WriteLine("== 5) async/await:await すると元の例外がそのまま届く");
        try { await FailAsync("A", 10); }
        catch (InvalidOperationException ex) { Console.WriteLine($"   await: {ex.GetType().Name}: {ex.Message}"); }

        try { FailAsync("B", 10).Wait(); }
        catch (AggregateException ex) { Console.WriteLine($"   Wait(): {ex.GetType().Name} → 中身 {ex.InnerException!.GetType().Name}: {ex.InnerException.Message}"); }

        try { FailAsync("C", 10).GetAwaiter().GetResult(); }
        catch (InvalidOperationException ex) { Console.WriteLine($"   GetAwaiter().GetResult(): {ex.GetType().Name}: {ex.Message}"); }

        Console.WriteLine("== 6) Task.WhenAll:await で届くのは最初の1件、全件は Task.Exception に");
        var all = Task.WhenAll(FailAsync("X", 30), FailAsync("Y", 10), OkAsync());
        try { await all; }
        catch (Exception ex)
        {
            Console.WriteLine($"   catch に届いた例外: {ex.Message}");
            Console.WriteLine($"   all.Exception: {all.Exception!.GetType().Name} / 件数 {all.Exception.InnerExceptions.Count}");
            foreach (var e in all.Exception.InnerExceptions) Console.WriteLine($"     - {e.Message}");
        }

        Console.WriteLine("== 7) キャンセル:OperationCanceledException で受ける");
        using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(50));
        try { await Task.Delay(5000, cts.Token); }
        catch (OperationCanceledException ex) { Console.WriteLine($"   {ex.GetType().Name}: {ex.Message}"); }

        Console.WriteLine("== 8) 自作例外");
        try { FindOrder(42); }
        catch (OrderNotFoundException ex) { Console.WriteLine($"   {ex.GetType().Name}: {ex.Message} (OrderId={ex.OrderId})"); }
        try
        {
            try { File.ReadAllText("/tmp/t192-orders.json"); }
            catch (IOException ex) { throw new OrderNotFoundException("注文データを読み込めませんでした", ex); }
        }
        catch (OrderNotFoundException ex) { Console.WriteLine($"   {ex.Message} ← 原因: {ex.InnerException!.GetType().Name}"); }

        Console.WriteLine("== 9) 例外を使わない書き方");
        Console.WriteLine($"   TryParse: {int.TryParse("abc", out var n)} {n}");
        var dict = new System.Collections.Generic.Dictionary<string, int> { ["a"] = 1 };
        Console.WriteLine($"   TryGetValue: {dict.TryGetValue("zzz", out var v)} {v} / GetValueOrDefault: {dict.GetValueOrDefault("zzz", -1)}");
        Console.WriteLine($"   FirstOrDefault: {new int[0].FirstOrDefault()} / File.Exists: {File.Exists("/tmp/t192-orders.json")}");
    }

    static bool Log(Exception ex) { Console.WriteLine($"   [log] {ex.GetType().Name}"); return false; }

    static async Task FailAsync(string name, int ms)
    {
        await Task.Delay(ms);
        throw new InvalidOperationException($"{name} の処理で例外");
    }
    static async Task OkAsync() => await Task.Delay(5);

    static void FindOrder(int id) => throw new OrderNotFoundException(id);
}

1) と 2) の部分(Divide メソッドと catch の順番)は前の章に載せたため、上のコードでは省いています。実行結果(.NET SDK 8.0.131)の 3) 以降は次のとおりです。

== 3) when 句:条件に合うときだけ catch する
   404: 既定値を使います
   503: 時間をおいて再試行します
   400: 外側で捕捉しました
   まとめて捕捉: FormatException
   [log] FormatException
   ログのあと、通常の catch に進みます
== 4) using:例外が出ても Dispose される
   Dispose: B
   Dispose: A
   catch: 処理中に例外
== 5) async/await:await すると元の例外がそのまま届く
   await: InvalidOperationException: A の処理で例外
   Wait(): AggregateException → 中身 InvalidOperationException: B の処理で例外
   GetAwaiter().GetResult(): InvalidOperationException: C の処理で例外
== 6) Task.WhenAll:await で届くのは最初の1件、全件は Task.Exception に
   catch に届いた例外: Y の処理で例外
   all.Exception: AggregateException / 件数 2
     - Y の処理で例外
     - X の処理で例外
== 7) キャンセル:OperationCanceledException で受ける
   TaskCanceledException: A task was canceled.
== 8) 自作例外
   OrderNotFoundException: 注文 42 が見つかりません (OrderId=42)
   注文データを読み込めませんでした ← 原因: FileNotFoundException
== 9) 例外を使わない書き方
   TryParse: False 0
   TryGetValue: False 0 / GetValueOrDefault: -1
   FirstOrDefault: 0 / File.Exists: False

when句で条件に合う例外だけを受ける

catch (型 変数) when (条件) と書くと、型が合い、かつ条件がtrueのときだけ その catch に入ります(例外フィルター、C# 6.0 以降)。

使い方 例
プロパティの値で分ける catch (ApiException ex) when (ex.Status == HttpStatusCode.NotFound)
複数の型をまとめる catch (Exception ex) when (ex is FormatException or OverflowException)
ログだけ取って素通りさせる catch (Exception ex) when (Log(ex))(Log がfalseを返す)

実行結果の 3) では、404 と 503 は内側の catch に入り、条件に合わなかった 400 は内側を通り抜けて外側の catch に届いています。 catch の中で if で判定して throw; し直す書き方と比べると、条件に合わない例外はスタックを巻き戻す前に通過するため、発生時の状態を保ったまま上位に伝わります。

usingで後始末を自動にする

ファイルや接続のように、使い終わったら閉じる必要があるもの(IDisposable を実装した型)は、using を使うと finally を書かずに済みます。

// finally で書く場合
StreamReader? reader = null;
try
{
    reader = new StreamReader("data.txt");
    Console.WriteLine(reader.ReadLine());
}
finally
{
    reader?.Dispose();
}

// using 宣言で書く場合(C# 8.0 以降):スコープを抜けるときに Dispose される
using var reader2 = new StreamReader("data.txt");
Console.WriteLine(reader2.ReadLine());

実行結果の 4) のとおり、例外が起きても Dispose は catch より先に実行されます。 複数ある場合は、宣言した順の逆(B → A)で解放されます。非同期の後始末が必要な型(IAsyncDisposable)には await using を使います。

async/awaitとAggregateException

非同期処理の例外は、待ち方によって届く型が変わります。

待ち方 catchに届く例外
await task 元の例外そのまま(InvalidOperationException など)
task.Wait()・task.Result AggregateException(元の例外は InnerException の中)
task.GetAwaiter().GetResult() 元の例外そのまま
await Task.WhenAll(...) 1件だけ。全件は Task.Exception.InnerExceptions に入る

実行結果の 5) で、await では InvalidOperationException、Wait() では AggregateException が届いていることを確認できます。非同期メソッドは await で待つ のが基本で、そうすれば同期処理と同じ try-catch で書けます。

Task.WhenAll で複数の処理がうまくいかなかった場合、await で届くのは1件だけです。全件を確認したいときは、実行結果の 6) のように Taskを変数に取っておき、all.Exception.InnerExceptions を調べます。

もう1つ、async void のメソッドで起きた例外は呼び出し元の try-catch では受け取れません。イベントハンドラー以外では async Task にして、await で待つ形にします。

キャンセルは、実行結果の 7) のように TaskCanceledException として届きます。これは OperationCanceledException の派生型なので、catch (OperationCanceledException) と書けば両方まとめて受けられます。

自作例外のテンプレートと今の書き方

独自の例外を作るときの基本形です。Microsoft Learn「ユーザー定義の例外を作成する方法」を今回開いて確認したところ、クラス名を Exception で終え、Exception を継承し、コンストラクタを3つ用意する 形が示されています。

public class OrderNotFoundException : Exception
{
    public int OrderId { get; }                       // 追加情報はプロパティで持つ

    public OrderNotFoundException() { }
    public OrderNotFoundException(string message) : base(message) { }
    public OrderNotFoundException(string message, Exception innerException) : base(message, innerException) { }
    public OrderNotFoundException(int orderId)
        : base($"注文 {orderId} が見つかりません") => OrderId = orderId;
}
ポイント 内容
命名 クラス名は 〜Exception で終える
継承元 Exception(または意味の近い既存の例外)
基本のコンストラクタ 引数なし/message/message と innerException の3つ
追加情報 OrderId のような読み取り専用プロパティで持つ
[Serializable] とシリアル化用コンストラクタ .NET 8 以降は書かない(下記)

以前のテンプレートには [Serializable] 属性と protected X(SerializationInfo info, StreamingContext context) のコンストラクタが含まれていました。.NET 8 でこのコンストラクタを書くと、次の警告が出ることを確認しました。

Program.cs(10,81): warning SYSLIB0051: 'Exception.Exception(SerializationInfo, StreamingContext)' is obsolete: 'This API supports obsolete formatter-based serialization. It should not be called or extended by application code.'

新しく書く自作例外では、[Serializable] とシリアル化用コンストラクタは不要 です。

引数の検証はThrowIfで1行にする

.NET 6 以降、引数を検証して例外を投げるための静的メソッドが増えています。if と throw を書く代わりに1行で済み、引数名もメッセージに自動で入ります。

メソッド 例外になる条件 追加されたバージョン
ArgumentNullException.ThrowIfNull(arg) null .NET 6
ArgumentException.ThrowIfNullOrEmpty(str) nullまたは空文字 .NET 7
ArgumentException.ThrowIfNullOrWhiteSpace(str) null・空文字・空白だけ .NET 8
ArgumentOutOfRangeException.ThrowIfNegative(n) 負の値 .NET 8
ArgumentOutOfRangeException.ThrowIfZero(n) ほか 0(ThrowIfGreaterThan なども) .NET 8
ObjectDisposedException.ThrowIf(条件, this) 条件がtrue .NET 7

この表のうち、ThrowIfNull・ThrowIfNullOrWhiteSpace・ThrowIfNegative の3つは今回 .NET 8 で実行して動作を確かめました。追加されたバージョンとそのほかのメソッドは、今回公式ページを開いていないため、一般に知られた仕様として記載しています。

// 書き換え前
if (name is null) throw new ArgumentNullException(nameof(name));

// 書き換え後(.NET 6 以降)
ArgumentNullException.ThrowIfNull(name);

実行すると ArgumentNullException | Value cannot be null. (Parameter 'name') のように、変数名がメッセージに入ります。

既存の例外と自作例外の選び方

状況 使う例外
引数がnull ArgumentNullException
引数の値が範囲外 ArgumentOutOfRangeException
引数の内容が受け付けられない ArgumentException
今の状態ではその操作ができない InvalidOperationException
対応していない操作 NotSupportedException
呼び出し側が型で区別して対処したい、アプリ固有の状況 自作例外

まずは既存の例外で表せないかを考え、呼び出し側が catch で区別したいときに自作例外を作る と、型の数を抑えられます。

例外を使わずに書ける場面

入力の形式違いやキーの有無など、ふつうに起こりうる状況は、例外ではなく戻り値で判定する と、コードが読みやすくなり処理も軽くなります。

例外になる書き方 例外にならない書き方 戻り値
int.Parse(text) int.TryParse(text, out var n) 変換できたかの bool
dict[key] dict.TryGetValue(key, out var v) キーがあったかの bool
dict[key] dict.GetValueOrDefault(key, 既定値) 値または既定値
list.First() list.FirstOrDefault() 要素または既定値
File.ReadAllText(path) File.Exists(path) で先に確認 あるかどうかの bool
(int)obj obj is int n 型が合うかの bool

ファイルは、確認した直後に削除されることもあります。File.Exists で確認したうえで、catch (IOException) も書いておく と安心です。

直す前と直した後

直す前 こうなる 直した後
catch (Exception ex) { throw ex; } 原因の行がスタックトレースから消える catch (Exception) { throw; }
catch { }(空のcatch) 何が起きたか記録に残らない 対処できる型だけ受ける。ログを残して throw;
catch (Exception) を最初に書く 下の catch に届かずコンパイルエラー(CS0160) 具体的な型を上、広い型を下に
throw new XException("説明"); で包む 元の例外の情報がなくなる throw new XException("説明", ex);
task.Wait()・task.Result AggregateException に包まれる await task
finally で Dispose を手書き 書き忘れや順番の間違いが起きやすい using var x = ...;
if (x is null) throw new ArgumentNullException(nameof(x)); 記述が長くなる ArgumentNullException.ThrowIfNull(x);
入力チェックを try { int.Parse } catch で行う 通常の流れに例外を使うことになる int.TryParse
finally の中で return する コンパイルエラー(CS0157)。finally から制御は出せない 戻り値は try か catch で返す

どこでcatchするかの判断表

状況 catchする場所 することの例
その場で回復できる(既定値を使う、再試行する) 例外が起きるすぐ近く when で条件を絞って対処する
利用者に伝わる言葉に言い換えたい 層の境目(データ取得と業務処理の間など) throw new XException("説明", ex);
対処はできないが記録したい 途中の層 ログを取って throw;
どこでも受けなかった例外 アプリの入口(Main、共通のエラー処理) ログを残し、利用者に案内を表示する

対処できない場所では受けずに通す のが基本です。すべてのメソッドに try-catch を書く必要はありません。

よくある質問

Q. throw; と throw ex; の違いは何ですか? A. throw; は受けた例外をスタックトレースを保ったまま再スローします。throw ex; は同じ例外を投げ直しますが、スタックトレースがその行から始まり直し、元の発生場所の記録が消えます。本記事の実行結果では、throw ex; だと ParseQuantity と LoadOrder の行が出力から消えています。

Q. finallyは必ず実行されますか? A. 例外の有無や return にかかわらず実行されます。本記事の Divide の例でも、return より前に finally の出力が出ています。プロセスが強制終了された場合などは実行されないため、重要なデータは処理の途中でも保存しておくと安心です。

Q. catch (Exception) ですべて受けてもよいですか? A. アプリの入口でログを取るために使うのは一般的です。途中の層では、対処できる型だけを受けるか、ログを取ったあと throw; で上に伝えると、原因を追いやすくなります。

Q. 自作例外に [Serializable] は必要ですか? A. .NET 8 以降で新しく書く場合は不要です。シリアル化用のコンストラクタを書くと SYSLIB0051 の警告が出ます。コンストラクタは「引数なし・message・message と innerException」の3つを用意します。

Q. awaitしたのにAggregateExceptionが出るのはなぜですか? A. どこかで .Wait() や .Result を使っていると、例外が AggregateException に包まれます。await に置き換えると、元の例外がそのまま届きます。Task.WhenAll の全件を見たい場合は、Taskの Exception.InnerExceptions を調べます。

まとめ

  • try で検知、catch で対処、finally で後始末。 return があっても finally は実行されます。
  • 再スローは throw;。 throw ex; はスタックトレースがその行から始まり直します(警告 CA2200)。
  • 言い換えて伝えるときは throw new X("説明", ex)。 元の例外は InnerException に残ります。
  • catch は具体的な型を上に。 条件で分けたいときは when 句が使えます。
  • 後始末は using。 例外が起きても Dispose が実行されます。
  • 非同期は await で待つ。 Wait()・Result は AggregateException に包まれます。
  • 自作例外はコンストラクタ3つ、[Serializable] は不要。 引数の検証は ThrowIfNull などで1行にできます。

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「C#例外処理|try-catch-finallyとthrowの違い・例外一覧」

株式会社RJCでは、C#の例外処理やエラーの読み方を仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?