この記事はC# Advent Calendar 2017の19日目の記事です。
18日目は@usamik26さんの「Moq : Mocking Framework for .NET - Qiita」でした。
C#におけるエラーハンドリング
C#は2002年に.NET Frameworkとともにバージョン1.0がリリースされ1、既に15年ほどの歴史があり、2017年12月19日現在はバージョン7.2まで進化しています2。その歴史の中では、当然言語機能も増え、エラーハンドリングのやり方も徐々に種類が増えてきました。
そこで、C#に置けるエラーハンドリングの実装を一度ここで整理し、特徴をまとめてみます。
免責事項
このリストはもちろん完全ではありません。誤字脱字から認識の齟齬、他にもこんなやりかたがあるよ、特徴にこんなものがあるよ、などありましたら、遠慮なく編集リクエストやコメントにてお知らせください。
前提条件
ここで「エラー」と呼んでいるのは例外(Exception)ではなく、ロジックのエラー(いわゆる業務エラー)のことを指しています。エラーの種類については、赤間さんのエントリ.NETの例外処理の定義に倣います。
エラーハンドリング実装あれこれ
- エラーハンドリングをしない
業務エラーと判定する処理がない場合、エラーハンドリングを端折ることもあるでしょう。
// メソッド定義
string NonErrorHandling(string name){
return $"Hello! {name}";
}
void Main()
{
// 呼び出し
var userName = "Sho";
var result = NonErrorHandling(userName);
}
メリット
- エラー処理がない分メインの処理はわかりやすくなる
デメリット
- エラー時は実行時例外が発生してしまう可能性が高い
- 戻り値がデフォルト値かどうかでエラー有無を判定する
処理結果を返す際、エラーがあった場合は処理結果の型の既定値(例:intなら0、参照型ならnull)を返します。ただ、本Advent Calendar 9日目の猪股さんによる「Null 非許容な参照型 - Qiitaにある通り、今後は参照型でもnullを排除していく方向ですので、注意が必要です。
// メソッド定義
string ReturningResultOnly(string name)
{
if (string.IsNullOrEmpty(name)) return null;
return $"Hello! {name}";
}
void Main()
{
// 呼び出し
var userName = "Sho";
var result = ReturningResultOnly(userName);
if (result == null)
{
Console.WriteLine("Some Error!");
return;
}
Console.WriteLine(result);
}
メリット
- 結果とエラーを1つの変数で扱える
デメリット
- エラーがあったかどうかの検出しかできない
- どのようなエラーなのかが表現できない
- 戻り値と同じ型でエラーも扱う
ひとつ前と戻り値と同じ型でエラーを表すのは同じですが、既定値ではなくあらかじめ決めて置いた値をエラーとして扱います。
// メソッド定義
string ReturningResultOrErrorCode(string name)
{
if (string.IsNullOrEmpty(name)) return "E001";
// some validation with return error code "Exxx";
return $"Hello! {name}";
}
void Main()
{
// 呼び出し
var userName = "Sho";
var result = ReturningResultOrErrorCode(userName);
switch(result)
{
case "E001" :
Console.WriteLine("name is empty!");
return;
// case "Exx" :
// error handling
// return;
}
Console.WriteLine(result);
}
メリット
- 多くの種類のエラーを表現できる
- エラーコードなどを返し、呼び出し元でエラーコードをもとにエラーメッセージを引くといったことができる
デメリット
- 処理結果とエラー内容が混ざってしまう
- 正常値とエラー値の境界がコード上に現れない
- エラーとする値の範囲をもれなく扱うのが困難
- 定数として定義しても管理が大変
- 想定していないエラー値が入り込む余地がある
- 戻り値でエラー有無、出力引数で処理結果を表す
エラーかどうかと処理結果を分けます。成功/失敗のような単純なケースでは有用です。
C# 7.0で追加された出力変数宣言を使えば、すっきりと書くことができます。
// メソッド定義
bool ReturningIsValidAndSettingResultToOutVar(string name, out string greeting)
{
if (string.IsNullOrEmpty(name))
{
greeting = null;
return false;
}
greeting = $"Hello! {name}";
return true;
}
void Main()
{
// 呼び出し
var userName = "Sho";
var isValid = ReturningIsValidAndSettingResultToOutVar(userName, out var result);
if (!isValid)
{
Console.WriteLine("Some Error!");
return;
}
Console.WriteLine(result);
}
メリット
- 結果とエラーが混ざらない
デメリット
- エラーがあったかどうかの検出しかできない
- どのようなエラーなのかが表現できない
- 戻り値でエラーコード、出力引数で処理結果を表す
ひとつ前と異なり、エラー有無をエラーコードで返します。
string ReturningErrorCodeAndSettingResultToOutVar(string name, out string greeting)
{
if (string.IsNullOrEmpty(name))
{
greeting = null;
return "E001";
}
// some validation with return error code "Exxx";
greeting = $"Hello! {name}";
return string.Empty;
}
void Main()
{
// 呼び出し
var userName = "Sho";
var errorCode = ReturningErrorCodeAndSettingResultToOutVar(userName, out var result);
switch(errorCode)
{
case "E001" :
Console.WriteLine("name is empty!");
return;
// case "Exx" :
// error handling
// return;
}
Console.WriteLine(result);
}
メリット
- 結果とエラーが混ざらない
- 多様なエラーを表せる
- 呼び出し元でエラーコードをもとにエラーメッセージを引くといったことができる
デメリット
- エラーがない場合を表す値がシグネチャからは分からない
- あくまで「取り決め」としてエラーでない値を定める必要がある
- 列挙型の戻り値でエラーの種類、出力引数で処理結果を表す
現在のところ、最も現実的な実装方法の一つです。
// エラーの種類
enum ErrorType
{
NoError,
NameIsEmpty,
// SomeError
}
// メソッド定義
ErrorType ReturningErrorTypeAndSettingResultToOutVar(string name, out string greeting)
{
if (string.IsNullOrEmpty(name))
{
greeting = null;
return ErrorType.NameIsEmpty;
}
// some validation with return error code "Exxx";
greeting = $"Hello! {name}";
return ErrorType.NoError;
}
void Main()
{
// 呼び出し
var userName = "Sho";
var errorType = ReturningErrorTypeAndSettingResultToOutVar(userName, out var result);
switch(errorType)
{
case ErrorType.NoError :
Console.WriteLine(result);
return;
case ErrorType.NameIsEmpty :
Console.WriteLine("name is empty!");
return;
// case ErrorType.SomeError :
// error handling
// return;
}
}
メリット
- 結果とエラーが混ざらない
- 多様なエラーを表せる
- エラーの有無を型で表せる
デメリット
- エラーチェックの種類ごとに列挙型を定義しないといけない
- エラーとするケースが増えても呼び出し側ではそのことがわからない
- 処理結果とエラーの種類をValueTupleで一緒に返す
ひとつ前とほぼ同じですが、C# 7.0で導入されたValueTupleを使って処理結果とエラーを一度に返します。
// エラーの種類
enum ErrorType
{
NoError,
NameIsEmpty,
// SomeError
}
// メソッド定義
(string result, ErrorType errorType) ReturningResultAndErrorAsValueTuple(string name)
{
if (string.IsNullOrEmpty(name))
{
return (null, ErrorType.NameIsEmpty);
}
// some validation with return error code "Exxx";
return ($"Hello! {name}", ErrorType.NoError);
}
void Main()
{
// 呼び出し
var userName = "Sho";
var (result, errorType) = ReturningResultAndErrorAsValueTuple(userName);
switch(errorType)
{
case ErrorType.NoError :
Console.WriteLine(result);
return;
case ErrorType.NameIsEmpty :
Console.WriteLine("name is empty!");
return;
// case ErrorType.SomeError :
// error handling
// return;
}
}
メリット
- 結果とエラーが混ざらない
- 多様なエラーを表せる
- エラーの有無を型で表せる
- ValueTupeの分解を使い結果とエラー種類の両方の変数を一度に宣言できる
- Go言語っぽい書き味になる
デメリット
- エラーチェックの種類ごとに列挙型を定義しないといけない
- エラーとするケースが増えても呼び出し側ではそのことがわからない
- ValueTupeがない古い
C#では使えない.NET FrameworkではNuGetからパッケージを導入しないといけない(コメント欄にて@aetosさんから指摘あり)
NuGet Gallery | System.ValueTuple
8.例外を戻して型スイッチで分岐
C# 7.0から導入された型スイッチを活用する方法です。エラーを表すのに独自の型を作成するのでなく、既存の例外を再利用します。
※エントリ公開後に@kazukさんに教えてもらいました。
エラー値としてのenumはexceptionを使うのも割と選択肢として悪くない
— Kazuhiko Kikuchi (@kazuk) 2017年12月19日
return new indexoutofrangeexception
// 独自定義の例外
class MyException : Exception
{
public string ErrorCode {get; set;}
}
// メソッド定義
(string result, Exception exception) ReturningResultAndExceptionAsValueTuple(string name)
{
if (string.IsNullOrEmpty(name))
{
return (null, new ArgumentNullException(nameof(name)));
}
string person = FindByName(name);
if (person == null)
{
return (null, new MyException { ErrorCode = "E001" });
}
return ($"Hello! {name}", null);
}
string FindByName(string name)
{
// 名前から人を探しに行く
if (name.StartsWith("0"))
{
// 見つからなかった
return null;
}
return "FullName";
}
void Main()
{
// 呼び出し
var userName = "Sho";
var (result, exception) = ReturningResultAndExceptionAsValueTuple(userName);
switch(exception)
{
case ArgumentNullException ex :
Console.WriteLine($"{ex.ParamName} is empty!");
return;
case MyException ex :
Console.WriteLine($"Some Error: {ex.ErrorCode}");
return;
case Exception ex:
throw ex;
}
Console.WriteLine(result);
}
メリット
- 既存の例外が標準ライブラリなどに豊富にあるため流用可能
- 独自の業務エラー用例外を作れば付加情報も扱える
- 業務例外以外はそのままthrowすることもできる
- エラーなしを表す値を定義しなくてよい(nullでいける)
デメリット
- 列挙型の時と同様にエラーとするケースが増えても呼び出し側ではそのことがわからない
- null非許容の参照型が導入されたら注意が必要
- 型スイッチがない古いC#では使えない
番外. 例外を使って業務エラーも処理する
業務エラーも例外を使って表します。いわゆる「部品」における引数の検証などではよく使われます。
// メソッド定義
string ReturningResultAndThrowingBusinessException(string name)
{
if (string.IsNullOrEmpty(name)) throw new ArgumentNullException(nameof(name));
return $"Hello! {name}";
}
void Main()
{
// 呼び出し
var userName = "Sho";
try
{
var result = ReturningResultAndThrowingBusinessException(userName);
Console.WriteLine(result);
}
catch (ArgumentNullException ex)
{
Console.WriteLine($"argument `{ex.ParamName}` is empty.");
}
}
メリット
- エラー時に即時に終了することが分かりやすい
デメリット
- 例外は「重い」処理
- 例外は本当に「例外的」な時だけ使い、フロー制御に使うべきではない
まとめ
現時点ではいずれの実装でも完全とは言えません。個人的には「小ネタ チェック例外とUnion型 | ++C++; // 未確認飛行 C ブログ」でも言及されているような、Union型があればいいなーとは思いますが、今のところ導入されそうな様子はありません。
しかし現実は待ってくれません。与えられた材料や条件を元に、それぞれのメリット、デメリットを天秤に掛け、最適と思えるやり方を選ぶことになります。
本エントリーが少しでもその助けとなれば幸いです。
20日目は@4_mio_11さんの視て、わかる!C#7.1までの言語機能(スライド)です。
サンプルコード
本エントリのサンプルコードは、みんな大好きLINQPadの*.linqファイルとしてGitHubにアップしてあります。