英語版
このページの英語版を見る

例外安全

例外セーフプログラミングとは、 例外をスローする可能性のあるコードが例外をスローした場合でも、 プログラムの状態が破損せず、リソースが漏洩しないように プログラミングすることである。従来の方法でこれを正しく行うと、複雑で 魅力のない壊れやすいコードになることが多い。その結果、例外セーフは しばしばバグが発生したり、単に都合の良さのために無視されたりする。

ここでは、m というミュートックスを取得して保持 し、いくつかの文を実行した後、解放する必要がある。
void locked_foo()
{
    Mutex m = new Mutex;
    lock(m);    // ミューテックスをロックする
    foo();      // 処理を行う
    unlock(m);  // ミューテックスのロックを解除する
}

foo() が例外をスローした場合、locked_foo() は例外の巻き戻しにより終了し、unlock(m) は決して呼び出されず、mutexはリリースされない。これはこのコードの致命的な 問題である。

RAII(リソース取得は初期化)という慣用句と try-finally文は、 例外安全プログラミングを記述する従来の手法の根幹をなす。

RAIIはスコープの破棄であり、この例は、Lock 構造体にスコープの終了時に呼び出されるデストラクタを追加することで修正できる。

struct Lock
{
    Mutex m;

    this(Mutex m)
    {
        this.m = m;
        lock(m);
    }

    ~this()
    {
        unlock(m);
    }
}

void locked_foo()
{
    Mutex m = new Mutex;
    auto l = Lock(m);
    foo();  // 処理を行う
}
locked_foo() が正常に終了した場合、またはfoo() からスローされた例外により終了した場合、l は デストラクタが呼び出され、ミュートックスがアンロックされる。 同じ問題に対する try-finally ソリューションは以下のようになる。
void locked_foo()
{
    Mutex m = new Mutex;
    lock(m);        // ミューテックスをロックする
    try
    {
        foo();      // 処理を行う
    }
    finally
    {
        unlock(m);  // ミューテックスのロックを解除する
    }
}

どちらの方法も機能するが、どちらにも欠点がある。 RAII による解決策では、 余分なダミー構造体の作成が必要になることが多く、その分、コードの行数が多くなり、 制御フローのロジックがわかりにくくなる。 これは、クリーンアップが必要で、プログラム中に複数回出現するリソースを管理する場合には有効であるが、 一度だけ実行すればよい場合には煩雑になる。 try-finally 解決策は、セットアップとアンワインディングコードを分離するもので、 視覚的に大きな分離となる場合が多い。密接に関連するコードは、まとめておくべきである。

スコープガード文は より良いアプローチである。

void locked_foo()
{
    Mutex m = new Mutex;

    lock(m);                // ミューテックスをロックする
    scope(exit) unlock(m);  // スコープを出るときにロックを解除する

    foo();                  // 処理を行う
}
この scope(exit)文は、通常の実行時には閉じ波括弧で実行され、 例外がスローされたためにスコープが終了した場合には 実行される。 巻き戻しコードを、見た目にもふさわしい場所、つまり 巻き戻しが必要な状態の生成の隣に配置する。RAIIやtry-finallyのソリューションよりもはるかに少ないコードで 済み、ダミー構造体の作成も必要ない。

次の例は、トランザクション処理として知られる問題のクラスである。
Transaction transaction()
{
    Foo f = dofoo();
    Bar b = dobar();

    return Transaction(f, b);
}
xml-ph-0000@deepl.internalとxml-ph-0001@deepl.internalの両方が成功しなければ、トランザクションは失敗したことになる。 トランザクションが失敗した場合は、データを復元しなければならない。

dofoo()dobar() の両方が成功しなければ、トランザクションは失敗したことになる。 トランザクションが失敗した場合は、データをdofoo()dobar() も発生していない状態に復元しなければならない。これをサポートするために、dofoo() にはアンワインド操作があり、dofoo_undo(Foo f)Foo の作成をロールバックする。

RAIIアプローチでは:

struct FooX
{
    Foo f;
    bool commit;

    @disable this();

    static FooX create()
    {
        auto fx = FooX.init;
        fx.f = dofoo();
        return fx;
    }

    ~this()
    {
        if (!commit)
            dofoo_undo(f);
    }
}

Transaction transaction()
{
    auto fx = FooX.create();
    Bar b = dobar();
    fx.commit = true;
    return Transaction(fx.f, b);
}
try-catchアプローチの場合:
Transaction transaction()
{
    Foo f = dofoo();
    try
    {
        Bar b = dobar();
        return Transaction(f, b);
    }
    catch (Exception o)
    {
        dofoo_undo(f);
        throw o;
    }
}
これらは機能するが、同じ問題を抱えている。 RAIIアプローチではダミー構造体の作成が必要であり、 XMLからロジックの一部を移動させるという無神経な

これらは機能するが、同じ問題を抱えている。 RAIIアプローチではダミー構造体の作成が必要であり、transaction() 関数からロジックの一部を移動させるのは難しい。 try-finallyアプローチは、この単純な例でも冗長的である。 成功しなければならないトランザクションのコンポーネントが2つ以上ある場合は、 それを書いてみよう。スケーラビリティが低い。 "structure""構造体"

解決策は次のようになる。 scope(failure)解決策は次のようになる。

例外によってスコープが終了した場合のみ実行される。 巻き戻しコードは最小限に抑えられ、見た目も美しく保たれる。 より複雑なトランザクションにも自然に拡張できる。
Transaction transaction()
{
    Foo f = dofoo();
    scope(failure) dofoo_undo(f);

    Bar b = dobar();
    return Transaction(f, b);
}
dofoo_undo(f) 例外によってスコープが終了した場合のみ実行される。 巻き戻しコードは最小限に抑えられ、美観を損なわないように配置されている。 より複雑なトランザクションにも自然な方法で拡張できる:
Transaction transaction()
{
    Foo f = dofoo();
    scope(failure) dofoo_undo(f);

    Bar b = dobar();
    scope(failure) dobar_unwind(b);

    Def d = dodef();
    return Transaction(f, b, d);
}

次の例では、あるオブジェクトの状態を一時的に変更する。 クラスデータメンバverbose があり、 クラスのアクティビティを記録するメッセージの送信を制御しているとする。 メソッドの1つの中で、verbose をオフにする必要がある。 そうしないと、メッセージが大量に出力されてしまうループが発生するからだ。
class Foo
{
    bool verbose;   // trueはメッセージを表示、falseは沈黙を意味する。
    ...
    bar()
    {
        auto verbose_save = verbose;
        verbose = false;
        ... lots of code ...
        verbose = verbose_save;
    }
}
Foo.bar() が例外で終了すると問題が発生する。冗長 フラグの状態が復元されないのだ。 これは簡単に修正できる。 scope(exit)
class Foo
{
    bool verbose;   // trueはメッセージを表示、falseは沈黙を意味する
    ...
    bar()
    {
        auto verbose_save = verbose;
        verbose = false;
        scope(exit) verbose = verbose_save;

        ...lots of code...
    }
}
また、xml-ph-0000@deepl.internal が長時間実行され続ける場合の問題もきれいに解決する。 将来的にメンテナンス担当のプログラマーが return文を挿入した場合でも、冗長モードが有効になっていることに気づかずに

また、...lots of code... が長時間実行され続け、将来、保守担当のプログラマーが その中に return 文を挿入した場合の問題もきれいに解決する。 ただし、そのプログラマーは、verbose を終了時にリセットしなければならないことに気づいていない。 リセットコードは、概念的には、実行される場所ではなく、本来あるべき場所に置かれるべきである (類似のケースとしては、ForStatement の継続式がある)。 これは、return、break、goto、continue、または例外によってスコープが終了した場合に機能する。

RAIIの解決策は、冗長な状態をリソースとして捕捉しようとする ことだが、あまり意味のない抽象化である。 try-finallyの解決策では、概念的にリンクされたセットとリセットコードの間に任意の大きさの分離が必要となる。 さらに、 無関係なスコープを追加する必要がある。

これは、複数ステップのトランザクションの別の例である。 今回は、電子メールプログラムの例だ。 電子メールの送信には、次の2つの操作が含まれる。
  1. SMTP送信操作を実行する。
  2. メールを「送信済み」フォルダにコピーする。POPではローカルディスクに、 IMAPではリモートにも存在する。

実際に送信されていないメッセージが「送信済み」に表示されるべきではなく、 送信済みのメッセージは実際に「送信済み」に表示されなければならない。

操作(1)は、周知の分散コンピューティングの問題であるため、元に戻すことはできない。 操作(2)はある程度の信頼性をもって元に戻すことができる。 そこで、作業を3つのステップに分ける。

  1. 件名を「[送信中] <件名>」に変更して、メッセージを「送信済み」にコピーする。この操作により、クライアントのIMAPアカウント(またはローカルディスク)に十分な容量があること、 適切な権限があること、接続が 確立されていることなどが確認される。
  2. SMTP経由でメッセージを送信する。
  3. 送信に失敗した場合は、「送信済みアイテム」からメッセージを削除する。メッセージの送信に成功した場合は、 「[送信中] <件名>」という件名を「<件名>」に変更する。 これらの操作は、いずれも高い確率で成功する。 フォルダがローカルにある場合は、成功する可能性が非常に高い。フォルダが リモートの場合でも、(1)のステップよりもはるかに高い確率で成功する。 なぜなら、任意の大量のデータ転送を伴わないからだ。
これは複雑な問題に対する説得力のある解決策である。 RAIIで書き直すには、MessageTitleSaverとMessageRemoverという2つの余計なクラスが必要になる。 try-finallyで書き直すには、ネストしたtry-finishが必要になる。
class Mailer
{
    void Send(Message msg)
    {
        {
            const origTitle = msg.Title();
            scope(exit) msg.SetTitle(origTitle);
            msg.SetTitle("[Sending] " ~ origTitle);
            Copy(msg, "Sent");
        }
        scope(success) SetTitle(msg.ID(), "Sent", msg.Title);
        scope(failure) Remove(msg.ID(), "Sent");
        SmtpSend(msg);  // 最も信頼性の低い部分を最後に行う
    }
}
これは複雑な問題に対する説得力のある解決策である。 RAIIで書き直すには、MessageTitleSaverとMessageRemoverという2つの余計なクラスが必要になる。 try-finallyで書き直すには、ネストしたtry-finally文が必要になるか、 状態の変化を追跡するための余分な変数を使用する必要がある。

ユーザーに長時間かかる処理についてフィードバックを与えることを検討する (マウスが砂時計に変わり、ウィンドウタイトルが 赤色/斜体表示になるなど)。 scope(exit)そのようにすることは、 キューに使用されるUI状態要素を人為的なリソースとして作成する必要がなく、 簡単に実行できる
void LongFunction()
{
    State save = UIElement.GetState();
    scope(exit) UIElement.SetState(save);
    ...lots of code...
}
さらに、 scope(success)また、 scope(failure) 操作が成功したか、エラーが発生したかを示すために使用できる。
void LongFunction()
{
    State save = UIElement.GetState();
    scope(success) UIElement.SetState(save);
    scope(failure) UIElement.SetState(Failed(save));
    ...lots of code...
}

RAII、try-catch-finally、スコープの使用例

RAIIはリソースの管理に使用するもので、ステートやトランザクションの管理とは異なる。 スコープは例外を捕捉しないため、try-catchは依然として必要である。冗長になるのはtry-finallyの方である。

謝辞

アンドレイ・アレクサンドレスクは、これらの構文の有用性について Usenet で議論し、 また try/catch/finally の意味論を comp.lang.c++.moderated への一連の投稿で定義した。

アンドレイ・アレクサンドレスクは、これらの構文の有用性について Usenet で議論し、 また comp.lang.c++.moderated メーリングリストに投稿した一連の記事で、try/catch/finally の意味論を定義した。 タイトルは「より安全でより良い C++? 」で、2005年12月6日に投稿が開始された。 D は、このアイデアを、 この機能に関する作者の実験と、D プログラマー・コミュニティ、 特に Dawid Ciezarkiewicz と Chris Miller による有用な digitalmars/D/34277.html">suggestions Dプログラマーコミュニティ、 特にDawid CiezarkiewiczとChris Millerの

私はスコット・マイヤーズに、 例外安全プログラミングについて教えてもらったことに感謝している。

参考文献:

  1. 「ジェネリックプログラミング:例外安全コードの書き方を永遠に変える 」アンドレイ・アレクサンドレスク、ペトル・マルギニアン著
  2. 「項目29:例外安全コードの作成に努める」 『Effective C++ Third Edition』127ページ、スコット・マイヤーズ著
item