Menu

C#で例外を投げる:ガード節、throw式、独自の例外

C#でいつ、どのように例外を投げるかを解説します。throw文、どの組み込みの例外型がどの間違いに合うか、??と?:によるthrow式、ThrowIfNullのヘルパー、そして独自のデータと内部例外を持つ独自の例外クラスの書き方を学びます。

このページのコードはエディタで実行できます - 編集してすぐに結果を確認できます。

例外のキャッチはエラー処理の半分にすぎず、残りの半分は正しい例外を投げることです。よく選ばれた例外は、何がうまくいかなかったのか、それが呼び出し側の間違いなのかプログラムの状態によるものなのかを、呼び出し側に正確に伝えます。このページでは投げる側を扱い、処理についてはtry catchで扱います。

throw文とガード節

throw は例外オブジェクトを受け取ります。メソッドの実行はそこで止まり、例外は呼び出し履歴をさかのぼってハンドラーを探します。最もよくある使い方はガード節です。メソッドの先頭で、処理を始める前に不正な入力を弾くチェックです。

出力:

ArgumentOutOfRangeException for parameter 'amount'
InvalidOperationException: The account is frozen.
Balance: 100

nameof(amount) は文字列 "amount" を生成し、引数の名前が変わっても正しいままです。引数の例外はそれを ParamName に保存し、ツールやログはそれを使って不正な引数を示します。

ガード節があると、メソッドの残りの部分は単純になります。チェックを通過した後のコードは、入力が有効だと仮定できます。また、不正な値が先に進んで3つ先のメソッドでわかりにくい NullReferenceException を起こすのではなく、間違いのあった地点で失敗させられます。

どの例外型を投げるか

状況を表す組み込みの型があれば、それを再利用します。呼び出し側はすでにその処理方法を知っています。

状況投げる例外
必須の引数が nullArgumentNullException
引数が許される範囲の外(負の数量、末尾を超えたインデックス)ArgumentOutOfRangeException
引数がそれ以外の点で不正(空の名前、形式の誤ったID)ArgumentException
オブジェクトの現在の状態では呼び出しが有効でないInvalidOperationException
その型では操作がそもそもサポートされない(読み取り専用コレクションの Add)NotSupportedException
メソッドがまだ書かれていないNotImplementedException
Dispose の後にオブジェクトが使われたObjectDisposedException
時間制限のある操作が時間切れになったTimeoutException

最初の3行と InvalidOperationException の境目は、誰が何かを変える必要があるかです。引数の例外は「呼び方を変えてください」と言い、InvalidOperationException は「呼び出しは問題ないが、今はできない」と言います。

Exception、SystemException、ApplicationException を直接投げてはいけません。呼び出し側は、他のすべてもキャッチしない限り、それらをキャッチできません。NullReferenceException、IndexOutOfRangeException、StackOverflowException も自分で投げてはいけません。ランタイムが実際のバグのために予約しています。

throw式

C# 7より前は、throw は文でしかありませんでした。C# 7以降は3つの場所で式としても使え、よくあるチェックを1行にできます。

出力:

Ana <ana@example.com>
Null: name
ArgumentException: email

ArgumentNullException は ArgumentException から派生しているので、catch (ArgumentException) を先に置くとnullのケースもキャッチされることに注意してください。両方を処理するときにcatch句の順序が重要になるのは、同じ理由からです。

ThrowIfNullなど(.NET 6以降)

現代の.NETには、チェックと例外の送出を書いてくれる静的なヘルパーがあり、引数名も自動的に取得されます。

public void Ship(Order order, int quantity, string address)
{
    ArgumentNullException.ThrowIfNull(order);                   // .NET 6
    ArgumentOutOfRangeException.ThrowIfNegativeOrZero(quantity); // .NET 8
    ArgumentException.ThrowIfNullOrWhiteSpace(address);          // .NET 8
    ObjectDisposedException.ThrowIf(disposed, this);             // .NET 7

    // ...
}

これらは手書きの if と throw と同じように動き、ガード節をそれぞれ1行に保ちます。古いターゲットでは、前に示した if の形で書きます。

独自の例外クラスを書く

呼び出し側がこの特定の失敗を個別にキャッチする必要がある場合や、ハンドラーがメッセージの文字列ではうまく運べないデータを必要とする場合は、独自の例外型を作ります。

出力:

Cannot withdraw 25 from a balance of 15.
Short by 10

慣例は次のとおりです。

  • 名前は Exception で終わります。
  • Exception から派生させます(InvalidOperationException のような、より限定的な組み込みの型の特殊なケースなら、その型から派生させます)。
  • 3つの標準的なコンストラクターを持ちます。引数なし、メッセージ、メッセージと内部例外です。独自のコンストラクターはその上に追加します。
  • 追加のデータは、コンストラクターで設定する読み取り専用のプロパティに入れます。そうすればハンドラーは、メッセージを解析する代わりに e.Requested に基づいて処理できます。

内部例外でラップする

低レベルの失敗を高レベルの失敗として表に出すべきときは、ラップします。元の例外は InnerException として保たれるので、情報は失われません。

出力:

Setting 'port' must be a number, got '80a'.
Caused by: FormatException

これで呼び出し側は、自分が理解できる設定という観点で扱えるようになり、e.ToString() を記録すれば、FormatException とそのスタックトレースを含む連鎖全体が出力されます。ラップするのは意味を加えるときだけにします。すべての例外を汎用の MyAppException でラップしても、ハンドラーが InnerException を掘り返す手間が増えるだけです。

例外を投げるか結果を返すか

例外は、呼び出し側が通常の動作では想定していない失敗のためのものです。よく何も見つからない検索や、よく不正になるユーザー入力のように、日常的に起きる結果には、.NETの慣例はTryパターンです。bool を返し、値を out 引数で返します。

public bool TryWithdraw(decimal amount, out string error)
{
    if (amount > Balance) { error = "Insufficient funds."; return false; }
    Balance -= amount;
    error = null;
    return true;
}

多くの型が両方を提供しています。int.Parse は例外を投げ、int.TryParse は false を返します。dict[key] は例外を投げ、dict.TryGetValue は false を返します。例外を投げるのは値を返すよりはるかにコストが高いので、1秒間に何千回も実行される経路に置くべきではありません。out 引数についてはrefとoutを参照してください。

良いメッセージを書く

例外のメッセージは、ログを見る開発者が読むものです。何が間違っていたのか、そして安全なら問題の値を書きます。「Invalid input.」より「Quantity must be between 1 and 99, got 0.」のほうが優れています。完全な文で書き、パスワードやトークンのような秘密はログファイルに残るので、メッセージに含めないようにします。

よくある間違い

  • Exception そのものを投げる。 呼び出し側が選択的にキャッチできません。限定的な型を使います。
  • 引数名の位置にメッセージを渡す。 new ArgumentNullException("name") は引数名を受け取り、メッセージは2番目です。
  • 引数名をハードコードする。 名前を変えても正しく保たれるように nameof(param) を使います。
  • 意味を加えない独自の例外。 組み込みの型が合うなら、それを使います。
  • ラップするときに元のエラーを失う。 必ず内部例外として渡します。

よくある質問

C#で例外を投げるには?

例外オブジェクトを作って投げます:throw new ArgumentException("Amount must be positive", nameof(amount));。実行はその行で止まり、例外は呼び出し履歴をさかのぼって、最も近い一致する catch へ向かいます。問題を表す最も限定的な組み込みの型を選ぶか、呼び出し側がこのケースを個別に処理する必要があるなら独自の型を選びます。

C#で独自の例外を作るには?

名前が Exception で終わるクラスを Exception から派生させ、標準的なコンストラクターを用意します。引数なしのもの、メッセージを受け取るもの、メッセージと内部例外を受け取るもので、それぞれ対応する base(...) コンストラクターを呼びます。注文IDや残高など、ハンドラーが必要とするデータには読み取り専用のプロパティを追加します。

ArgumentExceptionとInvalidOperationExceptionはどう使い分けますか?

呼び出し側が不正な値を渡した場合は ArgumentException(または ArgumentNullException / ArgumentOutOfRangeException)を投げます。直し方はメソッドの呼び方を変えることです。引数は問題ないのに、オブジェクトが呼び出しに適さない状態にある場合は InvalidOperationException を投げます。閉じた接続からの読み取りや、凍結された口座からの引き出しなどです。

C#のthrow式とは何ですか?

C# 7以降、throw は3つの場所で式として使えます。?? の後、?: のどちらかの分岐、そして式形式のメンバーやラムダの本体です。たとえば _name = name ?? throw new ArgumentNullException(nameof(name)); は、1行で代入するか例外を投げます。

ArgumentNullException.ThrowIfNullは何をしますか?

.NET 6で追加された静的なヘルパーです。ArgumentNullException.ThrowIfNull(customer); は、customer が null なら引数名を自動的に埋めた ArgumentNullException を投げ、そうでなければ何もしません。その後のバージョンでは、ArgumentException.ThrowIfNullOrEmpty(.NET 7)や ArgumentOutOfRangeException.ThrowIfNegative(.NET 8)などの同様のヘルパーも追加されました。

Coddy programming languages illustration

Coddyでコードを学ぼう

始める