【C++学習|豆知識】C++の落とし穴:コンストラクタ内での仮想関数呼び出しが危険な理由

導入

C++でクラス設計を行う際、「コンストラクタ内で初期化用メソッドを呼び出し、それを派生クラスでオーバーライドする」という実装を考えたことはないでしょうか。しかし、これはC++において非常に危険な設計です。なぜなら、オブジェクトの構築プロセスにおいて、仮想関数ポインタ(Vptr)は段階的に書き換えられるからです。この挙動を理解していないと、意図しない関数が実行されたり、プログラムがクラッシュしたりする原因となります。

基礎知識

C++のオブジェクトには、多態性(ポリモーフィズム)を実現するために、Vptr(仮想関数ポインタ)という隠れたポインタが埋め込まれています。これは、そのオブジェクトがどの仮想関数テーブル(Vtable)を参照すべきかを示すものです。

オブジェクトが構築されるとき、コンパイラはコンストラクタの先頭に、そのクラスのVtableを指すようにVptrを初期化するコードを自動的に挿入します。継承関係がある場合、構築は「基底クラス」から「派生クラス」の順で行われるため、Vptrもその順序で「基底クラスのVtable」から「派生クラスのVtable」へと次々に上書きされていきます。

実装/解決策

コンストラクタ実行中のオブジェクトは、まだ「派生クラス」としては完全に構築されていません。そのため、基底クラスのコンストラクタが実行されている間、Vptrはまだ「基底クラス」を指しています。この状態で仮想関数を呼び出すと、派生クラスのメソッドではなく、基底クラスのメソッド(あるいは何も実装されていない純粋仮想関数)が呼ばれてしまいます。

これを回避する最も安全な方法は、「コンストラクタ内で仮想関数を呼ばない」という原則を守ることです。複雑な初期化が必要な場合は、コンストラクタとは別に `initialize()` メソッドを用意し、オブジェクト作成後に外部から明示的に呼び出す設計を検討してください。

サンプルプログラム

以下のコードを実行すると、基底クラスのコンストラクタから呼び出した `init()` が、派生クラスのものではなく、基底クラスのものを指していることが確認できます。

include <iostream>

struct Base {
    Base() {
        std::cout << "Baseのコンストラクタ実行中" << std::endl;
        // ここで仮想関数を呼ぶと、Base::initが呼ばれる
        init(); 
    }
    virtual void init() {
        std::cout << "Baseのinitが呼ばれました" << std::endl;
    }
};

struct Derived : public Base {
    Derived() {
        std::cout << "Derivedのコンストラクタ実行中" << std::endl;
    }
    void init() override {
        std::cout << "Derivedのinitが呼ばれました" << std::endl;
    }
};

int main() {
    Derived d; // コンストラクタ呼び出し
    return 0;
}

応用・注意点

もし、コンストラクタから純粋仮想関数を呼び出してしまった場合、実行時に「Pure virtual method called」というエラーが発生してプログラムが強制終了します。これは非常にデバッグが困難なケースになりがちです。

また、デストラクタも同様です。デストラクタでは派生クラスから順に破棄されるため、基底クラスのデストラクタが実行される時点では、すでに派生クラスのデータは破壊されています。構築中・破壊中のオブジェクトに対して多態性を期待したコードを書くことは避け、「初期化と終了処理は、オブジェクトが完全に構築された後、あるいは破棄される前に行う」というルールを徹底しましょう。

コメント

タイトルとURLをコピーしました