C++ の例外と例外安全性

最終更新: 2024-05-28 17:55:25 +0800 +0800
原文は英語で書かれています。あなたはAI翻訳版を読んでいます。

例外は、失敗した操作の報告と、それに対応できる境界への伝播を分離します。例外は一致するハンドラーへ制御を移します。制御が外側へ移動する間、C++ は終了するスコープ内で完全に構築された自動オブジェクトを、構築と逆の順序で破棄します。したがって、Resource Acquisition Is Initialization (RAII) によって通常のクリーンアップ経路と例外時のクリーンアップ経路を同じにできます。

cpp
void load_configuration(std::filesystem::path path) {
    std::ifstream input{path};             // Closed during unwinding.
    if (!input) {
        throw std::runtime_error{"cannot open configuration"};
    }
    // Parsing may throw; input still closes.
}

try {
    load_configuration("app.conf");
} catch (const std::exception& error) {
    report(error.what());
}

例外オブジェクトは値で throw し、多相的な例外型は const 参照で catch します。基底例外を値で catch すると、派生オブジェクトがスライスされます。catch (...) は、不変条件を復元したり、エラーを変換したり、意図的に終了したりできる境界にのみ置きます。破損した状態で黙って処理を続けてはなりません。

巻き戻しと RAII #

コンストラクターが throw すると、完成したオブジェクトは生成されませんが、完全に構築された基底クラスとメンバーのサブオブジェクトについてはデストラクターが実行されます。別の例外がアクティブな間にデストラクターから例外を外へ出してはいけません。そうすると std::terminate が呼び出されるためです。したがって、デストラクター、スマートポインターのデリーター、ロールバックガードは throw しないようにすべきです。クリーンアップが失敗する可能性がある場合は、破棄前に失敗を報告する明示的な操作を公開します。

cpp
class Transaction {
public:
    explicit Transaction(Database& db) : db_{db} { db_.begin(); }

    void commit() {
        db_.commit();
        committed_ = true;
    }

    ~Transaction() noexcept {
        if (!committed_) {
            db_.rollback_noexcept();
        }
    }

private:
    Database& db_;
    bool committed_ = false;
};

デストラクターはロールバックを試みるだけで、commit() がエラーを報告できる操作として残ります。各 public 操作は、失敗後にどの状態が残るかを定義する必要があります。

例外安全性の保証 #

操作は次のいずれかの保証を宣言できます。

  1. No-throw 保証: 例外を伝播しない。
  2. 強い保証: 失敗した場合、観測可能な状態が変更されない。
  3. 基本保証: 不変条件が保持され、リソースがリークしないが、状態は変更される可能性がある。

型が RAII を使用しているというだけで、基本保証を超える保証を推測してはいけません。RAII はリークを防ぎますが、上書きされた値や外部効果を復元するものではありません。throw する可能性のある変更操作については、保証を文書化してください。

コミット、ロールバック、不変条件 #

強い保証は、準備してコミットする構造から導かれます。throw する可能性がある作業を置換状態で実行し、その後 throw しないコミットを適用します。コミットは swap、ポインター置換、データベーストランザクションのコミットであることが多いです。

cpp
void Account::replace_limits(Limits requested) {
    requested.validate();                  // May throw; `limits_` is unchanged.
    limits_.swap(requested);               // Commit must not throw.
}

置換状態のコストが大きい場合や、外部アクションを取り消せない場合は、基本保証を維持します。すべての失敗後に呼び出し元が依存できる不変条件、たとえば「オブジェクトは使用可能なままで残高は負にならない」を定義します。外部システムがトランザクションまたは補償アクションを提供しない限り、不可逆な副作用後のロールバックを主張しないでください。

noexcept は終了契約である #

noexcept は一部の用途で関数型を変更し、関数から例外が出ると std::terminate を呼び出すことを約束します。例外を抑制したり、定義上関数本体を高速化したりするものではありません。終了を意図した応答とし、呼び出すすべての操作がその契約に適合する場合に noexcept を付けます。

デストラクターは、別途宣言されない限り暗黙的に throw しません。明示的にデストラクターを noexcept(false) にするのは、巻き戻し中でない経路で呼び出し元が例外を catch できる場合に限り適切です。別の例外がすでに伝播中なら、引き続き安全ではありません。

noexcept は標準ライブラリの戦略にも影響します。たとえば std::vector が再割り当てするとき、移動コンストラクターが throw しなければ、強い保証を維持しながら要素を移動できます。移動が throw する可能性があり、コピーが利用可能なら、実装は代わりにコピーできます。移動が throw する可能性があり、コピーも利用できなければ、vector はその再割り当てで強い保証を提供できない場合があります。これはコンテナー固有の結果であり、不正直に操作を noexcept とマークする理由ではありません。

例外境界を設計する #

通常のコードは例外中立に保ちます。所有オブジェクトでリソースを取得し、ローカルに不変条件を維持し、回復ポリシーを知る層まで失敗を到達させます。対象のエラーモデルで必要な場合は、プロセス、リクエスト、スレッド、外部関数の境界で例外を変換します。例外を許可しないインターフェイスを C++ 例外が越えることを決して許可しないでください。

例外は検証の代替ではありません。変更前に事前条件を検証し、失敗が通常のローカル制御フローの一部である場合は C++23 の std::expected などの戻り値を使用し、呼び出し元に非局所的な伝播と信頼できるクリーンアップが必要な場合に例外を使用します。

コメント

GitHub で閲覧・コメントする(投稿にはログインが必要です)