【C++】例外安全性の3段階保証とは?|強い保証・基本保証・無例外保証を実例で解説

exception safety thumbnail C++

「例外安全性って言葉は聞くけど、try/catchできればそれで十分じゃないの?」「強い保証と基本保証って何が違うの?」「noexceptって結局何を約束しているの?」

こんな疑問はありませんか?

今回は、C++の例外安全性の3段階保証(強い保証・基本保証・無例外保証)について、実際にコンパイル・実行した結果を交えながら解説していきます。「例外をキャッチできるかどうか」ではなく「例外が起きた後、オブジェクトがどんな状態になるか」に焦点を当てます。

例外安全性の3段階保証とは?

例外安全性とは、関数の途中で例外が起きたときに、オブジェクトの状態がどこまで守られるかを表す考え方です。一般的に次の3段階に分けられます。

基本保証(basic guarantee)…例外が起きても壊れはしないが、一部だけ変更された状態になりうる
強い保証(strong guarantee)…例外が起きたら、呼び出す前の状態に完全に戻る
無例外保証(no-throw guarantee)…その処理は絶対に例外を投げない

「try/catchで例外を捕まえられるからOK」ではなく、捕まえた後のオブジェクトが信用できる状態かどうかまで考えるのがポイントです。

【実験】基本保証 vs 強い保証

アイテムを追加するInventoryクラスを、基本保証版と強い保証版の2通り実装して比べてみます。

// 基本保証(basic guarantee): 例外が起きても壊れはしないが、
// 「一部だけ変更された」中途半端な状態になりうる
class InventoryBasic {
    std::vector items;
    int addCount = 0;
public:
    void AddItem(const std::string& name) {
        addCount++; // 先にカウントだけ増やしてしまう
        if (name == "invalid") {
            throw std::runtime_error("無効なアイテム名");
        }
        items.push_back(name);
    }
    int GetAddCount() const { return addCount; }
    size_t GetItemCount() const { return items.size(); }
};

// 強い保証(strong guarantee): 失敗したら呼ぶ前の状態に完全に戻る
class InventoryStrong {
    std::vector items;
    int addCount = 0;
public:
    void AddItem(const std::string& name) {
        if (name == "invalid") {
            throw std::runtime_error("無効なアイテム名"); // 何も変更する前に投げる
        }
        items.push_back(name); // ここまで来たら失敗しない
        addCount++;
    }
    int GetAddCount() const { return addCount; }
    size_t GetItemCount() const { return items.size(); }
};

違いは「カウントを増やすタイミング」だけです。基本保証版は例外を投げるにカウントを増やし、強い保証版はitems.push_backが確実に成功したにカウントを増やしています。それぞれ「sword」を追加した後に「invalid」を追加(=失敗)させてみます。

[1] 基本保証(basic guarantee)
    例外キャッチ: 無効なアイテム名
    addCount=2 itemCount=1  <- カウントとアイテム数が食い違っている

[2] 強い保証(strong guarantee)
    例外キャッチ: 無効なアイテム名
    addCount=1 itemCount=1  <- 例外前と完全に一致している

基本保証版は、例外をちゃんとキャッチできているのにaddCount=2itemCount=1と数字が食い違ってしまっています。プログラムは壊れていませんが、「追加したアイテム数」を数える目的では、このカウントはもう信用できません。強い保証版は、両方とも1のまま一致していて、例外発生前の状態がそのまま保たれていることが分かります。

無例外保証を実際に確認する

最後に、無例外保証(no-throw guarantee)です。「絶対に例外を投げない」という約束は、noexcept演算子を使えばコンパイル時に本当かどうかを確かめられます。

std::vector a{1, 2, 3};
std::vector b{4, 5};
bool swapIsNoexcept = noexcept(a.swap(b));
printf("    noexcept(a.swap(b)) = %s\n", swapIsNoexcept ? "true" : "false");
a.swap(b);
[3] 無例外保証(no-throw guarantee)
    noexcept(a.swap(b)) = true
    swap後: a.size()=2 b.size()=3

noexcept(式)は、その式が本当に例外を投げないと分かっている場合にtrueを返します。std::vector::swapは中身のポインタを付け替えるだけの処理なので、メモリ確保も何も発生せず、本当に失敗しようがないためnoexceptが保証されています。

例外安全性のよくある失敗例と対処法

①状態を変更する順番を意識しない

上で実演した通りです。失敗する可能性がある処理を先に全部済ませてから、失敗しない処理でまとめて状態を確定させると、自然に強い保証に近づきます。

②デストラクタやswapから例外を投げてしまう

デストラクタやswap、ムーブコンストラクタは、多くのケースで無例外保証が期待されます。ここから例外を投げると、スタック巻き戻し中の二重例外std::terminateが呼ばれ、プログラムが強制終了することがあります。

③全部の関数に強い保証を求めてしまう

強い保証は安全ですが、コピーが増えてコストがかかることもあります。すべての関数に強い保証を課す必要はなく、本当に途中状態が問題になる箇所だけ強い保証を選ぶのが現実的です。

注意点

  • 例外安全性は「捕まえられるか」ではなく「捕まえた後の状態」の話
  • 強い保証にするには失敗しうる処理を先に、確定処理を最後に
  • デストラクタ・swap・ムーブは無例外保証が期待される
  • noexcept(式)コンパイル時に無例外保証を確認できる

まとめ

  • 例外安全性には基本保証・強い保証・無例外保証の3段階がある
  • 基本保証はキャッチできても状態が食い違うことがある(実際に確認した)
  • 強い保証は失敗しうる処理を先に済ませることで実現できる
  • 無例外保証はnoexceptコンパイル時に検証できる

「例外をキャッチできていれば安心」と思いがちですが、実際に数値を出してみると、キャッチできてもデータの整合性が崩れているケースがあることがはっきり分かりました。設計段階でどの保証を目指すか決めておくことの大切さを実感できました。

ここまで読んでくださりありがとうございました。

関連記事