【C#】例外処理完全ガイド|try-catch-finally・カスタム例外・throw再スロー・InnerException・設計原則まで

C# の例外処理は try-catch-finally の基本を覚えるだけでは不十分です。throwthrow ex の決定的な違いInnerException による例外ラップ・カスタム例外クラスの設計・async/await での例外処理・「例外を丸飲み」するアンチパターンまで理解して初めて、堅牢なアプリケーションを書けるようになります。

本記事では例外処理の基礎から実践的な設計原則・よくある落とし穴まで体系的に解説します。

スポンサーリンク

try-catch-finally の基本構文

try-catch-finally の全体構造
try
{
    // 例外が発生する可能性のある処理
    int result = int.Parse("abc"); // FormatException が発生
}
catch (FormatException ex)
{
    // 特定の例外を捕捉して処理
    Console.WriteLine($"フォーマットエラー: {ex.Message}");
}
catch (Exception ex)
{
    // それ以外のすべての例外を捕捉(最後に配置)
    Console.WriteLine($"予期しないエラー: {ex.Message}");
}
finally
{
    // 例外の有無に関わらず必ず実行される(リソース解放など)
    Console.WriteLine("finally は必ず実行されます");
}
各ブロックの役割
try … 例外が起きうる処理を囲む
catch … 特定の例外型を捕捉して対処する(複数書ける)
finally … 例外の有無にかかわらず必ず実行(省略可)
catchfinally はどちらか一方だけでも書ける(try-finallytry-catch は有効)

例外クラスの階層構造と主要な例外型

すべての例外は System.Exception を継承しています。より具体的な例外クラスを catch に指定することで、エラーの原因に応じた処理ができます。catch具体的な例外から汎用的な例外の順に書く必要があります。

例外クラス 意味 補足
Exception すべての例外の基底クラス 最終的な安全網として使う
SystemException ランタイム例外の基底 直接使うことは少ない
ArgumentException 不正な引数 ArgumentNullExceptionArgumentOutOfRangeException の親
ArgumentNullException 引数が null ArgumentException の派生
ArgumentOutOfRangeException 引数が有効範囲外 ArgumentException の派生
InvalidOperationException 現在の状態では操作できない オブジェクトの状態が不正なとき
NullReferenceException null を参照した 可能な限りコード側で防ぐ(後述)
IndexOutOfRangeException 配列の範囲外アクセス 長さチェック後にアクセスする
FormatException 文字列を型変換できない TryParse 系で回避可能
OverflowException 数値がオーバーフロー checked 演算子で明示的に発生させることも
DivideByZeroException ゼロ除算 除数を事前チェックで回避
IOException I/O エラー全般 FileNotFoundException などの基底
TaskCanceledException タスクがキャンセルされた CancellationToken 連携
OperationCanceledException 操作がキャンセルされた TaskCanceledException の親

Exception の重要プロパティ

Exception の主要プロパティを使う
try
{
    // わざと例外を発生させる
    throw new InvalidOperationException("操作が無効です",
        new ArgumentException("引数が不正", "userId"));
}
catch (InvalidOperationException ex)
{
    // Message: 人間が読めるエラーの説明
    Console.WriteLine($"Message: {ex.Message}");
    // → Message: 操作が無効です

    // StackTrace: 例外が発生したメソッドの呼び出し履歴(デバッグ用)
    Console.WriteLine($"StackTrace:
{ex.StackTrace}");

    // InnerException: この例外を引き起こした内側の例外
    if (ex.InnerException is not null)
        Console.WriteLine($"InnerException: {ex.InnerException.Message}");
    // → InnerException: 引数が不正 (Parameter 'userId')

    // GetType(): 実際の例外型
    Console.WriteLine($"Type: {ex.GetType().FullName}");
    // → Type: System.InvalidOperationException

    // Data: 例外に追加情報を付与できるディクショナリ
    ex.Data["RequestId"] = "req-12345";
    Console.WriteLine($"Data: {ex.Data["RequestId"]}");
}

throw と throw ex の決定的な違い

例外を再スローするとき、throwthrow exスタックトレースの保持において根本的に異なります。この違いを知らずに throw ex を使うと、デバッグ時に本来の発生源が分からなくなります。

throw vs throw ex の違い
void InnerMethod()
{
    throw new Exception("内部で発生したエラー");
}

// ── NG: throw ex を使うとスタックトレースがリセットされる ──
void BadRethrow()
{
    try
    {
        InnerMethod();
    }
    catch (Exception ex)
    {
        // throw ex は「ここから例外が発生した」と書き換えてしまう
        // InnerMethod() の情報が失われる!
        throw ex; // ← 絶対に使わない
    }
}

// ── OK: throw だけでスタックトレースを保持して再スロー ──
void GoodRethrow()
{
    try
    {
        InnerMethod();
    }
    catch (Exception ex)
    {
        Console.WriteLine($"ログ: {ex.Message}"); // ログだけ取る
        throw; // ← 元のスタックトレースを保持したまま再スロー
    }
}

// ── OK: 別の例外でラップして再スロー(InnerException で元の例外を保持)──
void WrapAndRethrow()
{
    try
    {
        InnerMethod();
    }
    catch (Exception ex)
    {
        // 意味のある例外でラップし、元の例外は InnerException に渡す
        throw new InvalidOperationException("処理に失敗しました", ex); // ← 推奨
    }
}
throw ex はスタックトレースを現在位置にリセットしてしまい、「どのメソッドで例外が発生したか」の情報が失われます。再スローするなら必ず throw のみ(引数なし)を使ってください。例外をラップする場合は第2引数に元の例外(innerException)を渡しましょう。

InnerException による例外ラップ

低レベルの例外(SQLExceptionIOException など)を呼び出し側が知る必要のない実装詳細として隠し、より意味のある高レベルな例外でラップするパターンです。

例外ラップの実践例
class UserRepository
{
    public User FindById(int id)
    {
        try
        {
            // DB アクセスの低レベル処理(例: MySQL 接続タイムアウト)
            return FetchFromDatabase(id); // ← SqlException が発生するとする
        }
        catch (Exception dbEx) // SqlException など低レベルな例外
        {
            // 呼び出し側は DB の詳細を知る必要はない
            // → ドメイン意味のある例外でラップして再スロー
            throw new RepositoryException($"ユーザー(id={id})の取得に失敗しました", dbEx);
        }
    }
}

// カスタム例外(次のセクションで詳説)
class RepositoryException : Exception
{
    public RepositoryException(string message, Exception inner)
        : base(message, inner) { }
}

// 呼び出し側のコード
var repo = new UserRepository();
try
{
    var user = repo.FindById(42);
}
catch (RepositoryException ex)
{
    Console.WriteLine(ex.Message);             // ユーザー取得に失敗
    Console.WriteLine(ex.InnerException?.Message); // 元の DB エラーの詳細
}

カスタム例外クラスの作り方

アプリケーション固有のエラーには、Exception を継承したカスタム例外を作ります。設計上の「これはプログラミングエラーか?それとも業務エラーか?」を明確にできます。

カスタム例外クラスの実装
// 基本パターン: Exception から派生させる
// シリアライズ対応のための3つのコンストラクタを実装するのが慣例
public class DomainException : Exception
{
    // エラーコードなどドメイン固有の情報を持てる
    public string ErrorCode { get; }

    public DomainException()
        : base("ドメインエラーが発生しました") { }

    public DomainException(string message)
        : base(message) { }

    public DomainException(string message, Exception innerException)
        : base(message, innerException) { }

    public DomainException(string errorCode, string message)
        : base(message)
    {
        ErrorCode = errorCode;
    }
}

// より具体的な派生例外
public class InsufficientFundsException : DomainException
{
    public decimal RequiredAmount { get; }
    public decimal CurrentBalance { get; }

    public InsufficientFundsException(decimal required, decimal current)
        : base("E001", $"残高不足です(必要: {required:C}、現在: {current:C})")
    {
        RequiredAmount = required;
        CurrentBalance = current;
    }
}

// 使い方
try
{
    decimal balance = 5000m;
    decimal price   = 8000m;
    if (balance < price)
        throw new InsufficientFundsException(price, balance);
}
catch (InsufficientFundsException ex)
{
    Console.WriteLine(ex.Message);       // 残高不足です(必要: ¥8,000、現在: ¥5,000)
    Console.WriteLine(ex.ErrorCode);     // E001
    Console.WriteLine(ex.RequiredAmount); // 8000
}
要素 意味 備考
Exception を継承 例外クラスの基本ルール 末尾を “Exception” にする命名規則
3つのコンストラクタ シリアライズ互換のための慣例 ()(string)(string, Exception)
カスタムプロパティ ドメイン固有の情報を持たせる ErrorCodeRequiredAmount など
適切な派生 汎用 → 具体的な階層を設計 DomainExceptionInsufficientFundsException

using ステートメントと finally の関係

IDisposable を実装したリソース(ファイル・DB接続・HTTP クライアント等)は、using ステートメントで囲むと例外発生時でも確実に Dispose() が呼ばれます。finallyDispose() を呼ぶのと等価です。

using ステートメント(C# 8 の using 宣言)
// ── 旧来の finally パターン ──
StreamReader? reader = null;
try
{
    reader = new StreamReader("data.txt");
    string content = reader.ReadToEnd();
    Console.WriteLine(content);
}
catch (FileNotFoundException ex)
{
    Console.WriteLine($"ファイルが見つかりません: {ex.FileName}");
}
finally
{
    reader?.Dispose(); // 例外が起きても必ず解放
}

// ── using ステートメント(同等。より簡潔)──
try
{
    using var reader2 = new StreamReader("data.txt"); // スコープ終了時に自動 Dispose
    string content = reader2.ReadToEnd();
    Console.WriteLine(content);
}
catch (FileNotFoundException ex)
{
    Console.WriteLine($"ファイルが見つかりません: {ex.FileName}");
}
// reader2 はここで Dispose() が呼ばれる(例外時も)

// ── using で複数リソースを管理 ──
try
{
    using var input  = new FileStream("input.bin",  FileMode.Open);
    using var output = new FileStream("output.bin", FileMode.Create);
    await input.CopyToAsync(output);
}
catch (IOException ex)
{
    Console.WriteLine($"I/O エラー: {ex.Message}");
}
// input / output の両方が確実に Dispose される
IDisposable を実装したオブジェクトを扱う場合は、原則として using で囲むのが C# のベストプラクティスです。using の詳細はusing宣言とIDisposableの基本を参照してください。

async/await での例外処理

非同期メソッドで発生した例外は、await の時点で再スローされます。try-catchawait の外側に書くだけで、同期コードと同じ感覚で例外を処理できます。

async/await での例外処理
// 非同期メソッドの例外も try-catch で捕捉できる
async Task<string> FetchDataAsync(string url)
{
    using var client = new HttpClient();
    try
    {
        // await で例外が発生するとここで再スローされる
        string response = await client.GetStringAsync(url);
        return response;
    }
    catch (HttpRequestException ex)
    {
        Console.WriteLine($"HTTP エラー: {ex.Message}");
        throw; // 再スロー(throw のみでスタックトレース保持)
    }
}

// 呼び出し側でも catch できる
async Task Main()
{
    try
    {
        string data = await FetchDataAsync("https://example.com/api");
        Console.WriteLine(data);
    }
    catch (HttpRequestException ex)
    {
        Console.WriteLine($"取得失敗: {ex.Message}");
    }
}
複数のタスクを並列実行した場合の例外処理
// Task.WhenAll は複数タスクの例外をまとめて AggregateException として投げる
async Task ParallelFetch()
{
    var task1 = FetchDataAsync("https://api1.example.com");
    var task2 = FetchDataAsync("https://api2.example.com");

    try
    {
        // 両方完了するまで待機。どちらかが失敗すると例外
        string[] results = await Task.WhenAll(task1, task2);
    }
    catch (HttpRequestException ex)
    {
        // await Task.WhenAll は最初の例外を再スローする
        Console.WriteLine($"いずれかのタスクが失敗: {ex.Message}");

        // すべての例外を確認したい場合は Task オブジェクトの Exception を見る
        foreach (var t in new[] { task1, task2 })
        {
            if (t.IsFaulted)
                Console.WriteLine($"タスク失敗: {t.Exception?.InnerException?.Message}");
        }
    }
}
非同期処理の詳細はasync/awaitで非同期処理を簡単に書く方法を参照してください。

例外処理の設計原則

原則 内容 理由
例外は例外的な状況にのみ使う 正常フローの制御に例外を使わない。int.TryParse / Dictionary.TryGetValue など Try 系メソッドで通常パスを処理する 例外のスローはコストが高い
具体的な例外を catch する catch (Exception) だけに頼らず、FormatExceptionIOException を個別に処理する 問題の種類に応じた対処ができる
例外を丸飲みしない catch { }(空の catch)や catch (Exception) { } でエラーを握りつぶさない 問題の検出が困難になる
throw のみで再スロー throw ex は絶対に使わない。スタックトレースを保持する throw のみを使う デバッグ時の追跡が容易になる
ログを取ってから再スロー 一度 catch してログを残してから throw でそのまま再スローするパターンが有効 問題の記録と伝播の両立
finally でリソースを解放する using ステートメントか finally でリソースを確実に解放する メモリリーク・ロックの防止

よくある落とし穴と注意点

空の catch ブロック(例外の丸飲み)

NG: 例外を丸飲みするアンチパターン
// NG: 例外を無視する(何も対処しない)
try
{
    ProcessData();
}
catch (Exception)
{
    // 何もしない ← エラーが隠蔽されてデバッグが困難になる
}

// OK: 少なくともログを残す
try
{
    ProcessData();
}
catch (Exception ex)
{
    logger.LogError(ex, "ProcessData で予期しないエラーが発生しました");
    throw; // ログしてから再スロー
}

catch の順序を誤る(コンパイルエラー)

NG: 汎用例外を先に書く
// NG: Exception(汎用)を先に書くと、後の catch に到達しない
try { /* ... */ }
catch (Exception ex)        { /* ... */ } // ← 先に書くと
catch (FormatException ex2) { /* ... */ } // ← ここには絶対に来ない(コンパイルエラー)

// OK: 具体的な例外から先に書く
try { /* ... */ }
catch (FormatException ex)  { /* ... */ } // 具体的な例外を先に
catch (ArgumentException ex){ /* ... */ }
catch (Exception ex)        { /* ... */ } // 汎用は最後

パフォーマンス:正常フローで例外を使う

NG vs OK: TryParse で例外を避ける
// NG: Parse で例外ありきの処理(コストが大きい)
string input = "abc";
try
{
    int value = int.Parse(input); // FormatException を前提にしている
    Console.WriteLine(value);
}
catch (FormatException)
{
    Console.WriteLine("数値ではありません");
}

// OK: TryParse で例外なしに処理(パフォーマンス優先)
if (int.TryParse(input, out int result))
    Console.WriteLine(result);
else
    Console.WriteLine("数値ではありません");
例外のスローとキャッチは通常の if 文より数十〜数百倍のコストがかかります(スタックトレースの構築が重い)。「ユーザーが数値以外を入力するのは想定内」のような正常パスでは TryParseTryGetValue?.演算子 などで例外を使わない設計にしましょう。

よくある質問

Qcatch なしの try-finally は書けますか?
Aはい、有効な構文です。try { } finally { } だけでも書けます。この形は「例外はここで処理しないが、リソースだけは確実に解放したい」場面で使います。例外はそのまま呼び出し元に伝播します。ただし多くのケースでは using ステートメントで置き換えた方がシンプルです。
Qcatch (Exception ex) と catch の違いは?
AC# では catch { }(引数なし)と catch (Exception ex) { } はほぼ同じ動作です。ただし引数なしの catch は例外オブジェクト(ex)を参照できないため、ログの取り方が限られます。現代の C# では catch (Exception ex) を使い、必ずメッセージをログに残すのが標準的です。
Qカスタム例外を作るべきタイミングは?
A以下のいずれかに該当する場合にカスタム例外を作ります。①呼び出し側が例外の型で処理を分岐したい(catch (InsufficientFundsException))②例外にドメイン固有の情報(エラーコード・残高情報など)を付加したい③ライブラリ内部の技術的例外(SQLエラー等)を意味のある業務例外に変換したい。単に「エラーメッセージを変えたい」だけなら既存の例外クラスで十分なことがほとんどです。
QAggregateException とはどんな例外ですか?
ATask.WhenAllParallel.ForEachTask.Wait などで複数タスクを実行したとき、複数の例外をまとめて格納するクラスです。ex.InnerExceptions(複数形)にすべての例外が入っています。await Task.WhenAll を使う場合は最初の例外が展開されて catch に到達するため、残りの例外は各 Task オブジェクトの .Exception プロパティから確認します。
Qtry-catch は深くネストすべきですか?
A基本的にネストは避けます。try-catch が3階層以上になってきたら、メソッドに切り出す(例外処理の境界を決める)か、例外を上位に伝播させる設計を検討してください。「どのレイヤーで例外を処理するか」を設計時に決めておくと、try-catch の乱立を防げます。例えば「コントローラー層で一括ハンドリング」「リポジトリ層でラップして再スロー」といった役割分担です。

まとめ

機能・概念 説明 重要ポイント
try-catch 例外を捕捉して対処する 具体的な例外から順に書く
finally 例外の有無によらず必ず実行 リソース解放に使う。using で代替可
throw(引数なし) スタックトレースを保持して再スロー throw ex は絶対NG
InnerException 元の例外を保持しながらラップ 低レベル例外を意味のある例外でラップする
カスタム例外 Exception を継承して作成 3つのコンストラクタを実装する慣例
async/await の例外 await の時点で再スロー 同期コードと同じ try-catch で処理
空の catch(NG) 例外を丸飲みするアンチパターン 必ずログを残すか再スローする
TryParse 系 正常フローで例外を避ける 例外スローは通常の if 文より数百倍コスト大

例外フィルター(when 句)の詳細は例外フィルター(when句)を使ったエラー処理を、using とリソース管理の詳細はusing宣言とIDisposableの基本を参照してください。