C# Exceptionとは?例外処理の基本からtry-catch・よくあるエラー対処法まで解説

はじめに

C#でアプリケーションを開発していると、実行中に予期しないエラーが発生することがあります。たとえば、存在しないファイルを読み込もうとしたり、nullの変数にアクセスしたり、文字列を数値に変換しようとして失敗したりするケースです。

このような実行時の問題を扱う仕組みが、C#の「Exception」、つまり例外です。

Exceptionを正しく理解しておくと、アプリケーションが突然停止するのを防ぎ、原因を調査しやすくなります。また、ユーザーにわかりやすいエラーメッセージを表示したり、ログを残して本番環境のトラブルに対応したりすることも可能になります。

この記事では、C# Exceptionの基本から、try-catchの書き方、よくある例外の種類、throwの使い方、非同期処理での例外処理、デバッグ時の調査方法まで解説します。

1. C# Exceptionとは?例外の意味と役割

1-1. Exceptionは実行中に発生する「想定外のエラー」を表す仕組み

C#のExceptionとは、プログラムの実行中に発生した異常な状態を表すオブジェクトです。

たとえば、次のような処理では例外が発生する可能性があります。

C#
string text = null;
Console.WriteLine(text.Length);

このコードでは、textnullであるにもかかわらずLengthプロパティにアクセスしているため、NullReferenceExceptionが発生します。

例外は、単なるエラーメッセージではありません。例外オブジェクトには、エラーの種類、メッセージ、発生場所、内部例外など、原因調査に必要な情報が含まれています。

C#では、このExceptionをtry-catchなどで処理することで、エラー発生時にもアプリケーションを適切に制御できます。

1-2. コンパイルエラー・実行時エラー・例外の違い

C#のエラーには、大きく分けてコンパイルエラーと実行時エラーがあります。

コンパイルエラーは、プログラムをビルドする段階で検出されるエラーです。たとえば、文法ミス、型の不一致、存在しない変数の参照などが該当します。

C#
int number = "abc"; // コンパイルエラー

一方、実行時エラーは、プログラムを実行している最中に発生するエラーです。Exceptionは、この実行時エラーを扱うための代表的な仕組みです。

C#
int x = 10;
int y = 0;
int result = x / y; // DivideByZeroException

つまり、コンパイルエラーは実行前に修正すべき問題であり、例外は実行中に発生する可能性のある問題を安全に処理するための仕組みです。

1-3. C#の例外はSystem.Exceptionを基底クラスとして扱われる

C#の例外クラスは、基本的にSystem.Exceptionを基底クラスとして定義されています。

代表的な例外クラスには、次のようなものがあります。

C#
System.Exception
├─ System.SystemException
├─ NullReferenceException
├─ IndexOutOfRangeException
├─ InvalidOperationException
├─ DivideByZeroException
└─ FormatException
└─ System.ApplicationException

通常の開発では、Exceptionクラスや、その派生クラスをcatchして例外処理を行います。

C#
try
{
// 例外が発生する可能性のある処理
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}

Exceptionは多くの例外の親クラスなので、catch (Exception ex)と書くと幅広い例外を受け取ることができます。ただし、すべての例外をまとめて処理すると原因に応じた対応が難しくなるため、使い方には注意が必要です。

1-4. 例外処理が必要になる代表的な場面

C#で例外処理が必要になる場面は多くあります。代表的な例は次のとおりです。

ファイルを読み書きする処理では、ファイルが存在しない、アクセス権限がない、別のプロセスが使用中である、といった理由で例外が発生することがあります。

C#
string text = File.ReadAllText("data.txt");

ユーザー入力を扱う処理では、想定外の文字列が入力されることで変換エラーが発生することがあります。

C#
int age = int.Parse("abc");

データベースや外部APIにアクセスする処理では、ネットワークエラー、タイムアウト、認証エラーなどが発生する可能性があります。

また、非同期処理では、キャンセルやタイムアウトによってTaskCanceledExceptionOperationCanceledExceptionが発生することもあります。

このように、外部環境やユーザー入力に依存する処理では、例外が発生する可能性を考慮して実装することが重要です。

2. C#の例外処理の基本

2-1. try-catchの基本構文

C#で例外処理を行う基本構文は、try-catchです。

C#
try
{
// 例外が発生する可能性のある処理
}
catch (Exception ex)
{
// 例外が発生したときの処理
}

tryブロックには、例外が発生する可能性のある処理を書きます。catchブロックには、例外が発生した場合の処理を書きます。

たとえば、文字列を数値に変換する処理で例外を捕捉する場合は、次のように書けます。

C#
try
{
int number = int.Parse("abc");
Console.WriteLine(number);
}
catch (FormatException ex)
{
Console.WriteLine("数値に変換できませんでした。");
Console.WriteLine(ex.Message);
}

このコードでは、int.Parse("abc")FormatExceptionが発生しますが、catchブロックで処理されるため、プログラム全体が異常終了することを防げます。

2-2. tryブロックで例外が発生したときの処理の流れ

tryブロック内で例外が発生すると、その時点でtryブロック内の残りの処理は実行されません。処理は対応するcatchブロックへ移動します。

C#
try
{
Console.WriteLine("処理開始");

int number = int.Parse("abc");

Console.WriteLine("この行は実行されません");
}
catch (FormatException)
{
Console.WriteLine("FormatExceptionが発生しました");
}

この場合、出力は次のようになります。

処理開始
FormatExceptionが発生しました

int.Parse("abc")で例外が発生したため、その後のConsole.WriteLine("この行は実行されません")は実行されません。

例外が発生すると通常の処理フローが中断されるため、tryブロックには必要最小限の処理を書くことが大切です。

2-3. catchブロックで例外を受け取る方法

catchブロックでは、発生した例外を変数として受け取ることができます。

C#
try
{
int number = int.Parse("abc");
}
catch (FormatException ex)
{
Console.WriteLine(ex.Message);
}

このexには、例外に関する情報が入っています。よく使うプロパティは次のとおりです。

C#
ex.Message      // エラーメッセージ
ex.StackTrace // 例外が発生した場所の情報
ex.InnerException // 内部例外

開発中やログ出力では、MessageだけでなくStackTraceも確認すると、どのメソッドのどの処理で例外が発生したのかを追跡しやすくなります。

2-4. finallyブロックの役割と使いどころ

finallyブロックは、例外が発生してもしなくても最後に必ず実行されるブロックです。

C#
try
{
Console.WriteLine("処理を実行します");
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
finally
{
Console.WriteLine("最後に必ず実行されます");
}

finallyは、主にリソース解放や後片付けに使われます。

たとえば、ファイル、データベース接続、ネットワーク接続などを手動で閉じる必要がある場合に利用されます。

C#
StreamReader reader = null;

try
{
reader = new StreamReader("data.txt");
string text = reader.ReadToEnd();
}
catch (IOException ex)
{
Console.WriteLine(ex.Message);
}
finally
{
reader?.Dispose();
}

ただし、現在のC#ではusing文やusing宣言を使ってリソース解放する場面も多いため、finallyは必ずしも毎回必要になるわけではありません。

2-5. using文・using宣言によるリソース解放との違い

using文やusing宣言は、IDisposableを実装したオブジェクトを自動的に解放するための構文です。

C#
using (var reader = new StreamReader("data.txt"))
{
string text = reader.ReadToEnd();
Console.WriteLine(text);
}

このコードでは、usingブロックを抜けるときに自動的にreader.Dispose()が呼ばれます。例外が発生した場合でもDisposeされます。

C# 8.0以降では、using宣言も使えます。

C#
using var reader = new StreamReader("data.txt");

string text = reader.ReadToEnd();
Console.WriteLine(text);

usingはリソース解放を簡潔に書くための仕組みであり、catchのように例外を処理するものではありません。

つまり、役割は次のように異なります。

try-catch-finally:例外処理と後片付けを行う
using:IDisposableオブジェクトの解放を自動化する

ファイル読み込み時の例外に対応したい場合は、usingとtry-catchを組み合わせることもあります。

C#
try
{
using var reader = new StreamReader("data.txt");
string text = reader.ReadToEnd();
Console.WriteLine(text);
}
catch (IOException ex)
{
Console.WriteLine("ファイルの読み込みに失敗しました。");
Console.WriteLine(ex.Message);
}

3. try-catchの具体的な使い方

3-1. 最も基本的なtry-catchのコード例

C#のtry-catchの基本的な使い方は、例外が発生しそうな処理をtryで囲み、catchで対応することです。

C#
try
{
Console.Write("数値を入力してください: ");
string input = Console.ReadLine();

int number = int.Parse(input);

Console.WriteLine($"入力された数値は {number} です。");
}
catch (FormatException)
{
Console.WriteLine("正しい数値を入力してください。");
}

このコードでは、ユーザーが数値以外の文字列を入力した場合にFormatExceptionが発生します。catchブロックで例外を処理することで、アプリケーションの異常終了を防げます。

ただし、この例のような入力チェックでは、例外処理よりもTryParseを使う方が適している場合があります。

C#
Console.Write("数値を入力してください: ");
string input = Console.ReadLine();

if (int.TryParse(input, out int number))
{
Console.WriteLine($"入力された数値は {number} です。");
}
else
{
Console.WriteLine("正しい数値を入力してください。");
}

予測できる入力ミスは、try-catchではなく事前チェックで防ぐのが基本です。

3-2. 例外の種類ごとにcatchを分ける方法

C#では、例外の種類ごとにcatchブロックを分けることができます。

C#
try
{
string text = File.ReadAllText("data.txt");
int number = int.Parse(text);

Console.WriteLine(number);
}
catch (FileNotFoundException)
{
Console.WriteLine("ファイルが見つかりません。");
}
catch (FormatException)
{
Console.WriteLine("ファイルの内容を数値に変換できません。");
}
catch (Exception ex)
{
Console.WriteLine("予期しないエラーが発生しました。");
Console.WriteLine(ex.Message);
}

このように分けることで、原因に応じて適切なメッセージや処理を実装できます。

ファイルがない場合と、ファイルの中身が不正な場合では、ユーザーに伝えるべき内容も開発者が調査すべき内容も異なります。そのため、想定できる例外は個別にcatchするのが望ましいです。

3-3. 複数のcatchを書くときの順番

複数のcatchを書く場合は、具体的な例外クラスから先に書き、最後に一般的な例外クラスを書く必要があります。

正しい例は次のとおりです。

C#
try
{
string text = File.ReadAllText("data.txt");
}
catch (FileNotFoundException)
{
Console.WriteLine("ファイルが見つかりません。");
}
catch (IOException)
{
Console.WriteLine("ファイル入出力エラーが発生しました。");
}
catch (Exception)
{
Console.WriteLine("予期しないエラーが発生しました。");
}

FileNotFoundExceptionIOExceptionの派生クラスです。そのため、より具体的なFileNotFoundExceptionを先に書きます。

次のように、先にExceptionを書いてしまうと、後続のcatchに到達できません。

C#
try
{
string text = File.ReadAllText("data.txt");
}
catch (Exception)
{
Console.WriteLine("例外が発生しました。");
}
// この後に FileNotFoundException を書いても到達できない

catchの順番は、例外処理の基本として非常に重要です。

3-4. catch(Exception)を使う場合の注意点

catch (Exception)は、多くの例外をまとめて捕捉できます。

C#
try
{
// 何らかの処理
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}

便利に見えますが、使いすぎには注意が必要です。すべての例外を同じように処理してしまうと、本来分けて対応すべきエラーまでまとめて扱ってしまうからです。

たとえば、ファイルが存在しない場合、入力形式が不正な場合、プログラムのバグによるnull参照が発生した場合では、対応方法が異なります。

catch (Exception)を使う場合は、主に次のような場面に限定するとよいでしょう。

アプリケーションの最上位で、予期しない例外をログに残す場合。

C#
try
{
RunApplication();
}
catch (Exception ex)
{
logger.LogError(ex, "予期しないエラーが発生しました。");
Console.WriteLine("エラーが発生しました。時間をおいて再度お試しください。");
}

共通のエラーハンドリングを行う場合。

外部APIやバッチ処理などで、処理全体が落ちないように保護する場合。

ただし、catch (Exception)で例外を受け取ったあと、何もせずに無視するのは避けるべきです。

3-5. 例外メッセージ・StackTraceを確認する方法

例外の原因を調べるときは、MessageStackTraceを確認します。

C#
try
{
string text = null;
Console.WriteLine(text.Length);
}
catch (Exception ex)
{
Console.WriteLine("Message:");
Console.WriteLine(ex.Message);

Console.WriteLine("StackTrace:");
Console.WriteLine(ex.StackTrace);
}

Messageには、例外の概要が入っています。StackTraceには、どのメソッドを経由して例外が発生したかが記録されています。

ログに残す場合は、例外オブジェクト全体を記録できるロガーを使うのが一般的です。

C#
logger.LogError(ex, "処理中に例外が発生しました。");

ex.Messageだけをログに残すと、発生箇所がわからず調査が難しくなることがあります。可能であれば、StackTraceやInnerExceptionも含めて記録しましょう。

4. C#でよく発生するException一覧と対処法

4-1. NullReferenceException:null参照エラー

NullReferenceExceptionは、nullのオブジェクトに対してプロパティやメソッドへアクセスしたときに発生します。

C#
string name = null;
Console.WriteLine(name.Length);

このコードでは、nameがnullであるためLengthにアクセスできません。

対処法としては、nullチェックを行います。

C#
if (name != null)
{
Console.WriteLine(name.Length);
}

また、null条件演算子を使うこともできます。

C#
Console.WriteLine(name?.Length);

C#ではnullable参照型を有効にすることで、nullの可能性がある変数をコンパイル時に検出しやすくなります。

C#
string? name = null;

NullReferenceExceptionはC#で非常によく発生する例外です。原因の多くは、初期化漏れ、戻り値のnull確認不足、依存オブジェクトの設定漏れなどです。

4-2. IndexOutOfRangeException:配列・リストの範囲外アクセス

IndexOutOfRangeExceptionは、配列の範囲外にアクセスしたときに発生します。

C#
int[] numbers = { 1, 2, 3 };
Console.WriteLine(numbers[3]);

配列のインデックスは0から始まるため、この配列で指定できるインデックスは012です。numbers[3]は範囲外です。

対処法は、配列やリストの長さを確認してからアクセスすることです。

C#
if (index >= 0 && index < numbers.Length)
{
Console.WriteLine(numbers[index]);
}

List<T>の場合は、範囲外アクセスでArgumentOutOfRangeExceptionが発生することがあります。

C#
List<int> list = new List<int> { 1, 2, 3 };
Console.WriteLine(list[3]);

配列やリストを扱うときは、要素数とインデックスの関係を常に意識しましょう。

4-3. InvalidOperationException:不正な状態での操作

InvalidOperationExceptionは、オブジェクトの現在の状態では実行できない操作を行ったときに発生します。

代表例は、空のコレクションに対してFirst()を呼び出すケースです。

C#
var numbers = new List<int>();

int first = numbers.First();

この場合、要素が存在しないため例外が発生します。

対処法としては、事前に要素が存在するか確認します。

C#
if (numbers.Any())
{
int first = numbers.First();
}

または、FirstOrDefault()を使う方法もあります。

C#
int first = numbers.FirstOrDefault();

ただし、FirstOrDefault()は値型の場合に既定値を返すため、0が実際の値なのか、要素がなかった結果なのかを区別したい場合は注意が必要です。

InvalidOperationExceptionは、列挙中のコレクションを変更した場合にも発生することがあります。

C#
foreach (var number in numbers)
{
numbers.Add(number);
}

このような場合は、別のリストに追加する、コピーを作ってから処理するなどの対応が必要です。

4-4. ArgumentException/ArgumentNullException:引数エラー

ArgumentExceptionは、メソッドに渡された引数が不正な場合に発生します。ArgumentNullExceptionは、nullを許可していない引数にnullが渡された場合に発生します。

C#
void PrintName(string name)
{
if (name == null)
{
throw new ArgumentNullException(nameof(name));
}

Console.WriteLine(name);
}

空文字や不正な形式をチェックする場合は、ArgumentExceptionを使います。

C#
void SetUserName(string userName)
{
if (string.IsNullOrWhiteSpace(userName))
{
throw new ArgumentException("ユーザー名は必須です。", nameof(userName));
}

Console.WriteLine(userName);
}

引数エラーは、メソッドの利用者に「渡した値が不正である」と伝えるための重要な例外です。

ライブラリや共通メソッドを作る場合は、不正な引数を早い段階で検出することで、後続処理で原因不明の例外が発生するのを防げます。

4-5. FormatException:文字列の変換エラー

FormatExceptionは、文字列の形式が期待した形式と異なる場合に発生します。

C#
int number = int.Parse("abc");

"abc"は数値として解釈できないため、FormatExceptionが発生します。

ユーザー入力や外部データを変換する場合は、ParseよりもTryParseを使う方が安全です。

C#
string input = "abc";

if (int.TryParse(input, out int number))
{
Console.WriteLine(number);
}
else
{
Console.WriteLine("数値に変換できません。");
}

日付変換でも同様です。

C#
string input = "2026/01/01";

if (DateTime.TryParse(input, out DateTime date))
{
Console.WriteLine(date);
}
else
{
Console.WriteLine("日付に変換できません。");
}

外部から受け取る文字列は、常に想定どおりとは限りません。変換処理では例外を前提にするより、まずTryParseで判定する設計が適しています。

4-6. DivideByZeroException:0除算エラー

DivideByZeroExceptionは、整数を0で割ったときに発生します。

C#
int x = 10;
int y = 0;

int result = x / y;

対処法は、割る前に分母が0でないか確認することです。

C#
if (y != 0)
{
int result = x / y;
Console.WriteLine(result);
}
else
{
Console.WriteLine("0で割ることはできません。");
}

計算処理では、入力値や計算結果によって0除算が発生することがあります。特に平均値、割合、単価、レートなどを計算する処理では注意が必要です。

C#
int total = 100;
int count = 0;

if (count > 0)
{
double average = (double)total / count;
Console.WriteLine(average);
}
else
{
Console.WriteLine("件数が0のため平均を計算できません。");
}

4-7. FileNotFoundException/IOException:ファイル操作のエラー

FileNotFoundExceptionは、指定したファイルが存在しない場合に発生します。

C#
string text = File.ReadAllText("data.txt");

ファイルが存在するか事前に確認する場合は、File.Existsを使います。

C#
string path = "data.txt";

if (File.Exists(path))
{
string text = File.ReadAllText(path);
Console.WriteLine(text);
}
else
{
Console.WriteLine("ファイルが見つかりません。");
}

ただし、File.Existsで確認した直後にファイルが削除される可能性もあります。そのため、ファイル操作ではtry-catchも併用するのが安全です。

C#
try
{
string text = File.ReadAllText("data.txt");
Console.WriteLine(text);
}
catch (FileNotFoundException)
{
Console.WriteLine("ファイルが見つかりません。");
}
catch (IOException ex)
{
Console.WriteLine("ファイル操作中にエラーが発生しました。");
Console.WriteLine(ex.Message);
}

IOExceptionは、入出力処理全般のエラーを表します。ファイルが使用中、読み取り権限がない、ディスクに問題がある、といったケースで発生することがあります。

4-8. TaskCanceledException/OperationCanceledException:非同期処理のキャンセル

OperationCanceledExceptionは、処理がキャンセルされたことを表す例外です。TaskCanceledExceptionは、その派生クラスで、Taskベースの非同期処理がキャンセルされた場合によく発生します。

C#
using var cts = new CancellationTokenSource();

try
{
await Task.Delay(5000, cts.Token);
}
catch (OperationCanceledException)
{
Console.WriteLine("処理がキャンセルされました。");
}

キャンセルは必ずしも異常終了ではありません。ユーザーがキャンセルボタンを押した、タイムアウトした、画面を閉じたなど、正常な制御として扱う場合もあります。

そのため、通常のエラーとキャンセルは分けて考えることが重要です。

C#
try
{
await DoWorkAsync(cancellationToken);
}
catch (OperationCanceledException)
{
Console.WriteLine("キャンセルされました。");
}
catch (Exception ex)
{
Console.WriteLine("エラーが発生しました。");
Console.WriteLine(ex.Message);
}

キャンセルを例外としてcatchする場合でも、ログレベルやユーザーへの表示は通常のエラーとは分けるとよいでしょう。

5. throwで例外を発生・再スローする方法

5-1. throw文で例外を明示的に発生させる

C#では、throw文を使って例外を明示的に発生させることができます。

C#
throw new Exception("エラーが発生しました。");

実際の開発では、単純なExceptionよりも、状況に応じた具体的な例外クラスを使う方が望ましいです。

C#
void SetAge(int age)
{
if (age < 0)
{
throw new ArgumentOutOfRangeException(nameof(age), "年齢は0以上である必要があります。");
}

Console.WriteLine(age);
}

このように、メソッドの前提条件を満たしていない場合に例外を発生させることで、不正な状態のまま処理が進むのを防げます。

5-2. 独自の例外メッセージを設定する

例外を発生させるときは、原因がわかるメッセージを設定することが大切です。

C#
throw new InvalidOperationException("注文が確定済みのため、商品を追加できません。");

悪い例は、原因が曖昧なメッセージです。

C#
throw new Exception("エラーです。");

これでは、何が原因で、どのように修正すればよいのかがわかりません。

良い例は、状況と理由がわかるメッセージです。

C#
throw new InvalidOperationException("在庫数が不足しているため、注文を確定できません。");

例外メッセージは、開発者が原因を調査するための情報です。ただし、ユーザーにそのまま表示すると専門的すぎる場合があります。ログには詳細を残し、画面にはわかりやすいメッセージを表示するようにしましょう。

5-3. throw; と throw ex; の違い

catchブロック内で例外を再スローする場合、throw;throw ex;には重要な違いがあります。

推奨されるのはthrow;です。

C#
try
{
DoSomething();
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
throw;
}

throw;を使うと、元のStackTraceを保持したまま例外を再スローできます。

一方、throw ex;は避けるべきです。

C#
try
{
DoSomething();
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
throw ex;
}

throw ex;を使うと、再スローした場所からStackTraceが記録され直し、元の発生箇所がわかりにくくなる場合があります。

そのため、catchした例外をそのまま上位へ投げ直す場合は、基本的にthrow;を使います。

5-4. 独自例外クラスを作成するケース

独自例外クラスは、業務ルールやアプリケーション固有のエラーを明確に表したい場合に作成します。

たとえば、在庫不足を表す例外を作る場合は次のように書けます。

C#
public class InsufficientStockException : Exception
{
public InsufficientStockException(string message)
: base(message)
{
}
}

使用例は次のとおりです。

C#
void Order(int quantity, int stock)
{
if (quantity > stock)
{
throw new InsufficientStockException("在庫が不足しています。");
}

Console.WriteLine("注文を確定しました。");
}

独自例外を作ることで、catch側で業務エラーとして明確に処理できます。

C#
try
{
Order(10, 3);
}
catch (InsufficientStockException ex)
{
Console.WriteLine(ex.Message);
}

ただし、何でも独自例外にすればよいわけではありません。既存の例外クラスで十分に意味が伝わる場合は、標準の例外クラスを使う方がシンプルです。

5-5. 例外を握りつぶしてはいけない理由

例外をcatchして何もしないことを「例外を握りつぶす」といいます。

C#
try
{
DoSomething();
}
catch
{
}

このようなコードは避けるべきです。例外が発生しても何も記録されないため、問題に気づけず、原因調査も困難になります。

特に本番環境では、エラーが発生した事実がログに残らないと、ユーザーから問い合わせがあっても調査できません。

最低限、ログを残すようにしましょう。

C#
try
{
DoSomething();
}
catch (Exception ex)
{
logger.LogError(ex, "処理中に例外が発生しました。");
throw;
}

例外を無視してよいケースもまれにありますが、その場合でも「なぜ無視してよいのか」をコメントで明示するのが望ましいです。

6. 例外処理のベストプラクティス

6-1. 予測できる条件は例外ではなく事前チェックで防ぐ

例外処理は便利ですが、通常の条件分岐の代わりに使うべきではありません。

たとえば、文字列が数値かどうかはTryParseで判定できます。

C#
if (int.TryParse(input, out int number))
{
Console.WriteLine(number);
}
else
{
Console.WriteLine("数値ではありません。");
}

配列の範囲外アクセスも、事前に長さを確認できます。

C#
if (index >= 0 && index < items.Length)
{
Console.WriteLine(items[index]);
}

null参照も、nullチェックやnullable参照型で防げます。

C#
if (user != null)
{
Console.WriteLine(user.Name);
}

予測できる条件は通常のif文で処理し、本当に想定外の異常に対して例外処理を使うのが基本です。

6-2. 必要な範囲だけをtry-catchで囲む

try-catchで広い範囲を囲みすぎると、どの処理で例外が発生したのかがわかりにくくなります。

C#
try
{
LoadFile();
ParseData();
SaveToDatabase();
SendMail();
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}

このコードでは、ファイル読み込み、データ変換、データベース保存、メール送信のどこで失敗したのかをcatchだけでは判断しにくくなります。

必要に応じて、処理単位で例外を分けると原因を特定しやすくなります。

C#
try
{
LoadFile();
}
catch (IOException ex)
{
logger.LogError(ex, "ファイル読み込みに失敗しました。");
}

try-catchは、例外が発生する可能性がある処理に絞って使いましょう。

6-3. 例外はログに残して原因を追跡できるようにする

例外が発生した場合は、原因を後から追跡できるようにログを残すことが重要です。

ログには、少なくとも次の情報を残すとよいでしょう。

発生日時
例外の種類
エラーメッセージ
StackTrace
関連する入力値やID
InnerException

C#では、ILoggerなどのロギング機能を使って例外を記録することが一般的です。

C#
try
{
ProcessOrder(orderId);
}
catch (Exception ex)
{
logger.LogError(ex, "注文処理に失敗しました。OrderId: {OrderId}", orderId);
throw;
}

ログに業務IDやユーザーID、注文IDなどを含めると、後から調査しやすくなります。ただし、パスワードやクレジットカード番号などの機密情報をログに出力してはいけません。

6-4. ユーザーに表示するメッセージと開発者向けログを分ける

例外メッセージをそのままユーザーに表示するのは避けるべきです。

C#
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}

開発者向けの例外メッセージには、内部実装、ファイルパス、SQL、StackTraceなどが含まれることがあります。そのまま表示すると、ユーザーにとってわかりにくいだけでなく、セキュリティ上の問題になることもあります。

ユーザーには、わかりやすい一般的なメッセージを表示します。

C#
catch (Exception ex)
{
logger.LogError(ex, "注文処理に失敗しました。");
Console.WriteLine("注文処理中にエラーが発生しました。時間をおいて再度お試しください。");
}

開発者向けには詳細ログを残し、ユーザー向けには簡潔で安全なメッセージを表示するのが基本です。

6-5. 例外処理でアプリケーションの状態を壊さない

例外が発生したときは、アプリケーションの状態が中途半端にならないように注意が必要です。

たとえば、注文処理で在庫を減らしたあとに決済で失敗した場合、在庫だけが減った状態になると問題です。

在庫を減らす
決済する
注文を確定する

このような処理では、トランザクションを使って一連の処理をまとめることが重要です。

C#
using var transaction = db.Database.BeginTransaction();

try
{
ReduceStock();
ChargePayment();
SaveOrder();

transaction.Commit();
}
catch
{
transaction.Rollback();
throw;
}

例外処理では、エラーをcatchするだけでなく、失敗時に安全な状態へ戻すことも考える必要があります。

6-6. パフォーマンス目的で例外を多用しない

例外は通常の条件分岐よりもコストが高いため、頻繁に発生する処理フローに使うべきではありません。

悪い例は、数値変換の失敗を毎回例外で処理することです。

C#
try
{
int number = int.Parse(input);
}
catch (FormatException)
{
Console.WriteLine("変換できません。");
}

ユーザー入力のように失敗が予測できる処理では、TryParseを使います。

C#
if (int.TryParse(input, out int number))
{
Console.WriteLine(number);
}
else
{
Console.WriteLine("変換できません。");
}

例外は、通常の処理ではなく異常系を扱うための仕組みです。制御フローの一部として多用すると、可読性やパフォーマンスに悪影響を与える可能性があります。

7. 非同期処理・TaskでのExceptionの扱い

7-1. async/awaitで発生した例外をtry-catchする方法

C#のasyncawaitでは、非同期メソッド内で発生した例外も通常のtry-catchで処理できます。

C#
try
{
await DownloadAsync();
}
catch (HttpRequestException ex)
{
Console.WriteLine("通信エラーが発生しました。");
Console.WriteLine(ex.Message);
}
catch (Exception ex)
{
Console.WriteLine("予期しないエラーが発生しました。");
Console.WriteLine(ex.Message);
}

awaitしている非同期処理の中で例外が発生すると、awaitした箇所で例外が再スローされます。そのため、同期処理と同じようにtry-catchで囲めます。

C#
async Task DownloadAsync()
{
throw new InvalidOperationException("ダウンロードに失敗しました。");
}
C#
try
{
await DownloadAsync();
}
catch (InvalidOperationException ex)
{
Console.WriteLine(ex.Message);
}

非同期処理でも、基本は「awaitする箇所をtry-catchで囲む」と覚えておくとよいでしょう。

7-2. Taskの例外が発生するタイミング

Task内で発生した例外は、Taskを作成した瞬間に呼び出し元へ直接投げられるとは限りません。多くの場合、awaitしたタイミングで例外として観測されます。

C#
Task task = Task.Run(() =>
{
throw new InvalidOperationException("Task内で例外が発生しました。");
});

try
{
await task;
}
catch (InvalidOperationException ex)
{
Console.WriteLine(ex.Message);
}

この場合、例外はawait taskの箇所でcatchされます。

Taskを開始しただけでawaitしない場合、例外を見落とす可能性があります。

C#
Task.Run(() =>
{
throw new InvalidOperationException("例外が発生しました。");
});

このような「投げっぱなし」のTaskは、例外処理が難しくなるため注意が必要です。非同期処理を開始したら、原則としてawaitして結果や例外を確認しましょう。

7-3. 複数Task実行時の例外処理

複数のTaskを並列に実行する場合は、Task.WhenAllを使うことがあります。

C#
try
{
await Task.WhenAll(
Task1Async(),
Task2Async(),
Task3Async()
);
}
catch (Exception ex)
{
Console.WriteLine("いずれかのTaskで例外が発生しました。");
Console.WriteLine(ex.Message);
}

Task.WhenAllでは、複数のTaskで例外が発生する可能性があります。awaitしたときには例外が1つ投げられるように見える場合がありますが、各Taskの状態を確認すると複数の例外を調べられます。

C#
var tasks = new[]
{
Task1Async(),
Task2Async(),
Task3Async()
};

try
{
await Task.WhenAll(tasks);
}
catch
{
foreach (var task in tasks.Where(t => t.IsFaulted))
{
Console.WriteLine(task.Exception);
}
}

複数Taskを扱う場合は、どのTaskが失敗したのかをログに残す設計にしておくと、障害調査がしやすくなります。

7-4. キャンセル処理と例外処理の違い

非同期処理では、エラーとキャンセルを分けて扱うことが重要です。

エラーは、処理中に異常が発生した状態です。たとえば、通信エラー、データ形式の不正、認証失敗などです。

キャンセルは、処理を途中で中止するための制御です。ユーザー操作やタイムアウトによって発生することがあります。

C#
try
{
await LongRunningProcessAsync(cancellationToken);
}
catch (OperationCanceledException)
{
Console.WriteLine("処理はキャンセルされました。");
}
catch (Exception ex)
{
Console.WriteLine("エラーが発生しました。");
Console.WriteLine(ex.Message);
}

キャンセルは想定された動作であることも多いため、通常のエラーと同じログレベルにしない方がよい場合があります。

たとえば、ユーザーが画面を閉じたことで処理がキャンセルされた場合、それはシステム障害ではありません。ログでは情報レベルとして扱うなど、状況に応じて分けることが大切です。

7-5. 非同期処理で例外を見落とさないための注意点

非同期処理で例外を見落とさないためには、Taskを適切にawaitすることが重要です。

避けたい例は、次のようなコードです。

C#
DoWorkAsync();

このようにawaitせずに非同期メソッドを呼び出すと、呼び出し元で例外をcatchできない可能性があります。

正しくは、awaitします。

C#
await DoWorkAsync();

イベントハンドラーなどでasync voidを使う場合を除き、非同期メソッドは基本的にTaskまたはTask<T>を返すようにします。

C#
async Task DoWorkAsync()
{
await Task.Delay(1000);
throw new InvalidOperationException();
}

また、非同期処理の中でtry-catchする場合は、例外をログに残したうえで、必要に応じて再スローします。

C#
async Task ExecuteAsync()
{
try
{
await DoWorkAsync();
}
catch (Exception ex)
{
logger.LogError(ex, "非同期処理で例外が発生しました。");
throw;
}
}

8. デバッグ時にExceptionを調査する方法

8-1. エラーメッセージから原因を読み取る

例外が発生したら、まずエラーメッセージを確認します。

C#
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}

たとえば、Object reference not set to an instance of an object.というメッセージは、null参照が原因であることを示しています。

Input string was not in a correct format.というメッセージは、文字列の形式が不正で変換に失敗したことを示しています。

エラーメッセージは、原因調査の最初の手がかりです。ただし、メッセージだけでは発生箇所まではわからないことがあります。その場合は、StackTraceを確認します。

8-2. StackTraceで例外の発生箇所を特定する

StackTraceには、例外が発生したメソッド呼び出しの流れが記録されています。

C#
catch (Exception ex)
{
Console.WriteLine(ex.StackTrace);
}

StackTraceを見ると、どのメソッドのどの行で例外が発生したのかを特定できます。

たとえば、次のような情報が出力されます。

at SampleApp.Program.Parse(String input) in Program.cs:line 25
at SampleApp.Program.Main() in Program.cs:line 10

この場合、Program.csの25行目で例外が発生し、そのメソッドが10行目から呼び出されていることがわかります。

デバッグ時は、エラーメッセージだけでなくStackTraceを必ず確認しましょう。

8-3. InnerExceptionで根本原因を確認する

InnerExceptionは、ある例外の内側に含まれている元の例外を表します。

たとえば、低レベルの例外を別の例外で包んで投げる場合があります。

C#
try
{
int number = int.Parse("abc");
}
catch (FormatException ex)
{
throw new InvalidOperationException("データの読み込みに失敗しました。", ex);
}

この場合、外側の例外はInvalidOperationExceptionですが、根本原因は内側のFormatExceptionです。

catch側では、InnerExceptionを確認できます。

C#
catch (Exception ex)
{
Console.WriteLine(ex.Message);

if (ex.InnerException != null)
{
Console.WriteLine("InnerException:");
Console.WriteLine(ex.InnerException.Message);
}
}

複雑なアプリケーションでは、例外が何層にもラップされることがあります。根本原因を調べるには、InnerExceptionをたどることが重要です。

8-4. Visual Studioで例外発生時に停止する設定

Visual Studioでは、例外が発生したタイミングでデバッガーを停止させることができます。

通常、try-catchで捕捉される例外は、そのまま処理が続くことがあります。しかし、原因を詳しく調べたい場合は、例外がスローされた瞬間に停止できると便利です。

Visual Studioでは、「例外設定」ウィンドウを使って、特定の例外がスローされたときに停止するよう設定できます。

たとえば、NullReferenceExceptionが発生した瞬間に止める設定にしておくと、nullになっている変数をその場で確認できます。

デバッグ時には、次の点を確認すると原因を特定しやすくなります。

どの行で例外が発生したか
変数の値は何か
nullになっているオブジェクトはどれか
メソッドの呼び出し順序はどうなっているか
InnerExceptionはあるか

8-5. ログ出力で本番環境の例外を追跡する

本番環境では、Visual Studioで直接デバッグできないことが多いため、ログ出力が重要になります。

例外ログには、次のような情報を含めると調査しやすくなります。

例外の種類
エラーメッセージ
StackTrace
InnerException
ユーザーID
リクエストID
処理対象のID
実行環境
発生日時

C#では、ASP.NET CoreなどでILoggerを使ったログ出力が一般的です。

C#
try
{
await service.ExecuteAsync();
}
catch (Exception ex)
{
logger.LogError(ex, "サービス実行中に例外が発生しました。");
throw;
}

本番環境では、同じ例外が繰り返し発生していないか、特定の条件でのみ発生していないかを追跡することが重要です。

また、ログに個人情報や機密情報を出力しないように注意しましょう。

9. C# Exceptionに関するよくある質問

9-1. ExceptionとErrorの違いは?

C#では、一般的にExceptionはプログラム実行中に発生する例外を表す仕組みです。System.Exceptionを基底クラスとして、さまざまな例外クラスが用意されています。

一方、Errorという言葉はより広い意味で使われます。コンパイルエラー、実行時エラー、設計上の誤り、環境エラーなどを含む一般的な表現です。

つまり、ExceptionはC#で扱える具体的な例外オブジェクトであり、Errorは問題全般を指す言葉として使われることが多いです。

9-2. try-catchはどこに書くべき?

try-catchは、例外が発生する可能性があり、かつその場で適切な対応ができる場所に書くべきです。

たとえば、ファイル読み込みに失敗したときに別のファイルを読む、ユーザーに再入力を促す、ログを残して処理を中断するなど、具体的な対応ができる場所です。

逆に、何をすればよいかわからない場所でcatchしても、適切な処理になりません。

アプリケーション全体で予期しない例外を捕捉するために、最上位でcatch (Exception)を使うことはあります。ただし、個別に対応できる例外は、できるだけ発生箇所に近い場所で処理するのが基本です。

9-3. catchを空にしてもよい?

基本的に、空のcatchは避けるべきです。

C#
try
{
DoSomething();
}
catch
{
}

このように書くと、例外が発生しても何も記録されず、問題が隠れてしまいます。

最低限、ログを残すようにしましょう。

C#
try
{
DoSomething();
}
catch (Exception ex)
{
logger.LogError(ex, "例外が発生しました。");
}

例外を無視してよい特殊なケースもありますが、その場合は理由をコメントで明記するのが望ましいです。

C#
try
{
temporaryFile.Delete();
}
catch (IOException)
{
// 一時ファイルの削除失敗は処理全体に影響しないため無視する
}

9-4. すべての例外をcatch(Exception)で処理してよい?

すべての例外をcatch (Exception)でまとめて処理するのは、基本的には推奨されません。

理由は、例外の種類によって適切な対応が異なるからです。

C#
catch (FileNotFoundException)
{
Console.WriteLine("ファイルが見つかりません。");
}
catch (FormatException)
{
Console.WriteLine("データ形式が不正です。");
}
catch (Exception ex)
{
logger.LogError(ex, "予期しない例外が発生しました。");
}

このように、想定できる例外は個別にcatchし、最後に予期しない例外をcatch (Exception)で受ける構成がよく使われます。

catch (Exception)を使う場合でも、ログを残す、必要に応じて再スローする、ユーザー向けメッセージを分けるなどの配慮が必要です。

9-5. 例外処理と戻り値によるエラー処理はどう使い分ける?

例外処理は、通常の処理では起こらない異常な状態を扱う場合に使います。

たとえば、ファイルが突然削除された、ネットワーク接続が失敗した、データベースに接続できない、といったケースです。

一方、戻り値によるエラー処理は、失敗が通常の流れとして想定される場合に適しています。

たとえば、文字列を数値に変換できるかどうかは、ユーザー入力ではよくあることです。この場合は例外よりもTryParseが適しています。

C#
if (int.TryParse(input, out int number))
{
Console.WriteLine(number);
}
else
{
Console.WriteLine("数値ではありません。");
}

使い分けの目安は次のとおりです。

予測できる失敗:戻り値、bool、Result型、TryParseなど
想定外の異常:Exception、try-catch

例外を使いすぎるとコードが読みにくくなり、パフォーマンスにも影響する可能性があります。通常の分岐で処理できるものは事前チェックや戻り値で扱い、本当に異常な状態に対してExceptionを使いましょう。

まとめ

C#のExceptionは、プログラム実行中に発生する想定外のエラーを扱うための重要な仕組みです。

例外処理の基本は、例外が発生する可能性のある処理をtryで囲み、発生した例外をcatchで受け取り、必要に応じてfinallyusingでリソースを解放することです。

C#
try
{
// 例外が発生する可能性のある処理
}
catch (Exception ex)
{
// 例外発生時の処理
}
finally
{
// 必要に応じて後片付け
}

C#では、NullReferenceExceptionIndexOutOfRangeExceptionInvalidOperationExceptionArgumentExceptionFormatExceptionDivideByZeroExceptionIOExceptionOperationCanceledExceptionなど、さまざまなExceptionが発生します。

それぞれの例外には原因と対処法があります。すべてをcatch (Exception)でまとめるのではなく、想定できる例外は種類ごとに処理し、ログを残して原因を追跡できるようにすることが大切です。

また、例外処理は通常の条件分岐の代わりではありません。予測できる入力ミスや範囲外アクセスは、TryParseやnullチェック、事前条件チェックで防ぐのが基本です。

非同期処理では、awaitする箇所をtry-catchで囲み、Taskの例外やキャンセルを適切に扱う必要があります。特に、キャンセルは通常のエラーとは意味が異なるため、OperationCanceledExceptionを分けて処理するとよいでしょう。

C# Exceptionを正しく扱えるようになると、アプリケーションの安定性、保守性、デバッグのしやすさが大きく向上します。エラーをただ隠すのではなく、原因を把握し、安全に処理できるコードを意識しましょう。