web-dev-qa-db-ja.com

エラー処理の考慮事項

問題:

長い間、exceptionsメカニズムが心配です。本当に何をすべきか解決できないと感じているからです。

CLAIM:このトピックについては外部で長い議論があり、それらのほとんどはexceptionsと比較してエラーコードを返すのに苦労しています。これは間違いなくここのトピックではありません。

エラーを定義しようとすると、Bjarne Stroustrup&Herb SutterのCppCoreGuidelinesに同意します

エラーは、関数がアドバタイズされた目的を達成できないことを意味します

CLAIM:exceptionメカニズムは、エラーを処理するための言語セマンティックです。

CLAIM:私には、タスクを達成しないための関数に「言い訳はありません」:関数が結果を保証できないように事前/事後条件を誤って定義したか、特定の例外的なケースが開発に時間を費やすのに十分重要であるとは見なされません解決策。それを考慮して、IMO、通常のコードとエラーコードの処理の違いは(実装前に)非常に主観的な行です。

CLAIM:事前または事後の条件が満たされていないことを示すために例外を使用することは、主にデバッグ目的でのexceptionメカニズムのもう1つの目的です。ここでは、exceptionsのこの使用法は対象としていません。

多くの書籍、チュートリアル、その他のソースでは、エラー処理を非常に客観的な科学として示している傾向があり、exceptionsで解決されます。堅牢なソフトウェアを使用するには、それらをcatchするだけで済みます。あらゆる状況から回復します。しかし、開発者としての私の数年は、私に別のアプローチから問題を見ることをさせます:

  • プログラマーは、特定のケースが非常にまれに慎重に実装できないと思われる場合に例外をスローすることにより、タスクを単純化する傾向があります。これの典型的なケースは次のとおりです:メモリ不足の問題、ディスクがいっぱいの問題、破損したファイルの問題など。これで十分な場合もありますが、必ずしもアーキテクチャレベルで決定されるとは限りません。
  • プログラマーは、ライブラリーの例外に関するドキュメントを注意深く読んでいない傾向があり、通常、関数がスローするタイミングとそのタイミングを認識していません。さらに、彼らが知っているときでさえ、彼らは本当にそれらを管理しません。
  • プログラマーは例外を十分に早期にキャッチしない傾向があり、その場合、ほとんどの場合、ログに記録してさらにスローします。 (最初のポイントを参照してください)。

これには2つの結果があります。

  1. 頻繁に発生するエラーは、開発の早い段階で検出され、デバッグされます(これは良いことです)。
  2. まれな例外は管理されず、ユーザーの自宅でシステムが(Niceログメッセージと共に)クラッシュします。エラーが報告されることもあれば、報告されないこともあります。

このことを考慮すると、IMOのエラーメカニズムの主な目的は次のとおりです。

  1. 特定のケースが管理されていないコードで可視化します。
  2. この状況が発生した場合は、問題のランタイムを関連コード(少なくとも呼び出し元)に通知します。
  3. 回復メカニズムを提供

エラー処理メカニズムとしてのexceptionセマンティックの主な欠陥はIMOです。throwがソースコードのどこにあるかは簡単にわかりますが、特定の関数が宣言を見てスローします。これは、上で紹介したすべての問題をもたらします。

言語は、言語の他の側面(たとえば、強い型の変数)の場合ほど厳密にエラーコードを強制およびチェックしません。

解決策を試す

これを改善するために、私は非常に単純なエラー処理システムを開発しました。これは、エラー処理を通常のコードと同じレベルの重要度にしようとするものです。

アイデアは:

  • 各(関連)関数はsuccess非常に軽量なオブジェクトへの参照を受け取り、場合によってはエラーステータスに設定することがあります。テキストのエラーが保存されるまで、オブジェクトは非常に軽量です。
  • 提供されたオブジェクトにすでにエラーが含まれている場合、関数はそのタスクをスキップすることをお勧めします。
  • エラーがオーバーライドされてはなりません。

完全なデザインは明らかに各側面(約10ページ)を徹底的に検討し、それをOOPに適用する方法も考慮します。

Successクラスの例:

class Success
{
public:
    enum SuccessStatus
    {
        ok = 0,             // All is fine
        error = 1,          // Any error has been reached
        uninitialized = 2,  // Initialization is required
        finished = 3,       // This object already performed its task and is not useful anymore
        unimplemented = 4,  // This feature is not implemented already
    };

    Success(){}
    Success( const Success& v);
    virtual ~Success() = default;
    virtual Success& operator= (const Success& v);

    // Comparators
    virtual bool operator==( const Success& s)const { return (this->status==s.status && this->stateStr==s.stateStr);}
    virtual bool operator!=( const Success& s)const { return (this->status!=s.status || this->stateStr==s.stateStr);}

    // Retrieve if the status is not "ok"
    virtual bool operator!() const { return status!=ok;}

    // Retrieve if the status is "ok"
    operator bool() const { return status==ok;}

    // Set a new status
    virtual Success& set( SuccessStatus status, std::string msg="");
    virtual void reset();

    virtual std::string toString() const{ return stateStr;}
    virtual SuccessStatus getStatus() const { return status; }
    virtual operator SuccessStatus() const { return status; }

private:
    std::string stateStr;
    SuccessStatus status = Success::ok;
};

使用法:

double mySqrt( Success& s, double v)
{
    double result = 0.0;
    if (!s) ; // do nothing
    else if (v<0.0) s.set(Error, "Square root require non-negative input.");
    else result = std::sqrt(v);
    return result;
}

Success s;
mySqrt(s, 144.0);
otherStuff(s);
saveStuff(s);
if (s) /*All is good*/;
else cout << s << endl;

私は自分の(自分の)コードの多くでそれを使用しており、プログラマ(私)は可能な例外的なケースとそれらを解決する方法(良い)についてさらに考えるように強いています。ただし、学習曲線があり、現在使用しているコードとうまく統合できません。

質問

プロジェクトでそのようなパラダイムを使用することの意味をよりよく理解したいと思います。

  • 問題の前提は正しいですか?または関連するものを逃しましたか?
  • ソリューションは優れたアーキテクチャのアイデアですか?または価格が高すぎますか?

編集:

メソッド間の比較:

//Exceptions:

    // Incorrect
    File f = open("text.txt"); // Could throw but nothing tell it! Will crash
    save(f);

    // Correct
    File f;
    try
    {
        f = open("text.txt");
        save(f);
    }
    catch( ... )
    {
        // do something 
    }

//Error code (mixed):

    // Incorrect
    File f = open("text.txt"); //Nothing tell you it may fail! Will crash
    save(f);

    // Correct
    File f = open("text.txt");
    if (f) save(f);

//Error code (pure);

    // Incorrect
    File f;
    open(f, "text.txt"); //Easy to forget the return value! will crash
    save(f);

    //Correct
    File f;
    Error er = open(f, "text.txt");
    if (!er) save(f);

//Success mechanism:

    Success s;
    File f;
    open(s, "text.txt");
    save(s, f); //s cannot be avoided, will never crash.
    if (s) ... //optional. If you created s, you probably don't forget it.
31
Adrian Maire

エラー処理は、おそらくプログラムの最も難しい部分です。

一般に、エラー状態があることを認識するのは簡単です。しかし、回避できない方法で信号を送り、適切に処理する( Abrahamsの例外安全性レベル を参照)ことは、本当に難しいことです。

Cでは、エラーの通知は戻りコードによって行われます。これは、ソリューションに同型です。

C++では、このようなアプローチのshort-comingのために例外が導入されました。つまり、呼び出し元がエラーが発生したかどうかを確認し、それ以外の場合はひどく失敗する場合にのみ機能します。 「いつでも大丈夫...」と自分を見つけたときはいつでも問題があります。人間は気にしても、それほど細心ではありません。

ただし、問題は例外に独自の問題があることです。つまり、非表示/非表示の制御フローです。これは、コードのロジックがエラー処理のボイラープレートによって難読化されないように、エラーケースを非表示にすることを目的としていました。エラーパスを不可解に近づけることを犠牲にして、「ハッピーパス」をより明確に(そして高速に!)作成します。


他の言語がこの問題にどのように取り組んでいるかを見るのは興味深いです。

  • Javaは例外をチェックしました(そしてチェックしなかったもの)、
  • Goはエラーコード/パニックを使用し、
  • Rustは sum types /panics)を使用します。
  • FP言語全般。

C++には何らかの形式のチェック例外がありましたが、代わりに非推奨になり、基本的なnoexcept(<bool>)に向かって簡素化されていることに気づいたかもしれません。チェックされた例外は拡張性に欠けるという点でやや問題があり、扱いにくいマッピング/ネストを引き起こす可能性があります。そして、複雑な例外階層(仮想継承の主な使用例の1つは例外...)です。

対照的に、GoとRustは次のアプローチをとります。

  • エラーは帯域内で通知する必要があります。
  • 例外は本当に例外的な状況で使用されるべきです。

後者は、(1)例外に名前を付けるpanicsであり、(2)ここに型階層/複雑な句がないという点で、かなり明白です。この言語は、「パニック」の内容を検査する機能を提供していません。タイプ階層、ユーザー定義の内容はなく、「おっと、問題が発生したため、回復できません」。

これにより、ユーザーは適切なエラー処理を使用できるようになりますが、例外的な状況(「待って、まだ実装していません!」など)でも簡単に救済することができます。

もちろん、残念ながらGoのアプローチは、エラーをチェックするのを忘れやすいという点で、あなたのアプローチとよく似ています...

... Rustアプローチは、主に2つのタイプを中心としています:

  • Option、これは_std::optional_に似ています、
  • Result 、これは2つの可能性のあるバリアントです:OkおよびErr。

成功を確認せずに誤って結果を使用する機会がないため、これははるかに簡潔です。実行すると、プログラムがパニックになります。


FP言語は、3つの層に分割できる構造でエラー処理を形成します。-Functor-適用可能/代替-モナド/代替

HaskellのFunctorタイプクラスを見てみましょう:

_class Functor m where
  fmap :: (a -> b) -> m a -> m b
_

まず第一に、型クラスは多少似ていますが、インターフェースとは異なります。 Haskellの関数シグネチャは、一見すると少し恐ろしく見えます。しかし、それらを解読しましょう。関数fmapは、_std::function<a,b>_に似た関数を最初のパラメーターとして受け取ります。次は_m a_です。 mは_std::vector_のようなものとして、_m a_は_std::vector<a>_のようなものとして想像できます。しかし、違いは、_m a_は明示的に_std:vector_である必要があるとは言っていないことです。したがって、それも_std::option_になる可能性があります。 _std::vector_や_std::option_などの特定の型の型クラスFunctorのインスタンスがあることを言語に伝えることにより、その型に関数fmapを使用できます。タイプクラスApplicativeAlternativeMonadについても同様に行う必要があります。これにより、ステートフルで失敗する可能性のある計算を実行できます。 Alternative typeclassは、エラー回復の抽象化を実装します。これにより、_a <|> b_のように言うことができます。つまり、aという用語またはbという用語のいずれかです。どちらの計算も成功しない場合でも、エラーになります。

HaskellのMaybe型を見てみましょう。

_data Maybe a
  = Nothing
  | Just a
_

つまり、_Maybe a_を期待している場合、Nothingまたは_Just a_のいずれかを取得します。上からfmapを見ると、実装は次のようになります。

_fmap f m = case m of
  Nothing -> Nothing
  Just a -> Just (f a)
_

_case ... of_式はパターンマッチングと呼ばれ、OOP世界で_visitor pattern_として知られているものに似ています。_case m of_をm.apply(...)として想像してくださいドットは、ディスパッチ関数を実装するクラスのインスタンス化です。_case ... of_式の下の行は、クラスのフィールドを名前でスコープ内に直接持ってくるそれぞれのディスパッチ関数です。Nothingブランチでは、Nothingと_Just a_ブランチでは、唯一の値をaとし、変換関数faに適用して、別の_Just ..._を作成します。これをnew Just(f(a))として読み取ります。

これにより、実際のエラーチェックを抽象化しながら、誤った計算を処理できるようになりました。この種の計算を非常に強力にする他のインターフェースの実装が存在します。実際、Maybeは、RustのOption- Typeの発想です。


代わりに、SuccessクラスをResultに向けて作り直すことをお勧めします。 Alexandrescuは実際に_expected<T>_と呼ばれる非常に近いものを提案しました。そのために標準的な提案 作成されました です。

私はRust命名とAPIに固執します。なぜなら、それが文書化され、機能しているからです。もちろん、Rustには、気の利いた_?_サフィックスがあります。コードをより甘くする演算子; C++では、TRYマクロとGCCの ステートメント式 を使用してエミュレートします。

_template <typename E>
struct Error {
    Error(E e): error(std::move(e)) {}

    E error;
};

template <typename E>
Error<E> error(E e) { return Error<E>(std::move(e)); }

template <typename T, typename E>
struct [[nodiscard]] Result {
    template <typename U>
    Result(U u): ok(true), data(std::move(u)), error() {}

    template <typename F>
    Result(Error<F> f): ok(false), data(), error(std::move(f.error)) {}

    template <typename U, typename F>
    Result(Result<U, F> other):
        ok(other.ok), data(std::move(other.data)),  error(std::move(other.error)) {}

    bool ok = false;
    T data;
    E error;
};

#define TRY(Expr_) \
    ({ auto result = (Expr_); \
       if (!result.ok) { return result; } \
       std::move(result.data); })
_

注:このResultはプレースホルダーです。適切な実装では、カプセル化とunionを使用します。ただし、要点は十分です。

これは私が書くことを可能にします( 実際に見る ):

_Result<double, std::string> sqrt(double x) {
    if (x < 0) {
        return error("sqrt does not accept negative numbers");
    }
    return x;
}

Result<double, std::string> double_sqrt(double x) {
    auto y = TRY(sqrt(x));
    return sqrt(y);
}
_

私はそれを本当にきちんと見つけます:

  • エラーコード(またはSuccessクラス)の使用とは異なり、エラーのチェックを忘れるとランタイムエラーが発生します1 ランダムな動作ではなく、
  • 例外の使用とは異なり、呼び出しサイトでどの関数が失敗する可能性があるかは明らかなので、驚くことはありません。
  • c ++-2X標準では、標準でconceptsを取得する場合があります。これにより、この種のプログラミングは、エラーの種類よりも選択を残すことができるため、はるかに楽しいものになります。例えば。結果として_std::vector_の実装により、すべての可能なソリューションを一度に計算できました。または、提案したように、エラー処理を改善することもできます。

1 適切にカプセル化されたResult実装;)


注:例外とは異なり、この軽量のResultにはバックトレースがないため、ロギングの効率が低下します。少なくとも、エラーメッセージが生成されたファイル/行番号をログに記録し、通常は詳細なエラーメッセージを書き込むと便利です。これは、TRYマクロが使用されるたびにファイル/行をキャプチャするか、本質的に手動でバックトレースを作成するか、libbacktraceなどのプラットフォーム固有のコードとライブラリを使用して、コールスタックにシンボルをリストすることにより、さらに複雑になります。


ただし、大きな注意点が1つあります。既存のC++ライブラリ、さらにはstdも例外に基づいています。サードパーティのライブラリのAPIはアダプターでラップする必要があるため、このスタイルを使用するのは困難な戦いになります...

32
Matthieu M.

CLAIM:例外メカニズムは、エラーを処理するための言語セマンティックです

例外は制御フローメカニズムです。この制御フローメカニズムの動機は、エラー処理が非常に反復的であり、メインとの関連性がほとんどない一般的なケースでは、エラー処理を非エラー処理コードから分離することでしたロジックの一部。

CLAIM:私には、タスクを達成しないための関数に「言い訳はありません」:関数が結果を保証できないように事前/事後条件を誤って定義したか、特定の例外的なケースが開発に時間を費やすのに十分重要であるとは見なされません解決策

検討:ファイルを作成しようとします。ストレージデバイスがいっぱいです。

さて、これは私の前提条件を定義するのに失敗したわけではありません。共有ストレージは、これを満たせない競合状態の影響を受けるため、一般に「十分なストレージが必要」を前提条件として使用することはできません。

それで、私のプログラムは何らかの方法で領域を解放してから正常に続行する必要があります。そうしないと、「ソリューションを開発する」のが面倒です。これは率直に言って無意味なようです。共有ストレージを管理する「解決策」はプログラムの範囲外であり、ユーザーのプログラムが適切に失敗し、ユーザーがスペースを解放したら再実行できるようにします。または、さらにストレージを追加しました。fineです。


成功クラスが行うことは、エラー処理をプログラムロジックに非常に明示的にインターリーブすることです。すべての単一の関数は、実行前に、何らかのエラーがすでに発生しているかどうかをチェックする必要があります。つまり、何もすべきではありません。すべてのライブラリ関数は、もう1つの関数でラップする必要があります。引数を1つ追加して(うまくいけば完全に転送できます)、まったく同じことを行います。

mySqrt関数は、失敗した場合(または前の関数が失敗した場合)に値evenを返す必要があることにも注意してください。したがって、マジック値(NaNなど)を返すか、不確定な値をプログラムに注入して、hoping何も成功状態をチェックせずにそれを使用しますあなたはあなたの実行を通り抜けました。

正確性とパフォーマンスのために、進歩を遂げることができなくなったら、制御をスコープ外に戻すのがはるかに良いです。例外とCスタイル早期エラーによる明示的なエラーチェックの両方がこれを実現します。


比較として、実際に機能するアイデアの例は、Haskellの Errorモナド です。システムに対する利点は、ロジックの大部分を通常どおりに記述し、それをモナドでラップして、1つのステップが失敗したときに評価を停止することです。このようにして、エラー処理システムに直接触れる唯一のコードは、失敗する可能性があるコード(エラーをスローする)と、失敗に対処する必要があるコード(例外をキャッチする)です。

モナドスタイルと遅延評価がC++にうまく変換できるかどうかはわかりません。

46
Useless

プロジェクトでそのようなパラダイムを使用することの意味をよりよく理解したいと思います。

  • 問題の前提は正しいですか?または関連するものを逃しましたか?
  • ソリューションは優れたアーキテクチャのアイデアですか?または価格が高すぎますか?

あなたのアプローチはあなたのソースコードにいくつかの大きな問題をもたらします:

  • sの値をチェックすることを常に覚えているクライアントコードに依存しています。これはエラー処理に戻りコードを使用するアプローチと共通であり、例外が言語に導入された理由の1つです。例外があれば、失敗しても、黙って失敗することはありません。

  • このアプローチで作成するコードが多ければ多いほど、エラー処理のために追加する必要のあるエラーボイラープレートコードも多くなり(コードはもはや最小限ではなくなります)、保守作業が増えます。

しかし、開発者としての私の数年は、私に別のアプローチから問題を見ることをさせます:

これらの問題の解決策は、技術リーダーレベルまたはチームレベルでアプローチする必要があります。

プログラマーは、特定のケースが非常にまれに慎重に実装できないと思われる場合に例外をスローすることにより、タスクを単純化する傾向があります。これの典型的なケースは次のとおりです:メモリ不足の問題、ディスクがいっぱいの問題、破損したファイルの問題など。これで十分な場合もありますが、必ずしもアーキテクチャレベルで決定されるとは限りません。

スローされる可能性のあるすべてのタイプの例外を常に処理していることに気付いた場合、デザインは良くありません。どのエラーが処理されるかは、開発者が実装したい気分ではなく、プロジェクトの仕様に従って決定する必要があります。

自動テストをセットアップし、単体テストの仕様と実装を分離して対処します(2人の異なる人にこれを実行してもらいます)。

プログラマーはドキュメンテーションを注意深く読まない傾向があります[...]さらに、彼らが知っているときでさえ、彼らは本当にそれらを管理しません。

これ以上コードを記述してこれに対処することはありません。あなたの最善の策は、細心の注意を払って適用されたコードレビューだと思います。

プログラマーは例外を十分に早期にキャッチしない傾向があり、その場合、ほとんどの場合、ログに記録してさらにスローします。 (最初のポイントを参照してください)。

適切なエラー処理は困難ですが、例外があり、戻り値よりも例外ではありません(実際に返されるか、I/O引数として渡されるかは関係ありません)。

エラー処理の最もトリッキーな部分は、エラーを受け取る方法ではなく、エラーが発生した場合にアプリケーションが一貫した状態を維持するようにする方法です。

これに対処するには、エラー状態の特定と実行にさらに注意を払う必要があります(より多くのテスト、より多くの単体/統合テストなど)。

15
utnapistim