web-dev-qa-db-ja.com

基本クラスは、APIドキュメントの一部である、誤ってスローされた例外をラップする責任を負うべきですか?

特殊な例外タイプを使用してメソッドの例外的な結果を示すことは、一般的なアプローチです。これらの例外タイプはプログラミングの欠陥を示すとは見なされませんが、私が指摘したように、例外的な結果を返すつもりです。

次の基本クラスを想定します。

public class MyCollection
{
  private readonly Dictionary<string, string> underlyingCollection = new Dictionary<string, string>();

  public void AddEntry(string key, string value)
  {
    this.underlyingCollection.Add(key, value);
    this.OnEntryAdded(key, value);
  }

  /// <summary>
  /// ...
  /// </summary>
  /// <param name="key">...</param>
  /// <returns>...</returns>
  /// <exception cref="KeyNotFoundException">An entry with the provided key could not be found.</exception>
  public string GetValue(string key)
  {
    string value = this.underlyingCollection[key];
    return this.ProcessEntry(key, value);
  }

  protected virtual void OnEntryAdded(string key, string value)
  {
  }

  protected virtual string ProcessEntry(string key, string value) => value;
}

クラスのコンシューマは、KeyNotFoundExceptionが未知のキーで呼び出されたときにGetValueを期待し、それに反応することができます。 (Java achecked exceptionを使用できます-これが良いアイデアであるかどうかという点を無視してください。)

誰かが派生クラスを作成します:

public class CustomizedCollection : MyCollection
{
  private readonly Dictionary<string, int> countByXKeyLookup = new Dictionary<string, int>();

  protected override void OnEntryAdded(string key, string value)
  {
    base.OnEntryAdded(key, value);

    if (key.StartsWith("x", StringComparison.OrdinalIgnoreCase))
    {
      string xKey = ExtractKeyPrefix(key);
      int count;

      if (!this.countByXKeyLookup.TryGetValue(xKey, out count))
      {
        this.countByXKeyLookup.Add(xKey, count);
      }
      else
      {
        this.countByXKeyLookup[xKey] = count + 1;
      }
    }
  }

  // correct implementation
  protected override string ProcessEntry(string key, string value)
  {
    if (!key.StartsWith("x", StringComparison.OrdinalIgnoreCase))
    {
      return value;
    }

    int count = this.countByXKeyLookup[ExtractKeyPrefix(key)];
    return value + " ** X-Key count is " + count;
  }

  private static string ExtractKeyPrefix(string key) => key.Substring(0, 1);
}

クラスのコンシューマーはこのコードを記述し、すべてが期待どおりに機能します。

var testee = new CustomizedCollection();
testee.AddEntry("A", "Alfa");
testee.AddEntry("XB", "X-Bravo");
testee.AddEntry("XC", "X-Charlie");
testee.AddEntry("YD", "Y-Delta");
string key = null;

try
{
  key = "A"; Console.WriteLine(testee.GetValue(key));
  key = "XB"; Console.WriteLine(testee.GetValue(key));
  key = "XC"; Console.WriteLine(testee.GetValue(key));
  key = "YD"; Console.WriteLine(testee.GetValue(key));
  key = "NA"; Console.WriteLine(testee.GetValue(key));
}
catch (KeyNotFoundException)
{
  Console.WriteLine("Key '" + key + "' not available.");
}

出力は次のとおりです。

Alfa 
 X-Bravo ** X-Keyカウントは1 
 X-Charlie ** X-Keyカウントは1 
 Y-Delta 
 Key 'NA'です利用不可。

後で、派生クラスのメソッドProcessEntryにバグが導入されました。

// buggy implementation
protected override string ProcessEntry(string key, string value)
{
  // bug here!
  if (!key.StartsWith("x", StringComparison.OrdinalIgnoreCase) && !key.StartsWith("y", StringComparison.OrdinalIgnoreCase))
  {
    return value;
  }

  int count = this.countByXKeyLookup[ExtractKeyPrefix(key)];
  return value + " ** X-Key count is " + count;
}

ここでコードを実行すると、例外が誤って解釈されます。

Alfa 
 X-Bravo ** X-Keyカウントは1 
 X-Charlie ** X-Keyカウントは1 
 Key 'YD'は使用できません。

しかし、キー 'YD'は利用可能です

このバグの根本的な原因は、このAPIのコンシューマーが、要求されたキーが見つからない場合にKeyNotFoundExceptionが返されるという文書化された動作に依存しているという事実にあります。

誰がバグの原因ですか?まあ、派生クラスCustomizedCollectionの開発者は、完全に異なるコンテキストで(つまり、プライベートKeyNotFoundException辞書にアクセスして)countByXKeyLookupを発生させるバグを導入しました。これにより、APIを使用するだけの開発者は混乱します。キー 'YD'が存在しないと報告されている理由が理解できないためです。

私の質問は:基本クラスがAPIドキュメントの一部である「間違った」例外を除外し、それらを別の非特定の例外(例: InvalidOperationException?あるいは、派生クラスの開発者は、誤解を招くような特別な意味を持つ例外をバブルアップさせないことを知っているべきですか?

私が議論したくないこと:

  • 継承は良い考えかどうか。
  • TryDoReturnBoolアプローチを使用するほうが、例外をスローするよりも優れているかどうか。
  • クラスを封印する必要があるかどうか。
  • 集約が継承よりも優れているかどうか、および集約によって問題が解決されるかどうか。
  • 例外の根本的な原因をスタックトレースから解析できるかどうか、およびこれが適切かどうか。

私が話したいこと:

  • 基本クラスから派生した開発者によって導入されたそのようなプログラミングの欠陥を軽減するのに十分なほど基本クラスを堅牢にすることは良い考えかどうか。
  • 基本クラスをできるだけシンプルに保ち、許可された例外のみをスローする責任を基本クラスから派生する開発者に委任することは良い考えかどうか。

これは、より堅牢でより複雑な実装の私の解決策です(編集されたメソッドGetValueのみを表示):

public class MyCollection
{
  // ...
  // some members have been omitted for brevity
  // ...

  /// <summary>
  ///   ...
  /// </summary>
  /// <param name="key">...</param>
  /// <returns>...</returns>
  /// <exception cref="KeyNotFoundException">An entry with the provided key could not be found.</exception>
  public string GetValue(string key)
  {
    string value;
    ExceptionDispatchInfo approvedEdi;

    try
    {
      this.GetValueImpl(key, out value, out approvedEdi);
    }
    catch (KeyNotFoundException exc)
    {
      throw new InvalidOperationException("Exception caught from customized code.", exc);
    }

    approvedEdi?.Throw();
    return value;
  }

  private void GetValueImpl(string key, out string value, out ExceptionDispatchInfo approvedEdi)
  {
    value = null;
    approvedEdi = null;

    try
    {
      value = this.underlyingCollection[key];
    }
    catch (KeyNotFoundException exc)
    {
      approvedEdi = ExceptionDispatchInfo.Capture(exc);
      return;
    }

    value = this.ProcessEntry(key, value);
  }
}

元の投稿

3
Peter Perot

質問:

基本クラスは、APIドキュメントの一部である、誤ってスローされた例外をラップする責任を負うべきですか?

同じ質問の少し長いバージョン:

拡張機能に対してオープンな基本クラスは、派生クラスとそのメソッドからの誤った、または誤解を招く例外をスローする動作を軽減する責任を負うべきですか?

概観

これは、「例外の保証」のより大きな議論に該当します。これは、コードの組み合わせによって実現できる最小限の安全性と機能についての保証です(ライブラリコードとアプリケーションコード、またはベース/フレームワーククラスと派生クラス/サブクラス)。例外的な動作が考慮される場合。説明されている例外的な動作は、意図された(正しく実装および伝達された)動作と意図されていない(誤って実装された)動作の両方をカバーします。

従来の情報源と興味

...アンマネージプログラミング言語から

C++などの事前にコンパイルされたプログラミング言語では、リソース管理をランタイム環境のみで行うマネージプログラミング言語とは対照的に、リソース管理をソースコードとコンパイルされたマシンコードに組み込む必要があります。このため、スローされた例外(通常、残りのコードがスキップされる)の存在下でのリソース管理コードの実行は、C++などのアンマネージプログラミング言語に大きな関心があります。

この問題を調査した専門家は、通常、やり取りは非常に複雑であると結論付けています。スローされた例外が存在する場合にC++コードの特定の部分が正しく記述されたと主張する際に、業界の専門家でさえ間違いを犯しました。

...そして管理されたプログラミング言語から

マネージプログラミング言語では、メモリ管理の責任の大部分は言語ランタイムにあるため、通常、メモリリークとデータ破損は最大の問題ではありません。言語ランタイムは、例外がスローされても、いくつかの保証を提供します。

ただし、ネットワークリソースやデータベース操作など、他の種類のリソースやソフトウェア操作に関しては、責任はプログラマーにあります。

...そして伝統的なエンジニアリングから。

ソフトウェアの例外的な動作は、ソフトウェアアーキテクチャのフェイルセーフ設計に関する大きな議論の一部です。

例外的な安全保証

ウィキペディアの主な記事: https://en.wikipedia.org/wiki/Exception_safety

障害からの回復性

例外の安全性保証の多くは、プログラムが障害から回復しようとするときに期待できるものに正確に焦点を合わせています(例外がスローされ、特定のコードがスキップされる原因になります)。

一部のタイプのアプリケーションアーキテクチャ(一部の古いアーキテクチャを含む)では、保証された例外の回復可能性の設計は、これらのアーキテクチャではソフトウェア操作がユーザーコマンドに編成されるため、やや簡単です。ユーザーコマンド内のどこかで障害が発生した場合、これらのアプリケーションアーキテクチャは、失敗したユーザーコマンドが発生しなかったかのように、デフォルトでプログラムの状態をロールバックします。

今日のプログラミング言語の例外処理メカニズムは、これらの古いアプリケーションアーキテクチャに大きく貢献しています。ユーザーコマンドを囲む「巨大な」try-catchブロックが存在するため、例外がスローされて他の場所でキャッチされなかった場合、ユーザーコマンドは失敗しますが、他のデータ損失やアプリケーションの誤動作は発生しません。

現在、他の多くのソフトウェアアプリケーションでは、今日の例外処理メカニズムでは不十分です。典型的なユーザーコマンドには、数十から数百のバックエンド操作の連鎖が含まれる場合があります。これらのいずれかが失敗する可能性があります。これらの障害は、一般的に(均一に)処理するのではなく、具体的に(異なる方法で)処理する必要があります。これは Railway Oriented Programming を刺激して、例外的なケースのコストを合体によって管理できるようにします。

全体としての試験システムの重要性

フレームワークに関しては、フレームワーク開発チームは、フレームワークの使用目的を説明するために使用されるサンプル拡張機能だけでなく、独自のコードをテストする責任があります。

拡張機能または派生クラスの開発者は、独自のコードをテストする責任があります。

フレームワーク内のソースコードを確認することの重要性

フレームワークのソースコードが利用できないか不明瞭になっているため、フレームワークと拡張コード間の予期しない相互作用のトラブルシューティングが不可能になる場合があります(参照: Wikipediaのソフトウェア難読化記事 )。フレームワークベンダーはこの問題を強く認識しており、これは歴史的に顧客の不満につながり、将来の販売を妨げてきました。フレームワークベンダーは、有料ソースの顧客にソースコードのライセンスを付与するようになっています(そのソースコードを一般に公開することを防ぎます)。これにより、有料ユーザーは、拡張コードからスローされる例外に対するフレームワークの期待について推論することができます。

野生のコードベースからの観察

小規模なチームや自家製のプロジェクトの場合、基本クラスと派生クラスは通常、同じプログラマーによって作成されます。その結果、プログラマーは、例外をスローする動作のどのような使用が許容されるかについて、一貫した見解と期待を持っています。これは便利で効率的な時間の使用ですが、欠点は、唯一のプログラマが多くの場合、これらの期待を言葉での例外の使用に書き留めないことにあります。それを書き留めようとしないと、プログラマーはそれを注意深く検討することにあまり時間を費やさないかもしれません。

中規模のチームでは、派生クラス(拡張)を作成しているプログラマーが、元の基本クラスを作成した元のプログラマーと相談できることが非常に重要です。関係するすべてのプログラマーは優れたコード読み取りスキルを備えており、さまざまなプログラマーが作成した複雑なコードを通じて例外的な動作を推論できることが期待されます。必要に応じて、特定の場所に例外を挿入することにより、実験を通じて推論を検証する必要がある場合があります。この規模のソフトウェアには、多くの場合、広範なロギングがあり、開発と本番の両方でのソフトウェアの例外的な動作の理解に役立ちます。

大規模なチーム、市販のソフトウェアフレームワーク、およびオペレーティングシステム向け。あまり注意を払わない拡張コードがシステムを停止させるリスクは非常に大きいため、これらの大規模なソフトウェアシステムは、多くの場合、拡張コードから不正な動作を傍受して分離するという余計な作業を行います。これらのソフトウェアシステムでは、フレームワークコードから拡張コードに制御が移行するたびに、try-catchブロックが発生し、元のソフトウェアの状態を保護して、破損した場合にこれらの状態を回復できるようにします。また、エラーの状態を人間のユーザーに通知するユーザーインターフェイス要素もあり、不正な動作がフレームワーク自体ではなく特定の拡張コードに起因している可能性があることを明確にします。


元の質問への回答

Doc BrownとShapeOfMatterの回答はどちらも、コーディング設定の選択に加えてデザインの問題があることを示唆しています。

ただし、別のデザインの問題が表示されます。

  • 典型的なプログラマーがDecorateFilenameという名前のメソッドを見つけたとき、それが何をすることになっているのか、そしてそれについてどのような仮定ができるのかについて直感があると思います。
  • たとえば、失敗した場合は、ファイル名が装飾されないなど、予期した文字列フォーマット規則に何らかの形で適合していない可能性があります。
  • DecorateFilenameは、ファイル名の検証(許可されたファイル名のリストに対して)を行うために再利用されているため、基本クラス(DecorateFilename)は期待していませんでした:
    • 許可されたファイル名のリストが由来するファイルが存在しない可能性があります。
    • LoadDataに渡されるファイル名は、この検証チェックに失敗した可能性があります。
  • 理想的には、これらの追加の障害モードをさまざまな例外タイプにマップして、APIの呼び出し側がそれらを簡単に処理できるようにする必要があることに同意します。迷惑なUIエラーメッセージがいかに煩わしいかは、私たち全員が知っています。

責任をシフトすることにより、(他の時点に)障害モードをシフトすることができます。いくつかの可能性があります:

  • 「許可されたファイル名のリストが保存されているファイル」を事前にロードします。いくつかの回答とコメントが指摘しているように、このファイルは「インフラストラクチャ」または「デプロイメント」または「構成」の性質を持ち、これらは通常、事前に初期化されます。
  • 「ファイル名の装飾」と「ファイルの読み込み」の役割を分離して、「ファイル名の検証」の新しいステップをそれらの間に挿入できるようにし、APIの呼び出し元がそれらから異なる例外をキャッチすることを期待することを認識します。

ただし、今日では、多くのプログラミングプラクティスにより、この種の不注意で誤解を招く例外が予期しないときにスローされる(見られる)ようになっています。構成の一部として「許可されたファイル名ファイル」をロードするオプションに戻ります。実際に使用されるまですべてを遅延ロードする一般的な方法があります。その場合、実際に使用されたときに例外がスローされます。つまり、失敗モードをシフトしようとしても機能しません。

要約すると、今日のプログラミングスタイルでは、予期しないときに(予期しないAPIメソッドから)予期しないエラーモードと例外がスローされる可能性があることを受け入れる必要があります。これらの可能性を予測し、受け入れ、対応しなければなりません。

1
rwong

誤解を招く例外をキャッチして、誤解を招かない例外に変換することで、それらの誤解を招く例外が発生するのを防ぐことができます。

  public class CustomizedFileManager : MyFileManager
  {
    protected override string DecorateFilename(string filename)
    {
      try
      {
        string decoratedFilename = base.DecorateFilename(filename);
        var allowedNames = File.ReadAllLines("ListOfAllowedFilenames.txt"); // can throw FileNotFoundException

        if (!allowedNames.Contains(decoratedFilename))
        {
          throw new InvalidOperationException("Filename not allowed");
        }

        return decoratedFilename;
      }
      catch (FileNotFoundException e)
      {
        throw new DeploymentException("The file \"ListOfAllowedFilenames.txt\" couldn't be found", e);
      }
    }
  }

CustomizedFileManagerクラスは、FileNotFound例外がデプロイメントの問題であり、ユーザーが指定したファイルに関連する問題ではないことを認識しているため、例外を適切に再解釈できます。

例外をキャッチして再解釈します。

  public class CustomizedFileManager : MyFileManager
  {
    protected override string DecorateFilename(string filename)
    {
      string decoratedFilename = base.DecorateFilename(filename);

      try
      {
          var allowedNames = File.ReadAllLines("ListOfAllowedFilenames.txt"); 
      }
      catch (FileNotFoundException e)
      {
          throw new InvalidOperationException(
              "The Load Data operation could not be executed because " + filename + 
              " could not be found, most likely due to a deployment error.", 
              e);
      }

      if (!allowedNames.Contains(decoratedFilename))
      {
        throw new InvalidOperationException("Filename not allowed");
      }

      return decoratedFilename;
    }
  }

これがコードのにおいだと思う理由がわかりません。 「ファイルが見つかりません」の例外がスタックをバブルアップしないことが要件である場合は、それを実現するコードを記述します。

5
Robert Harvey

Edit 1は、返す適切な例外を決定するのが派生クラスの責任であることをより明確にします。

返される例外は、通常は無視されるか、プログラムを停止するか、または何かをトリガーするかに関係なく、何らかの方法で実行可能である必要があります。

コレクションのコンシューマーが、ユーザーが「承認済み」リストにないキーの表示を要求したとき、または単に表示を要求したときに実行したい追加のアクションはありますか辞書にないキーの?

あなたは決して消費者が例外メッセージを解析して実行するアクションを決定する必要がありますが、KeyNotInApprovedListExceptionをスローすることで消費者にトリガーを許可できる可能性がありますセキュリティ監査ログエントリ、GUI要素を赤に着色する、または承認をリクエストできる場所にユーザーを転送する。これは派生クラスの設計者によって行われる決定であり、スローされる例外タイプは、基本クラスの設計中に考慮されないユースケースを持つ派生クラスを含むライブラリにある可能性があります。

Framework Design Guidelines には例外に関する章があり、7.2.3の特定のサブセクションがあり、ラッピング例外質問に関連していますこれは、トランザクション管理APIから上位レベルに伝播することが許可されている場合、FileNotFoundExceptionのような下位層からの例外がどのように完全に無意味であるかを示し、TransactionFileMissingExceptionをスローする場合があります。

4
nvuono

これを処理する方法はたくさんあります。 3つの可能性があります。

データのロードのみにフォーカスLoadData

メソッドが次のようになるだけです:

public string[] LoadData(string filename)
{
    if (filename == null) throw new ArgumentNullException(nameof(filename));

    return File.ReadAllLines(filename);
}

さて、その例外は単にファイルにアクセスできないためです。 filenameを決定するのは、別のコードの責任になります。

ブロックDecorateFilenameFileNotFoundExceptionをスローしないようにする=

public string[] LoadData(string filename)
{
    if (filename == null) throw new ArgumentNullException(nameof(filename));

    string decoratedFilename = null;
    try
    {
        decoratedFilename = DecorateFilename(filename);
    }
    catch (FileNotFoundException ex)
    {
        throw new InvalidOperationException(InnerException: ex);
    }

    return File.ReadAllLines(decoratedFilename);
}

したがって、InvalidOperationExceptionに問題がある場合、DecorateFilenameがスローされます。

例外を回避するには、「試行パターン」を使用してください

public string[] LoadData(string filename)
{
    if (filename == null) throw new ArgumentNullException(nameof(filename));

    if (!TryDecorateFilename(filename, out var decoratedFilename))
    {
        throw new InvalidOperationException();
    }

    return File.ReadAllLines(decoratedFilename);
}

したがって、TryDecorateFilenameは例外をスローしません。失敗するとfalseを返します。

3
David Arno

あなたが説明している問題の一般的な解決策に向けて合理的なアプローチをとることはできないと思います。これは私ができる最高のことです:

  • 「誤解を招く例外」の可能性は、過度に複雑または不正な形式のソリューションを示唆しています。
  • コード内の例外の解析は最後の手段でなければなりません。

あなたが与える例では、configファイルがないためにファイル読み取りコードがFileNotFound例外をスローする可能性があるという事実の両方に注意する必要があります。そして、ファイルが存在するかどうかを検出するためにFileNotFound例外に依存していること。

  • 許可されたファイル名のリストをコードに含めることもできますが、これは問題ありません。または、存在と有効性がすでにチェックされている、より汎用的な構成ファイルに含めることもできます。それが適切でない場合は、allowed-filename構成ファイルをCustomizedFileManagerコンストラクターで読み取る必要があります(この構成ファイルの名前を引数として取る場合があります)。
  • スタックの上位のコードがファイルが存在するかどうかを知りたい場合は、例外を待つのではなく、ファイルが存在するかどうかを確認する必要があります。私たちはこれによく違反します。時々、すべてを行う価値がないrightが、それはright方法です。
2
ShapeOfMatter

多くの場合、継承を可能にするためにクラスを正しく設計することは、一見するよりも困難です。この場合、1つの問題がコメントに既に非表示になっています

/// <exception cref="FileNotFoundException">File <paramref name="filename" /> has not been found.
/// </exception>

これは、これをクラスのパブリックAPIの非公式契約にします。クラスのユーザーは、この例外を正確にこの種の失敗のみを表すものとして期待します。

派生が任意の例外をスローできるようにする保護された関数と一緒に、派生クラスがLiskov Substition Principleに違反するのを簡単にします-つまり、派生が例外を使用して呼び出し元にエラーを通知しようとすると、元のクラスに従わないリスクが発生しやすくなりますクラスの契約。

私はこれに対処するさまざまな方法を見ています:

  • 単純に継承を許可せず、元のクラスをシールして、保護されたメソッドを「プライベート」に変更します。このようにして、クラスのユーザーは、次のようなソリューションを使用することを余儀なくされます DavidArnoによってスケッチされます #1として-コード内の別の部分にファイル名チェックを追加し、独自のエラー処理を追加します。これにより、コードの責任を軽減することで問題が解決します。ファイルのロード、ファイルの自動名前変更、および許可されたファイルのリストに対する自動検証の責任をすべて1つの関数にまとめると、特にエラーが発生した場合に、さまざまなステップの結果を分離することが難しくなります。

  • コメントを変更します-呼び出し元がすべての種類の例外を受け取ることができることを明確にします。したがって、FileNotFound例外が発生した場合は、常にメッセージテキストを評価または利用する必要があります(これには実際にはステートメントwhichファイルが正確に欠落していたため、試してみました)。また、InvalidOperationExceptionsまたは他の型も処理する必要があります。これにより、派生クラスがLSPに違反することなく、さまざまな問題に対してさまざまな例外を使用できるようになります( Bart van Ingen Schenauの回答 および Robert Harveyの回答 に示すように)。

  • 派生クラスを強制してLSPに従います(つまり、コメントで説明されているものとは異なるタイプまたは異なるセマンティクスの例外をスローしないようにします)。残念ながら、コントラクトはコメントとしてのみ定義されていたため、コードレビューによってのみ実施でき、この場合、派生が適切なエラー処理を利用することはほとんど不可能になります。

したがって、問題の根本的な原因はすでにベースクラスの設計にあり、その後ベースクラスを変更せずに派生クラスでそれを解決しようとすることはトレードオフにすぎません。

2
Doc Brown