C# の例外処理は try-catch-finally の基本を覚えるだけでは不十分です。throw と throw ex の決定的な違い・InnerException による例外ラップ・カスタム例外クラスの設計・async/await での例外処理・「例外を丸飲み」するアンチパターンまで理解して初めて、堅牢なアプリケーションを書けるようになります。
本記事では例外処理の基礎から実践的な設計原則・よくある落とし穴まで体系的に解説します。
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 … 例外の有無にかかわらず必ず実行(省略可)catch と finally はどちらか一方だけでも書ける(try-finally や try-catch は有効)
例外クラスの階層構造と主要な例外型
すべての例外は System.Exception を継承しています。より具体的な例外クラスを catch に指定することで、エラーの原因に応じた処理ができます。catch は具体的な例外から汎用的な例外の順に書く必要があります。
| 例外クラス | 意味 | 補足 |
|---|---|---|
Exception |
すべての例外の基底クラス | 最終的な安全網として使う |
SystemException |
ランタイム例外の基底 | 直接使うことは少ない |
ArgumentException |
不正な引数 | ArgumentNullException・ArgumentOutOfRangeException の親 |
ArgumentNullException |
引数が null | ArgumentException の派生 |
ArgumentOutOfRangeException |
引数が有効範囲外 | ArgumentException の派生 |
InvalidOperationException |
現在の状態では操作できない | オブジェクトの状態が不正なとき |
NullReferenceException |
null を参照した | 可能な限りコード側で防ぐ(後述) |
IndexOutOfRangeException |
配列の範囲外アクセス | 長さチェック後にアクセスする |
FormatException |
文字列を型変換できない | TryParse 系で回避可能 |
OverflowException |
数値がオーバーフロー | checked 演算子で明示的に発生させることも |
DivideByZeroException |
ゼロ除算 | 除数を事前チェックで回避 |
IOException |
I/O エラー全般 | FileNotFoundException などの基底 |
TaskCanceledException |
タスクがキャンセルされた | CancellationToken 連携 |
OperationCanceledException |
操作がキャンセルされた | TaskCanceledException の親 |
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 の決定的な違い
例外を再スローするとき、throw と throw ex はスタックトレースの保持において根本的に異なります。この違いを知らずに 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 による例外ラップ
低レベルの例外(SQLException・IOException など)を呼び出し側が知る必要のない実装詳細として隠し、より意味のある高レベルな例外でラップするパターンです。
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) |
| カスタムプロパティ | ドメイン固有の情報を持たせる | ErrorCode・RequiredAmount など |
| 適切な派生 | 汎用 → 具体的な階層を設計 | DomainException → InsufficientFundsException |
using ステートメントと finally の関係
IDisposable を実装したリソース(ファイル・DB接続・HTTP クライアント等)は、using ステートメントで囲むと例外発生時でも確実に Dispose() が呼ばれます。finally で Dispose() を呼ぶのと等価です。
// ── 旧来の 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-catch を 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}");
}
}
}
例外処理の設計原則
| 原則 | 内容 | 理由 |
|---|---|---|
| 例外は例外的な状況にのみ使う | 正常フローの制御に例外を使わない。int.TryParse / Dictionary.TryGetValue など Try 系メソッドで通常パスを処理する |
例外のスローはコストが高い |
| 具体的な例外を catch する | catch (Exception) だけに頼らず、FormatException や IOException を個別に処理する |
問題の種類に応じた対処ができる |
| 例外を丸飲みしない | catch { }(空の catch)や catch (Exception) { } でエラーを握りつぶさない |
問題の検出が困難になる |
| throw のみで再スロー | throw ex は絶対に使わない。スタックトレースを保持する throw のみを使う |
デバッグ時の追跡が容易になる |
| ログを取ってから再スロー | 一度 catch してログを残してから throw でそのまま再スローするパターンが有効 |
問題の記録と伝播の両立 |
| finally でリソースを解放する | using ステートメントか finally でリソースを確実に解放する |
メモリリーク・ロックの防止 |
よくある落とし穴と注意点
空の catch ブロック(例外の丸飲み)
// NG: 例外を無視する(何も対処しない)
try
{
ProcessData();
}
catch (Exception)
{
// 何もしない ← エラーが隠蔽されてデバッグが困難になる
}
// OK: 少なくともログを残す
try
{
ProcessData();
}
catch (Exception ex)
{
logger.LogError(ex, "ProcessData で予期しないエラーが発生しました");
throw; // ログしてから再スロー
}
catch の順序を誤る(コンパイルエラー)
// NG: Exception(汎用)を先に書くと、後の catch に到達しない
try { /* ... */ }
catch (Exception ex) { /* ... */ } // ← 先に書くと
catch (FormatException ex2) { /* ... */ } // ← ここには絶対に来ない(コンパイルエラー)
// OK: 具体的な例外から先に書く
try { /* ... */ }
catch (FormatException ex) { /* ... */ } // 具体的な例外を先に
catch (ArgumentException ex){ /* ... */ }
catch (Exception ex) { /* ... */ } // 汎用は最後
パフォーマンス:正常フローで例外を使う
// 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("数値ではありません");
TryParse・TryGetValue・?.演算子 などで例外を使わない設計にしましょう。よくある質問
try { } finally { } だけでも書けます。この形は「例外はここで処理しないが、リソースだけは確実に解放したい」場面で使います。例外はそのまま呼び出し元に伝播します。ただし多くのケースでは using ステートメントで置き換えた方がシンプルです。catch { }(引数なし)と catch (Exception ex) { } はほぼ同じ動作です。ただし引数なしの catch は例外オブジェクト(ex)を参照できないため、ログの取り方が限られます。現代の C# では catch (Exception ex) を使い、必ずメッセージをログに残すのが標準的です。catch (InsufficientFundsException))②例外にドメイン固有の情報(エラーコード・残高情報など)を付加したい③ライブラリ内部の技術的例外(SQLエラー等)を意味のある業務例外に変換したい。単に「エラーメッセージを変えたい」だけなら既存の例外クラスで十分なことがほとんどです。Task.WhenAll・Parallel.ForEach・Task.Wait などで複数タスクを実行したとき、複数の例外をまとめて格納するクラスです。ex.InnerExceptions(複数形)にすべての例外が入っています。await Task.WhenAll を使う場合は最初の例外が展開されて catch に到達するため、残りの例外は各 Task オブジェクトの .Exception プロパティから確認します。まとめ
| 機能・概念 | 説明 | 重要ポイント |
|---|---|---|
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の基本を参照してください。