LoginSignup
27
21

More than 1 year has passed since last update.

[C#]例外処理の利用方針があやふやだったので、勉強しなおした

Last updated at Posted at 2021-01-22

はじめに

自分は業務でC#を書いているのですが、今までかなりあやふやな知識で例外処理を使っていました。そこで、一度学びなおし、現時点での自分の中での「例外処理の利用方針」を固めておこうと思いました。

学びなおすために色々調べた結論としては、自分の知りたかった例外処理の利用方針は「++C++;」の「[雑記]例外の使い方」のページにとてもわかりやすくまとまっていました。

ただ上のページを読んだうえで自分の言葉でもまとめておきたかったので、上のページを大幅に参考にさせてもらいつつ、自分なりの解釈やコード例などを付け加えてまとめたのがこの記事となります。

  • この記事は個人の考えをまとめたものです。
  • この記事の内容はC#のバージョンについて特に意識していませんが、一部のコード例は古いバージョンのC#では使えない構文や機能を使っている可能性があります。

++C++;で提案されている例外処理の利用方針

++C++;」では大体以下のような方針での例外処理の利用が提案されていました。(自分の選択で抜粋 + 解釈を加えさせてもらってるので、原文そのままの引用になっていません。ちゃんと見たい方は元ページを参照ください。)

例外処理利用の基本ルール

  • 「メソッドの定める結果を達成できないなら例外を投げる」
  • 例外は3パターンあり、それぞれに対して次のような対策方針をとる

例外3パターンとその対策方針

例外のパターン 具体例 対処法
①利用法上の例外(利用者側が正しい使い方をしていれば回避できる例外) ・nullを渡して欲しくないメソッドの引数がnull になってる。(Sytem.ArgumentNullException)
・初期化がきちんと行わる前にクラスの操作が行われた。(System.InvalidOperationException)
・catchしない。発生しなくなるまでテストとデバッグを行う(※)
・できれば事前にチェックが行える仕組みをつくる
②発生は避けれないが復帰可能な例外 ・System.IO.File.ReadAllText (File.Exsits()で事前確認していても、その後の実行までにファイルが消される可能性がある)
・対処可能ならcatchする。
・対処不可能(あるいは上位層に対処を任せた方が良い場合)なら、基本catchしない(※)
③対処のしようがない致命的な例外 ・.NET Framework自体のエラー
・その他プログラムが実行不可能なもの
・catchしない(プログラムを停止させてよい)(※)

(※)「catchしない」というのはcatchして握りつぶしたり、処理の方向を捻じ曲げたりしないという意味。一度catchし、処理途中のもののロールバックやログ出力を行った後、再throwするのは有り。

一言で方針をまとめるなら、「throwは遠慮なくして良いがcatchをするかどうかは状況をしっかり見て決めよう」、といったところでしょうか。

各パターンの詳細

上で見た例外処理3パターンについて、それぞれポイントや具体例などを見ていきます。

パターン①利用法上の例外

利用者側が正しい使い方をしていれば回避できる例外です。
このパターンのポイントは、「catchしない」ことです。

コード例を示します。

void Main()
{
    string name = null;
    // 引数にnullを渡さないことは、メソッド利用側で意識して避けられるのでcatchしない! 
    SayHelloTo(name);
}


void SayHelloTo(string name)
{
    // nullを渡してほしくない引数にnullが入っている。(このままではメソッド定める結果を達成できない)
    if(name is null)
    {
        throw new ArgumentNullException($"{nameof(name)} is null");
    }

    Console.WriteLine($"Hello, {name}");
}

上の例で、メソッド"SayHelloTo()"では、nullを渡して欲しくない引数"name"にnullが入っていた場合に例外をthrowしています。しかしメソッド利用側でcatchはしていません。
**catchを書いてないとなんとなくアプリケーションが止まりそうで怖い感じがしますが、ビビッてcatchを書いてはいけません。**不正なパターンが起きなくなるまで、しっかりテストを行います。

さらに、このパターンに限らずですが、呼び出される側のメソッド(SayHelloTo())は特に呼び出す側の事を意識する必要はありません。メソッドの処理を正常に行うための前提条件が整っていないなら遠慮なく例外を投げてよいです。
(ただし、頻繁に例外が起きることが予想される場合は、処理コストが高くなるのを避けるため、後述するTry-Parseパターンの実装が好ましいです。)

また、どうしても変数"name"にnullが入る可能性を排除できない場合もcatchは書きません。以下のように、事前チェックをして回避します。

void Main()
{
   string name = null;
   // 引数にnullを渡さないことは、メソッド利用側で意識して避けられるのでcatchしない! 
   // nullチェックはメソッド利用側でできることなので、チェックする。
    if(!(name is null))
    {
        SayHelloTo(name);
    }
    else
    {
        Console.WriteLine($"Cannot execute {nameof(SayHelloTo)}. Because the {nameof(name)} is null)";
    }   
}

void SayHelloTo(string name)
{
    //(実装省略)
}

この際、例外を出さないための事前チェック(上のコード例でいう、!(name is null))がメソッド利用側では難しい場合があります。その場合は、後述のTester-Doerパターンのように、事前チェックをするためのメソッドを用意します。

パターン①利用法上の例外のまとめ

  • メソッドの処理が上手く実行できない場合は、遠慮なくthrowする。
  • ビビッてcatchを書くのではなく、デバッグ・テストを例外がでなくなるまで行う。

パターン②発生は避けれないが復帰可能な例外

例えば簡単なHttpRequestを送るメソッドを考えてみます。

async Task Main()
{
	Uri uri = new Uri($"http://hogehoge/api/message");
	var msg = await GetMessageAsync(uri);
	Console.WriteLine(msg);
}

private static HttpClient client = new HttpClient();

async Task<string> GetMessageAsync(Uri uri)
{
	if (uri is null)
	{
		throw new ArgumentNullException($"{nameof(uri)} is null.");
	}

	// HttpRequestExceptionがthrowされる可能性がある。
	return await client.GetStringAsync(uri);
}

上の例では、"client.GetStringAsync()"でHttpRequestExceptionがthrowされる可能性がありますが、多くの場合これは呼び出す側で事前に予期して避けづらい例外です。

しかしこのエラーは、単にリクエストに失敗したことを表示すれば問題ないので、復帰できるものです。
以下のように復帰しましょう。

async Task Main()
{
	Uri uri = new Uri($"http://hogehoge/api/message");

	try
	{
		var msg = await GetMessageAsync(uri);
		Console.WriteLine(msg);
	}
	// 基本的に具体的な例外クラスをcatchする。catch (Exception ex)とは書かない。
	catch (HttpRequestException ex)
	{
		Console.WriteLine($"Failed to get message. Error message : {ex.Message}");
	}
}

private static HttpClient client = new HttpClient();

async Task<string> GetMessageAsync(Uri uri)
{
	if (uri is null)
	{
		throw new ArgumentNullException($"{nameof(uri)} is null.");
	}

	// HttpRequestExceptionがthrowされる可能性がある。
	return await client.GetStringAsync(uri);
}

特に難しいところはないですね。HttpRequestExceptionを指定でcatchし、それに対する復帰処理(上の例だと失敗メッセージの表示)を行うだけです。

一つポイントとしては、基本的に具体的な例外の種類(Exception派生クラス)を指定してあげることです。
なぜなら、"catch (Exception ex)"とのように派生元のExceptionクラスを丸ごとcatchしてしまうと、メソッド内でどんなエラーが起きたかがわからず、きちんと復帰できているとは言えないからです。
(ただし、catch句内でExceptionを再throwする、別の種類の例外に変換する(元のExceptionはinnerExceptionにする)といった場合は、派生元のExceptionクラスを丸ごとcatchしてもOKです。)

また、このパターンの悩みどころは、どこで復帰処理を書くかが選べるところです。
上の例では、"Main()"の中でcatchを使い復帰処理を書きましたが、以下のように"GetMessageAsync()"の中で復帰処理を書くこともできます。

async Task Main()
{
	Uri uri = new Uri($"http://hogehoge/api/message");
	var msg = await GetMessageAsync(uri);
	Console.WriteLine(msg);
}

private static HttpClient client = new HttpClient();

async Task<string> GetMessageAsync(Uri uri)
{
	if (uri is null)
	{
		throw new ArgumentNullException($"{nameof(uri)} is null.");
	}

	try
	{
		// HttpRequestExceptionがthrowされる可能性がある。
		return await client.GetStringAsync(uri);
	}
	catch (HttpRequestException ex)
	{
		// このやり方だと、関数呼び出し側で、処理が失敗したかの判断がしづらいので、
		// 関数自体をTry-Parseパターンにした方がよい。
		return $"Failed to get message. Error message : {ex.Message}";
	}
}

上の例では、"GetMessageAsync()"の中で、catchを使い復帰処理を書いています。

このように、このパターン②における復帰処理は、コード中のどの部分でcatchをするかある程度選べてしまいます。メソッドの責務(行いたい処理は何か)を明確にし、適切な呼び出し箇所でcatch,復帰処理を行いましょう。

ちなみに今回の例だと、"Main()"側でcatchを書く方が良いかなと個人的には思います。
なぜなら、上の例で、復帰処理として"Failed ~"という文字列を返していますが、これではメソッド呼び出し側から見て処理が成功したかどうか判別しづらいからです。、
(もし、"GetMessageAsync()"側で復帰処理を行いたい場合は、後述のTry-Parseパターンをつかうなどして処理の成功or失敗をメソッド呼び出し側にわかりやすくする工夫をした方が良いでしょう。)

パターン②発生は避けれないが復帰可能な例外のまとめ

  • きちんと復帰できる場合はcatchして復帰してやる。
  • 具体的なException派生クラスをcatchすることを意識する。
  • どこで復帰処理を行うのかが悩みどころ。メソッドの責務を明確にして判断する。

パターン③対処のしようがない致命的な例外

このパターンは特に意識して対処する必要ないと思います。
自分はあまり良い例が思い浮かばないのですが、++C++;-例外の使い方では.NET Framework自体のエラーが例としてあげられていました。

その他の雑多な話

ここまでで基本的な方針は書きました。

ただ実践では理論通り書けない部分も多く、方針があったとしても例外処理の書き方に迷う部分もあると思います。

そこでここからは、例外処理に関連して使うテクニックや、理想通りいかない現実的なコードの話などを、つらつらと書きます。(あまり書く項目に一貫性はないです。)

自分の経験・上の方針をもとに自分が昔書いたコードを修正してみて気づいたこと・C#の有名OSSであるJson.NETのコードを観察して気づいたこと、の3つを内容のベースとして書いています。

Tester-Doerパターン

パターン①で利用法上の例外(≒業務エラー)はcatchせず、メソッド呼び出し側でエラーパターンの事前チェックをすると書きました。しかし、そもそも例外は基本的には発生しない方が望ましいです。
なので、事前チェック用のメソッドを用意してやるのも一つの手です。そのような実装の仕方をTester-Doerパターンと言います。(Tester=事前チェック用メソッド、Doer=実行したいメソッド)

void Main()
{
    string name = "Taro";
    if (CanExcecuteMethodSayHelloTo(name))
    {
        SayHelloTo(name);
    }
}

// Tester
bool CanExcecuteMethodSayHelloTo(string name)
{
    return !(name is null);
}

// Doer
void SayHelloTo(string name)
{
    if (name is null)
    {
        throw new ArgumentNullException($"{nameof(name)} is null");
    }

    Console.WriteLine($"Hello, {name}");
}

この例のnullチェックぐらい簡単な確認ならTesterを用意するまでもないですが。

.NETのクラスでも、このTester-Doerパターンは見られますね。

  • System.Collection.Generics.ICollectionのIsReadOnly
  • System.IO.StreamのCanRead, CanWrite

Try-Parseパターン

この記事のはじめの方に「メソッドの定める結果を達成できないなら例外を投げる」と書きました。
しかし例外は重い処理なので、それなりの発生頻度が予想される場合には使いたくありません。その場合は、Try-Parseパターンが使えます。

Try-Parseパターンは、メソッドとしては例外をthrowせず、boolで結果を返す方法です。

void Main()
{
	string path = @"filepath";

	// 戻り値としてはboolが返ってくるので、結果はout引数で受け取る。
	var succeeded = TryGetFileData(path, out var data);
	if (succeeded)
	{
		Console.WriteLine($"Suceeded to get data from {path}");
	}
}

bool TryGetFileData(string path, out byte[] fileBytes)
{
	try
	{
		fileBytes = File.ReadAllBytes(path);
		return true;

	}
	catch (IOException ex)
	{
		Console.WriteLine($"Failed to get file data. Error : {ex.Message} ");
        // throwはせず、戻り値でfalseを返し処理の失敗を知らせる。
		fileBytes = null;
		return false;
	}
}

"TryGetFileData()"メソッドでは、処理が成功したかどうかを、例外を投げて知らせる代わりに、戻り値のboolで知らせています。
また、本来なら戻り値として受け取っていた結果はout引数として受け渡します。(処理失敗時は、null or 適当な失敗を表す値を代入。)

上述したように、Try-Parseパターンには、例外を投げるのと比較して、処理失敗時のコストが低いというメリットがあります。ただし、Try-Parseパターンには、メソッド呼び出し側に例外処理を強制できないというデメリットもあります。それを理解した上で、例外を投げるのか、Try-Parseパターンを使うかどうか判断しましょう。

Try-Parseパターンにはメリット・デメリットがある。場合によって例外を投げるのと使い分けるとよい。

  • Try-Parseパターンのメリット:処理失敗時のコストが例外を投げるより低い
  • Try-Parseパターンのデメリット:メソッド呼び出し側に例外処理を強制できない

Try-Parseパターンは、.NETのクラスだと、以下でも使われていますね。
このパターンの場合は、Try~という名前のメソッドにするのが慣習ですね。

  • System.Int32のTryparseメソッド

非同期メソッドの場合

非同期メソッドの場合、out引数が使えません。
その場合は、タプルで結果を返すなどして工夫しましょう。

async Task<(bool, string)> TryGetMessageAsync(Uri uri) { //(省略)}

全例外クラス継承元のExceptionクラスをcatchする場合

catch句では、対象となるException派生クラスを指定できる一方で、以下のように全ての例外クラスの継承元となるExceptionクラスも指定できてしまいます。(強制的に全ての例外をcatchすることができてしまう。)
これは場合によっては、適切な処理が行われず例外の発生が見えにくくなり、不具合の原因特定を難しくすることにつながってしまいます。

void CatchAllException()
{
    try
    {
        // 何かしらのエラーが起きるメソッド。
        RaiseError();
    }
    // 全ての例外をcatchする。
    // 復帰処理などを行う必要がある例外が発生した場合、後から原因が特定しにくいバグとなり得る。
    catch (Exception ex)  
    {
        Console.WriteLine($"Error : {ex.Message}");
    }
}

なので方針としては、派生大元のExceptionは基本的にそのままcatchしない。catchする場合は特定の場合のみとします。
では、catchしてよいのはどのような場合かを見ていきます。

派生大元のExceptionクラスをcatchしてよいとき

  1. 再度throwするとき。(catchの目的はログ出力のためなど。再throwの仕方に注意)
  2. 別の例外に変換するとき。
  3. 独自のエラーハンドリングをし、exceptionは再throwしないとき。(きちんとエラーハンドリングがされることが条件)

それぞれコード例を見てみます。

// ログを出力のためにcatchし、再throwする。
void Rethrow()
{
    try
    {
        RaiseError();
    }
    catch (Exception ex)
    {
        Console.WriteLine($"Failed to {nameof(Rethrow)}. : {ex.Message}");
        // throw exとは書いてはいけない。StackTraceが消えるため。
        throw;
    }
}

// 別の例外に変換する
void Convert()
{
    try
    {
        RaiseError();
    }
    catch (Exception ex)
    {
        // catchしたExceptionはInnerExceptionにする。
        throw new OriginalException($"Original exception occurred", ex);
    }
}

// 独自のエラーハンドリングをする。
void ErrorHandleSelf()
{
    try
    {
        RaiseError();
    }
    catch (Exception ex)
    {
        // HandleErrorメソッドできちんと処理が行われることが前提。極力使わない。
        HandleError(ex);
    }
}

1 再度throwするとき。

ログ出力などの任意の処理を行った後、再度throwします。このとき、"throw ex"と書くとStackTraceが消えてしまうため、"throw"とだけ書きます。

2 別の例外に変換するとき。

そもそもExceptionをそのままcatchすることは例外が握りつぶされてしまうことが一番の問題なので、別の例外に変換するのはOKです。ただし、変換前の例外情報も貴重なため、InnerExceptionとして残しておきます。
このパターン使われるのは以下の場合が多いかなと思います。

  • より適切な種類の例外に置き換えれるとき(より具体的な例外の方が不具合の原因を特定しやすい)
  • ライブラリ的に扱われるレイヤーで、そのライブラリから出される例外を統一したいとき

3. 独自のエラーハンドリングをし、exceptionは再throwしないとき。

多くの場合は全ての例外をカバーすることは難しいため、このやり方は推奨ではないです。
ただし、開発プロジェクト内でエラーハンドリングのやり方が統一されている場合などには使われることがあるかもしれません。

独自の例外クラスについて

C#では、Exceptionクラスを継承することによって、独自の例外クラスを作成できます。

class OriginalException : Exception
{
    public OriginalException() { /*(実装省略)*/ }
    public OriginalException(string? paramName) { /*(実装省略)*/ }
    public OriginalException(string? message, Exception? innerException) { /*(実装省略)*/ }
    public OriginalException(string? paramName, string? message) { /*(実装省略)*/ }
    protected OriginalException(SerializationInfo info, StreamingContext context) { /*(実装省略)*/ }
}  

独自例外クラスを作成するべきタイミングについては、Microsoftの公式ドキュメント-ユーザー定義の例外を作成する方法に書いているものそのままです。

.NET では、基底クラス Exception から最終的に派生した例外クラスの階層構造を提供します。 ただし、定義済みの例外のいずれも要件を満たさない場合は、Exception クラスから派生することによって、独自の例外クラスを作成できます。

つまり、独自の例外クラスは、積極的につくるものではなく、.NET標準で用意されてる例外クラスに該当するものがない場合のみ作成すると覚えておけば大丈夫です。
その他、独自例外を定義した方が、明らかに利用者側の復帰処理などに役立つ場合、あるいは一塊のコード内(ライブラリなど)で一貫した例外を使用したい場合などは独自例外をつくることを検討しましょう。

ちなみにですが、C#でJsonを簡単に取り扱えるライブラリJson.NETでは、"JsonException"という独自例外クラスが定義されており、ライブラリ利用側には"JsonException"(実際にはいくつかあるその派生クラス)が渡されるようになっています。独自例外クラスの使われ方について実際のコードが見たいかたは、参照してみると良いと思います。(JsonExceptionクラス)

また、独自例外作る際は、作法として定義しておくべきメソッドがいくつかあります。ドキュメントを参考にして定義しましょう。

XMLドキュメントコメント

C#では、メソッドやクラスなどに対してXML形式でドキュメントコメントを書けます。
メソッドのコメントでは、発生する可能性のある例外を示すためのタグ"<exception>"が用意されています。
メソッド利用側の実装の助けになるので積極的に書きましょう。

/// <summary>
/// Show <paramref name="arg"/> on standard output.
/// </summary>
/// <exception cref="ArgumentException">Throw if <paramref name="arg"/> is null or whitespace.</exception>
void Print(string arg)
{
    if (string.IsNullOrWhiteSpace(arg))
    {
        throw new ArgumentException($"{nameof(arg)} is null or whitespace. Please substitute proper value.");
    }

    Console.WriteLine($"Output : {arg}");
}

メソッド固有のルールではなく、例外処理を使う理由

Try-Parseパターンのように、例外処理構文を使わなくてもメソッド処理の成功・失敗を伝える方法はあります。
ただしその場合には、メソッド固有のルール(例えばTry-Parseパターンでは、成功・失敗を戻り値とするいうルール)を決めていることによるデメリットがあります。
この点についても、++C++;-例外の使い方で簡潔にまとめられているので引用させていだきます。(原文では、文書で書かれているものを私の言葉で少し書き直し、表形式にしています。)

メソッド固有のルールを使うデメリット 例外処理を使うメリット
例外の検出が面倒。
(呼び出すメソッド固有のルールを知っておく必要がある。)
throw, try-catch文に処理の仕方が統一されている。
正常動作部と例外処理部の区別が分かりにくい。 正常動作部と例外処理部の区別が分かりやすい。
try の中には動作が正常な時の処理が、 catch の中には例外発生時の対処のみが書かれる。
メソッド利用者に例外処理を行うことを強制出来ない。 メソッド利用側に例外処理させることを強制できる。
(例外発生時に処理が行われないと、アプリが終了する。)
メソッドを呼び出すたびに例外処理用のコードを書く必要がある。 例外処理部(catch 節)や、正常・例外問わず必ず実行する必要がある部分(finaly 節)が一か所にまとまる。

このように、例外処理構文を使うことにより、メソッド固有のルールによるデメリットを大きくカバーすることができます。
なので実装の際は、まずは例外処理構文を使うことを検討、何か特別な理由があるならメソッド固有のルールを使うという方針でよいでしょう。

業務エラーには例外処理を使ってはいけない?

例外処理の利用方針パターン①で、利用法上の例外について説明しました。
ただ例外の利用方法について調べていると、「業務エラー(≒利用方上の例外)には例外処理を用いてはいけない」という記述をよく見かけます。

個人的には「業務エラーでもthrowはしてよい、ただしcatchはしない」という方針で良いと思います。
というのも、前節で書いた、throwを使わない場合に発生する「メソッド呼び出し側に例外処理を強制できない」といデメリットを避けるためです。

OSSではどうなってるか

Json.NETではどのように例外処理が使われているか軽く調べてみました。
以下は、ソリューション内のNewtonsoft.Jsonプロジェクトで使われいてる、throw, catchの大体の数になります。(grepでコード内のコメントも拾ってしまっているので、正確な数ではないです。)

:~/Json.NET/Src/Newtonsoft.Json$ grep -r -n "throw new" | wc -l    371
:~/Json.NET/Src/Newtonsoft.Json$ grep -r -n "catch" | wc -l   51

見てわかるように、新しい例外をthrowしている数に対してcatchしている数が少ないです。
実際のコードを見てみても、概ね「業務エラーでもthrowはしてよい、ただしcatchはしない」という方針で書かれているのかなぁという印象を受けました。

おわりに

例外処理を利用方針について、自分の勉強用に調べた内容をまとめました。
同じようなことを知りたがっている人のお役に立てれば幸いです。

もし内容の間違いや改善点などあれば、ご指摘いただけると嬉しいです。

27
21
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
27
21