コピーコンストラクタ、代入演算子、デストラクタ

C++のためのAPIデザイン』のp.210には、こんな記述があります。

 C++の初心者が陥りやすいのは、リソースを割り当てるクラスを作り、それに伴いデストラクタは用意したが、コピーコンストラクタと代入演算子を定義しないことだ。

というわけで、今日はコピーコンストラクタと代入演算子の定義がないと何が起こるのかについて実験してみます。

きっかけ

ゲームプログラマになる前に覚えておきたい技術』のp.520には即死サンプルという不穏当なコードが掲載されています。

#include <vector>
using namespace std;

class A {
public:
    A() { mX = new int(); }
    ~A() { delete mX; }
    int* mX;
};

vector< A > gA; // グローバルにvector

void add() { // 配列に足す関数
    A a;
    gA.push_back( a );
}

int main() {
    add();
    *( gA[ 0 ].mX ) = 6;
    return 0;
}

 これが破滅的なコードであることが理解できるだろうか? このコードはコンパイルして動くので、実際に動かしてみるといい。(中略)こんなに簡単なコードで、これほどの地獄を引き起こせるのが、C++という言語なのだ。

実際にコードを打ち込んで動かしてみたところ、見事にクラッシュしました。なにこれこわい。

ビッグスリー原則に基づいた解決策

先ほどのコードの最も重大な問題点は、メモリ管理まわりに欠陥がある事です。C++では、コピーコンストラクタや代入演算子を適切に定義しないとメモリが二重解放されてしまう危険性があり、『C++のためのAPIデザイン』のp.211には以下のように書かれています。

 (前略)値オブジェクトを作成するときは「ビッグスリー」原則に従うことが重要である。ビッグスリー原則とはマーシャル・クラインが1990年代初頭に提案した原則で、「デストラクタ、コピーコンストラクタ、代入演算子の3つのメンバ関数は常に共存する必要がある」というものだ。

これを踏まえ、即死サンプルのクラスAを書き直してみました。

class A {
public:
    // デフォルトコンストラクタ
    A() {
        mX = new int();
    }
    
    // コピーコンストラクタ
    explicit A(const A &a) {
        mX = new int(*a.mX);
    }

    // デストラクタ
    virtual ~A() {
        delete mX;
    }
    
    // 代入演算子のオーバーロード
    A &operator=(const A &a) {
        if(this != &a) {
            *mX = *a.mX;
        }
        return *this;
    }
    
    int *mX;
};

まだ、メンバ変数がpublicだったりするのが危ないところですが、これでひとまずバグは消えました。

さらにこの例の場合では、スマートポインタを使うとデストラクタを省略できます。

class A {
public:
    // デフォルトコンストラクタ
    A() {
        mX = unique_ptr<int>(new int());
    }
    
    // コピーコンストラクタ
    explicit A(const A &a) {
        mX = unique_ptr<int>(new int(*a.mX));
    }

    // デストラクタ
    virtual ~A() { }
    
    // 代入演算子のオーバーロード
    A &operator=(const A &a) {
        if(this != &a) {
            *mX = *a.mX;
        }
        return *this;
    }
    
    unique_ptr<int> mX;
};

平山尚氏の解決策

別解として、『ゲームプログラマになる前に覚えておきたい技術』中にあった解決策をご紹介しましょう。

 まず、最低でも「中でnewする型は(引用者註:STLコンテナに)ポインタを入れる」をルールとして採用しよう。未熟な人は「STLコンテナにはポインタを入れる」をルールにしてしまうのも良い。
 さらに、間違いが起こらないように、中でnewをするクラスはコピーを禁止することを義務づけよう。いっそ、作るクラス全てを全部コピー禁止にしてもいい。

いっけん乱暴にも見える解決策ですが、この問題の本質をよく捉えていると思います。

この実行時エラーは、JavaC#のように、参照経由でオブジェクトの操作を行う仕様のプログラミング言語では原理的に発生しえない現象だからです。

ちなみに、上記の方針を取り入れると、こんなソースコードになります。

#include <vector>
using namespace std;

class A {
public:
    A() { mX = new int(); }
    ~A() { delete mX; }
    int* mX;
    
private:
    A( const A &);
    void operator=( const A &);
};

vector< A* > gA;

void add() {
    A a;
    gA.push_back( &a );
}

int main() {
    add();
    *( gA[ 0 ]->mX ) = 6;
    return 0;
}

なお、コピーコンストラクタと代入演算子の封印は本質ではなく、“vectorがコピーコンストラクタを暗黙的に呼ぶ”という仕様を知らない人が過ちを犯さないよう、予防措置的にprivateにしているだけです。

なお、前回Pimplイディオムを実装した際にも、コピーコンストラクタと代入演算子privateにしていましたが、それもこの問題を回避するためです。

どちらの策を採るかは状況に応じて変わるでしょうが、きちんと危険性を知った上で判断するようにしたいですね。

Copyright (c) 2012 @tercel_s, @iTercel, @pi_cro_s.