C#
AdventCalendar
C#Day 19

C#のエラーハンドリング実装あれこれ

この記事は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の例外処理の定義に倣います。

エラーハンドリング実装あれこれ

1. エラーハンドリングをしない

業務エラーと判定する処理がない場合、エラーハンドリングを端折ることもあるでしょう。

// メソッド定義
string NonErrorHandling(string name){
  return $"Hello! {name}";
}

void Main()
{
  // 呼び出し
  var userName = "Sho";
  var result = NonErrorHandling(userName);  
}

メリット

  • エラー処理がない分メインの処理はわかりやすくなる

デメリット

  • エラー時は実行時例外が発生してしまう可能性が高い

2. 戻り値がデフォルト値かどうかでエラー有無を判定する

処理結果を返す際、エラーがあった場合は処理結果の型の既定値(例: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つの変数で扱える

デメリット

  • エラーがあったかどうかの検出しかできない
    • どのようなエラーなのかが表現できない

3. 戻り値と同じ型でエラーも扱う

ひとつ前と戻り値と同じ型でエラーを表すのは同じですが、既定値ではなくあらかじめ決めて置いた値をエラーとして扱います。

// メソッド定義
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);    
}

メリット

  • 多くの種類のエラーを表現できる
  • エラーコードなどを返し、呼び出し元でエラーコードをもとにエラーメッセージを引くといったことができる

デメリット

  • 処理結果とエラー内容が混ざってしまう
  • 正常値とエラー値の境界がコード上に現れない
    • エラーとする値の範囲をもれなく扱うのが困難
    • 定数として定義しても管理が大変
  • 想定していないエラー値が入り込む余地がある

4. 戻り値でエラー有無、出力引数で処理結果を表す

エラーかどうかと処理結果を分けます。成功/失敗のような単純なケースでは有用です。
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);    
}

メリット

  • 結果とエラーが混ざらない

デメリット

  • エラーがあったかどうかの検出しかできない
    • どのようなエラーなのかが表現できない

5. 戻り値でエラーコード、出力引数で処理結果を表す

ひとつ前と異なり、エラー有無をエラーコードで返します。

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);
}

メリット

  • 結果とエラーが混ざらない
  • 多様なエラーを表せる
  • 呼び出し元でエラーコードをもとにエラーメッセージを引くといったことができる

デメリット

  • エラーがない場合を表す値がシグネチャからは分からない
    • あくまで「取り決め」としてエラーでない値を定める必要がある

6. 列挙型の戻り値でエラーの種類、出力引数で処理結果を表す

現在のところ、最も現実的な実装方法の一つです。

// エラーの種類
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;
  }
}

メリット

  • 結果とエラーが混ざらない
  • 多様なエラーを表せる
  • エラーの有無を型で表せる

デメリット

  • エラーチェックの種類ごとに列挙型を定義しないといけない
  • エラーとするケースが増えても呼び出し側ではそのことがわからない

7. 処理結果とエラーの種類を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さんに教えてもらいました。

// 独自定義の例外
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にアップしてあります。

https://github.com/masaru-b-cl/csharp-ac2017