- C#
C#例外処理|try-catch-finallyとthrowの違い・例外一覧
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期連続の黒字決算。