思うだけで学ばない日記 2.0

思うだけで学ばない日記から移転しました☆!よろしくお願いします。

C++コンストラクタの正しい書き方

【追記:中程に訂正箇所があります】
コンストラクタと例外に関して世間一般に流布する誤解(というものがあるとして)とは無関係に、次の2点は全くの真実だ。

  1. C++のメリットを享受しようと思えば、コンストラクタ内で例外が発生する危険性をとうてい0にはできない
  2. 例外は関数から抜ける手段の1つにすぎない(returnと比べて重く、かつcatchされるまで何段もすっぽ抜けるが、catchされるまでの間、構築済みの自動オブジェクトのデストラクタは全てきちんと呼ばれる。)

まず1つ目について。
次のコードで定義されるクラスFooは、スタック上に自動オブジェクトとして宣言したとき破綻する。
一般に10億バイトものオブジェクトをスタック上には構築できない。

class Foo
{
private:
    unsigned char tooBigBuffer[1000000000];
public:
    Foo() { ZeroMemory(tooBigBuffer, sizeof(tooBigBuffer)); }
};

そんないやらしい制限は無くしてFooをよりポータブルに使いたければ、少なくともtooBigBuffer[]をヒープ上にとる設計にせねばならない。(※下記コードは危険な点があるが後で直す。)

class Foo
{
private:
    unsigned char* tooBigBuffer;
public:
    Foo() {
        tooBigBuffer = new unsigned char[1000000000];
        ZeroMemory(tooBigBuffer, 1000000000);
    }
    virtual ~Foo() { delete[] tooBigBuffer; }
};

だがFooインスタンス構築時点でヒープメモリが足りないと、newがbad_alloc()例外を発生し、ZeroMemory()は実行されずにコンストラクタを抜けてくる。これは、もしそうなったら避けようがない。仮にnewをtry { } ブロックで囲ったとしても、メモリ確保に失敗したのであるから結局直後のZeroMemory()を実行するわけにはいかず、それは対策にならない。

次に、2つ目について。
一旦コンストラクタは忘れて、普通の関数を考える。
関数の途中からreturnしたくなるシチュエーションに遭遇することは誰しもあるはず。
例えば、qsort()に渡す比較関数等は、いかに「関数の途中からは絶対に抜けない」というコーディング規則を信条とするプログラマでも途中から抜けるコードを書くだろう。次のように。(さもないとあまりにすっきりしないコードになる。)

int cmp(int a, int b)
{
    if (a < b) {
        return -1;
    } else if (a > b) {
        return 1;
    } else {
        return 0;
    }
}

なお、次のように書くと一見すっきりするのだが、これは残念ながら比較条件によってはオーバーフローしてしまい正しい比較にならないバグがある。(cmp(-INT_MAX, INT_MAX)等)

int cmp(int a, int b) { return a - b; }

例外についてはかえって話は単純だ。発生した例外は避けられない。だから、例外への対処において、「関数の途中からは絶対に抜けない」系のドグマは出る幕がない。例外発生時にその場で抜ける前提で、なお安全に動くように書く必要がある。だがそれで十分でもある

前出のヒープ使用版のFooクラスを安全に書き直してみよう。
【追記:下記サンプルはコメントで指摘された事実誤認を含んでおり間違いなので撤回します。】

class Foo
{
private:
    unsigned char* tooBigBuffer;
public:
    Foo() : tooBigBuffer(0)     // newの前にポインタは0にする!
    {
        tooBigBuffer = new unsigned char[1000000000];
        ZeroMemory(tooBigBuffer, 1000000000);
    }
    virtual ~Foo() { delete[] tooBigBuffer; }  // 0を受け取ったdelete[]は何もしないという仕様
};

例外が発生し得ない時点で、早々にヒープを指す予定のポインタを0に初期化しておくのがポイント。

このFooを使って、次のコードを書いたとする。*1

void bar()
{
    Foo a;
    Foo b;
    Foo c;

    cout << "Hello World!" << endl;
}

bの構築時のnewで例外が発生した場合、即座にbのコンストラクタを抜けた後、bのデストラクタ、aのデストラクタの順で呼ばれ、cの構築とcout << ...の実行は行われない。 【追記:bのデストラクタが呼ばれる、というのが間違い。】

で、bのデストラクタ呼び出し時点でtooBigBuffer == 0であるので、Foo::~Foo()内のdelete tooBigBuffer;は何の仕事もしない。

delete 0;やdelete 0;が何ら作用を持たないというC++言語仕様は多分こういう目的のためにある。

補足

真に例外が発生してはならないのはコンストラクタでなくてデストラクタ。デストラクタが例外を投げたら救いようがないわけだが…
だが幸いなことに、デストラクタの仕事に一般に資源の確保は含まれないから、デストラクタの中において、例外の発生が避けられないシチュエーションはコンストラクタより少ない。最悪、例外が発生する危険が無視できないならtry{}ブロックで囲ってやり過ごす、が対策になり得る。(必ずしも稼働の継続が可能になるわけではないが、いきなり落ちたりせずに、人間の操作者にまで異常を通知した上で安全にプログラムを停止させる等の対処が可能になる。デストラクタが呼ばれたオブジェクトは(事故でもない限り)他からアクセスされないから、デストラクタの実行が不完全でも即落ちることにはならない。)

補足2

Foo a;は、Fooクラスのインスタンスaの定義であると同時にインスタンスaがFooクラスであるという宣言でもある
ということを今更ハッケソすた、

追記 18:33

上記本文にはコメントで指摘された間違いがあります。(bar()が出てくるあたり。)
オブジェクトbのコンストラクタ内で例外が発生した場合、bのデストラクタは呼ばれません。
この点謹んで訂正させていただきます(汗

確認用サンプル:

#include <iostream>

class Baz
{
public:
    /// 新しいインスタンスを初期化します。
    Baz() {
        //throw "Baz Failed.";
        std::cout << "Baz Constructed." << std::endl;
    }

    /// インスタンスを破棄します。
    ~Baz() {
        std::cout << "Baz Destructed." << std::endl;
    }

};

class Foo
{
private:
    Baz m;

public:
    /// 新しいインスタンスを初期化します。
    Foo() {
        //throw "Foo Failed.";                   // Foo例外コメントアウト
        std::cout << "Foo Constructed." << std::endl;
    }

    /// インスタンスを破棄します。
    ~Foo() {
        std::cout << "Foo Destructed." << std::endl;
    }

};

void Test()
{
    try {
        Foo b;
    } catch (const char* msg) {
        std::cout << msg << std::endl;
    }
}

実行結果
(1) Foo例外コメントアウト
→ Foo b, Bar b.mのデストラクタが全て呼ばれる。

Baz Constructed.
Foo Constructed.
Foo Destructed.
Baz Destructed.
続行するには何かキーを押してください . . .

(2) Foo例外コメントアウトを外したとき
→ bのメンバb.mデストラクタが呼ばれた後bコンストラクタ中断。その後、b自体のデストラクタは呼ばれない。

Baz Constructed.
Baz Destructed.
Foo Failed.
続行するには何かキーを押してください . . .

なお『Windows プロフェッショナルゲームプログラミング』のp.39〜p.41に書いてある方法は、メンバのデストラクタが呼ばれることを利用して、コンストラクタが中断しても、すでに確保済みのヒープメモリのみ勝手に解放させたり(auto_ptr使用)、すでに開いたHANDLEのみを自動で閉じさせる(HANDLEのWrapperクラスを作成)というものです。

*1:ここでは最適化でa,b,cの構築自体が除去されるケースは考えないものとする。最適化無しでビルド前提。