D 1.0   D 2.0
About Japanese Translation

Last update Sun Dec 10 22:56:57 2006

例外安全なプログラミング

例外安全なプログラミングとは、 例外を投げる可能性があるコードが実際に例外を投げた場合に、 プログラムの状態が壊れずリソースもリークしないように作るプログラミングのことを言います。 これを正しく実現するには、既存の方法では、複雑で読みにくく脆いコード を書かねばなりませんでした。結果として、例外安全性に関して バグが残っていることが非常に多かったり、そもそも手間を省くために 例外安全が完全に無視されたりしてきました。

例として、数行の文を実行するあいだMutexをロックして、 終わったら解放するというケースを考えてみましょう:
void abc()
{
    Mutex m = new Mutex;
    lock(m);	// mutexをロック
    foo();	// 処理を行う
    unlock(m);	// mutexをアンロック
}

foo() が例外を投げると、abc() は例外による巻き戻しで終了します。 この場合 unlock(m) は呼び出されず、Mutexは解放されません。 これはこのコードの致命的な欠陥です。

RAII (Resource Acquisition Is Initialization) イディオムと、 try-finally文。この二つが、例外安全なプログラミングを実現するための これまでの基本的な方法でした。

RAII とはスコープに関連づけられた破棄処理のことで、先ほどの例は、 スコープの終了時に呼び出されるデストラクタをもったLockクラスを使うことで修正できます:

class Lock
{
    Mutex m;

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

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

void abc()
{
    Mutex m = new Mutex;
    scope L = new Lock(m);
    foo();	// 処理を行う
}
abc() が正常終了しても foo() からの例外で終了しても、L のデストラクタは呼び出され、 Mutex は解放されます。 また、同じ問題を try-finally で解決すると次のようになります:
void abc()
{
    Mutex m = new Mutex;
    lock(m);	// mutexをロック
    try
    {
	foo();	// 処理を行う
    }
    finally
    {
	unlock(m);	// mutexをアンロック
    }
}

どちらの方法も問題を解決はします。しかし、どちらにも欠点があります。 RAII による方法では 余分なダミークラスを作る必要が生じ、コード量も増えますし、 実行フローの流れも見通しが悪くなります。 これは、必ず解放されなければならないリソースを、プログラム中で何度も使う場合には 問題になりません。しかし、 一度だけ必要な解法処理を記述するには面倒です。 try-finally による解決は、 リソースの初期化コードと巻き戻し処理のコードが分離して記述され、 実際ソースコード上で視覚的に大きく離れてしまします。しかし、密接に関連した処理は一カ所にまとまっているべきです。

そこで scope exit 文が、 もっと簡単なアプローチを可能にします:

void abc()
{
    Mutex m = new Mutex;

    lock(m);	// mutexをロック
    scope(exit) unlock(m);	// スコープ終了時にアンロック

    foo();	// 処理を行う
}
scope(exit) 文は、 正常な実行で中括弧を抜ける時か、 あるいは例外が投げられてスコープを抜けるときに 実行されます。 この方法では、巻き戻しコードが戻す状態の生成部分の すぐ隣に配置されるという綺麗なソースになります。また、 RAII と比べても try-finally と比べてもコード量も少なく、 ダミークラスを定義する必要もありません。

次の例は、トランザクション処理と呼ばれる種類の問題です:
Transaction abc()
{
    Foo f;
    Bar b;

    f = dofoo();
    b = dobar();

    return Transaction(f, b);
}

dofoo() と dobar() の両方が成功するか、そうでなければトランザクション失敗とします。 トランザクションが失敗する場合は、 dofoo() も dobar() も実行される前の状態にデータが戻っていないといけません。 これを実現するために、dofoo() の操作を巻き戻す dofoo_undo(Foo f) があって、 Fooの生成を取り消せるようになっています。

さて、RAIIの方法では:

class FooX
{
    Foo f;
    bool commit;

    this()
    {
	f = dofoo();
    }

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

Transaction abc()
{
    scope f = new FooX();
    Bar b = dobar();
    f.commit = true;
    return Transaction(f.f, b);
}
try-finally の方法では:
Transaction abc()
{
    Foo f;
    Bar b;

    f = dofoo();
    try
    {
	b = dobar();
	return Transaction(f, b);
    }
    catch (Object o)
    {
	dofoo_undo(f);
	throw o;
    }
}

どちらも動作はしますが、やはり同じ問題を抱えています。 RAIIではダミークラスを作る必要があり、abc() 関数のロジックを一部外に 取り出したことで読みにくくなっています。 try-finally の方法はこのシンプルな例ではまだなんとかなっているようにも見えますが、 3個以上の処理を含むトランザクションを書こうとすると、 拡張性に乏しいことがわかります。

scope(failure) 文による解決法では次のようになります:

Transaction abc()
{
    Foo f;
    Bar b;

    f = dofoo();
    scope(failure) dofoo_undo(f);

    b = dobar();

    return Transaction(f, b);
}
dofoo_undo(f) はスコープが例外によって終了するときにのみ実行されます。 巻き戻しコードは最小限で、あるべき場所に綺麗に配置されています。 もっと複雑なトランザクションの場合でも、拡張のしかたは明らかです:
Transaction abc()
{
    Foo f;
    Bar b;
    Def d;

    f = dofoo();
    scope(failure) dofoo_undo(f);

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

    d = dodef();

    return Transaction(f, b, d);
}

次は、あるオブジェクトの例を一時的に変更するという例です。 クラスのデータメンバ verbose があって、 クラスの動作のログ取り動作を制御しているものとしましょう。 メソッドの一つが、物凄く大量のメッセージをはき出すループを含んでいて大変なことに なるので verbose をオフにする必要があったとします:
class Foo
{
    bool verbose;	// trueならメッセージを表示。falseなら表示しない
    ...
    bar()
    {
	auto verbose_save = verbose;
	verbose = false;
	... ながーいコード ...
	verbose = verbose_save;
    }
}
Foo.bar() が例外で終了すると問題が起きます。 verbose の状態が復元されません。 この問題は、scope(exit) を使うと簡単に解決します:
class Foo
{
    bool verbose;	// trueならメッセージを表示。falseなら表示しない
    ...
    bar()
    {
	auto verbose_save = verbose;
	verbose = false;
	scope(exit) verbose = verbose_save;

	... ながーいコード ...
    }
}

これは、将来的に ...ながーいコード... の中に、保守プログラマが verboseを戻す必要に気づかずreturn文を挿入してしまった、 という場合でも問題なく動くコードになっています。 復元コードが、 実行される箇所ではなくそれが概念的に属する場所に配置されているからです。 (ForStatement の条件更新式の利点に似ています。 上のコードは、スコープから return, break, goto, continue, 例外の どれで抜ける場合も正しく動作します。

RAII で解決しようとすると、verbose の状態をリソースとして捕捉することになり、 意味のある抽象化になりません。 try-finally による方法は、 概念的に関連している値のセットと復元コードが、 間に関係のないコードが挟まることでいくらでも離されてしまう危険性を含んでいます。

複数ステップのトランザクションの別の例を挙げましょう。 今度は、emailプログラムを例にとります。 emailの送信は、二つの操作からなっています:
  1. SMTP の送信操作の実行
  2. 送信済みメールの "Sent" フォルダへのコピー。POPならばローカルディスクへ、 IMAPならリモートに保存することになります

実際の送信が完了していないメールが "Sent" の中にあってはいけませんし、 送信済みのメールは確実に "Sent" に収まっている必要があります。

操作1は、ご存じの通りコンピュータ間の分散処理であるため、取り消すことはできません。 操作2は、ある程度の確実性をもって取り消すことができます。 そこで、作業を3つのステップに分解して考えます:

  1. メールを、タイトルを "[Sending] <Subject>". に変更しつつ "Sent" に保存。 この操作は、クライアントのIMAPアカウント(あるいはローカルディスク) に空き容量があり、権限が適切に設定されていて、ネットワーク接続が正しく保たれている ことなどなど、が必要になります。
  2. SMTP経由でメッセージを送信
  3. 送信が失敗した場合、メッセージを "Sent" から削除します。 送信が成功した場合は、 タイトルを "[Sending] <Subject>" から "<Subject>" に変更します。 どちらの操作も、ほぼ確実に成功すると考えられます。フォルダがローカルの場合は 特に確実です。フォルダがリモートの場合も、 操作(1)と比べると、 巨大なデータ転送を伴わない分、成功確率はずっと高いと言えるでしょう。
class Mailer
{
    void Send(Message msg)
    {
	{
	    char[] 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 が必要になります。 try-finally で書き直そうと思うと、try-finally文のネストや、 状態の進展を記録する余分な変数が必要になります。

時間のかかる処理の実行中であることをユーザーにフィードバックする (マウスカーソルを砂時計に変える、Windowタイトルを 赤くしたり斜体にしたりする、...) という例を考えます。 scope(exit) を使えば、 使用されるUI状態を管理する人工的なリソースオブジェクトを必要とせず、 簡単に実現できます:
void LongFunction()
{
    State save = UIElement.GetState();
    scope(exit) UIElement.SetState(save);
    ...ながーいコード...
}
さらに、scope(success) と scope(failure) を使えば操作が成功したのか失敗したのかのフィードバックを返すのも 簡単です:
void LongFunction()
{
    State save = UIElement.GetState();
    scope(success) UIElement.SetState(save);
    scope(failure) UIElement.SetState(Failed(save));
    ...ながーいコード...
}

RAII, try-catch-finally, scope をそれぞれ使うべき場合

RAII は、状態やトランザクション管理ではなく、リソース管理に威力を発揮する機能です。 scope文だけでは例外のcatchはできませんから、try-catch も必要です。 try-finally は、scope文によって冗長な構文となったと言えます。

謝辞

Andrei Alexandrescu はここで紹介した要素の有用性について Usenet で議論し、 またDec 6, 2005から始まる comp.lang.c++.moderated への一連の投稿 "A safer/better C++?" にて、 その意味論を try/catch/finally を用いて定義しました。 D ではこのアイデアを、 考案者の実験に沿って構文を多少変更し、 また D プログラマコミュニティから…特にDawid Ciezarkiewicz と Chris Miller からの重要な 提案 を元に実装しました。

また、Scott Meyers には例外安全なプログラミングについてご教授いただきました。感謝します。

参考文献:

  1. Generic<Programming>: Change the Way You Write Exception-Safe Code Forever by Andrei Alexandrescu and Petru Marginean
  2. "Item 29: Strive for exception-safe code" in Effective C++ Third Edition, pg. 127 by Scott Meyers (※訳注: 日本語訳 あり)