閉じる

【C++】コンストラクタで仮想関数を呼んではいけない理由と解決策を解説

C++を学び始めたばかりの方や、他のオブジェクト指向言語(JavaやC#など)から移行してきた方が最初につまずきやすいポイントの一つに、「コンストラクタ内での仮想関数の呼び出し」があります。

期待通りに派生クラスの関数が呼ばれず、意図しない挙動に悩まされた経験はないでしょうか。

C++の設計思想において、オブジェクトの生成過程は非常に厳格に管理されており、この挙動を正しく理解することはバグの混入を防ぐために不可欠です。

本記事では、なぜコンストラクタで仮想関数を呼んではいけないのか、その内部メカニズムから具体的な解決策まで、図解を交えて詳しく解説します。

コンストラクタで仮想関数を呼んだ時の挙動

C++において、基本クラス(親クラス)のコンストラクタ内で仮想関数を呼び出すと、派生クラス(子クラス)でオーバーライドした関数ではなく、基本クラス自身の関数が呼び出されます。

まずは、この現象を確認するためのサンプルコードを見てみましょう。

期待通りに動かないサンプルコード

以下のプログラムでは、Baseクラスのコンストラクタ内で仮想関数log()を呼び出しています。

C++
#include <iostream>
#include <string>

// 基本クラスの定義
class Base {
public:
    Base() {
        std::cout << "Base constructor start." << std::endl;
        // コンストラクタ内で仮想関数を呼び出す
        log(); 
    }

    virtual ~Base() {}

    // 仮想関数の定義
    virtual void log() const {
        std::cout << "Log: Called from Base class." << std::endl;
    }
};

// 派生クラスの定義
class Derived : public Base {
public:
    Derived() : m_data("Derived Data") {
        std::cout << "Derived constructor start." << std::endl;
    }

    // 仮想関数をオーバーライド
    void log() const override {
        std::cout << "Log: Called from Derived class. Data: " << m_data << std::endl;
    }

private:
    std::string m_data;
};

int main() {
    std::cout << "Creating Derived object..." << std::endl;
    Derived d;
    return 0;
}
実行結果
Creating Derived object...
Base constructor start.
Log: Called from Base class.
Derived constructor start.

この結果からわかる通り、Derivedクラスのインスタンスを生成しているにもかかわらず、Baseクラスのコンストラクタ内ではBase::log()が実行されています。

Javaなどの言語では、ここで派生クラス側のメソッドが呼ばれることが多いため、C++特有の挙動と言えます。

なぜ派生クラスの関数が呼ばれないのか

なぜこのような挙動になるのでしょうか。

それはC++のオブジェクト構築の順序型の安全性に深い理由があります。

オブジェクトの構築順序と安全性

C++では、派生クラスのオブジェクトが生成される際、必ず「基本クラスのコンストラクタ」から先に実行されます。

  1. メモリが確保される。
  2. Baseクラスのコンストラクタが呼び出される。
  3. Derivedクラスのメンバ変数が初期化され、コンストラクタが呼び出される。

Baseクラスのコンストラクタが実行されている時点では、Derivedクラス特有のメンバ変数(上記の例ではm_data)はまだ初期化されていません。

もしこのタイミングで派生クラスのlog()が呼び出せてしまうと、初期化されていないm_dataにアクセスすることになり、プログラムがクラッシュしたり、未定義の動作を引き起こしたりする危険があります。

C++はこのような危険を回避するために、コンストラクタ実行中はそのクラスの型としてオブジェクトを扱う仕様になっています。

仮想関数テーブル(vtable)の仕組み

技術的な側面で見ると、C++は「仮想関数テーブル(vtable)」を使用して、実行時に呼び出す関数を決定します。

構築フェーズvptr(仮想関数ポインタ)の向き先実行される関数
Baseコンストラクタ実行中Baseの仮想関数テーブルBaseのメンバ関数
Derivedコンストラクタ実行中Derivedの仮想関数テーブルDerivedのメンバ関数

Baseのコンストラクタが動いている間、オブジェクト内の仮想関数ポインタ(vptr)はまだBaseのテーブルを指しています。

そのため、動的結合(Dynamic Binding)が行われず、静的に解決されたかのような挙動になるのです。

解決策1:2段階初期化(Init関数)の利用

最も古典的で単純な解決策は、コンストラクタで全ての処理を行わず、別途初期化用の関数(init()など)を用意する方法です。

init()関数による実装

C++
#include <iostream>
#include <memory>

class Base {
public:
    virtual ~Base() {}

    // オブジェクト構築後に明示的に呼ぶ関数
    void init() {
        log();
    }

    virtual void log() const {
        std::cout << "Base log" << std::endl;
    }
};

class Derived : public Base {
public:
    void log() const override {
        std::cout << "Derived log" << std::endl;
    }
};

int main() {
    auto d = std::make_unique<Derived>();
    // オブジェクトは完全に構築されているので、仮想関数が正しく動く
    d->init(); 
    return 0;
}

この方法のメリットとデメリット

  • メリット: 実装が非常にシンプルで、既存のコードを修正しやすい。
  • デメリット: 利用者がinit()を呼び忘れるリスクがある。不完全な状態のオブジェクトが生存できてしまうため、オブジェクト指向の「カプセル化」の原則を弱める可能性があります。

解決策2:ファクトリ関数の利用

利用者にinit()の呼び出しを強制させないためには、静的ファクトリ関数を使用するのが現代的で安全な方法です。

ファクトリ関数による実装例

コンストラクタをprotectedに設定し、直接のインスタンス化を禁止することで、安全性を高めます。

C++
#include <iostream>
#include <memory>

class Base {
protected:
    // 直接生成させない
    Base() = default;

public:
    virtual ~Base() {}

    // 仮想関数
    virtual void log() const = 0;

    // ファクトリテンプレート
    template<typename T, typename... Args>
    static std::unique_ptr<T> create(Args&&... args) {
        auto ptr = std::make_unique<T>(std::forward<Args>(args)...);
        // 構築完了後に仮想関数を伴う処理を行う
        ptr->log(); 
        return ptr;
    }
};

class Derived : public Base {
public:
    // Base::createからアクセスできるようにする
    Derived() : Base() {}

    void log() const override {
        std::cout << "Derived instance created and initialized safely." << std::endl;
    }
};

int main() {
    // 安全に生成
    auto d = Base::create<Derived>();
    return 0;
}
実行結果
Derived instance created and initialized safely.

この方法であれば、create関数を経由することで、「構築」と「仮想関数による初期化」を一連の流れとしてカプセル化できます。

解決策3:基本クラスへ情報を渡す(引数による解決)

仮想関数を使って派生クラスから情報を取得するのではなく、派生クラスから基本クラスのコンストラクタへ必要な情報を引数として渡すことで、仮想関数呼び出し自体を不要にする設計です。

パラメータを渡す実装例

C++
#include <iostream>
#include <string>

class Base {
public:
    // 仮想関数ではなく、コンストラクタ引数で情報を受け取る
    explicit Base(const std::string& message) {
        std::cout << "Base notification: " << message << std::endl;
    }
    virtual ~Base() {}
};

class Derived : public Base {
public:
    // 派生クラスから親へメッセージを投げる
    Derived() : Base("Message from Derived") {
        std::cout << "Derived constructor." << std::endl;
    }
};

int main() {
    Derived d;
    return 0;
}

このアプローチの考え方

この方法は、「ダウンコール(親から子を呼ぶ)」を「アップパス(子から親へ渡す)」に置き換える手法です。

多くの場合、コンストラクタで仮想関数を呼びたくなる理由は、「親クラスの初期化ロジックの一部を子クラスでカスタマイズしたい」という動機に基づいています。

それを関数のオーバーライドではなく、データの受け渡しとして再設計することで、C++の構築順序の制限を回避できます。

解決策4:Strategyパターンや外部クラスへの委譲

より複雑な挙動を制御したい場合は、初期化ロジック自体を別のクラス(Strategy)として分離し、それをコンストラクタに渡す方法も有効です。

Strategyパターンの適用

C++
#include <iostream>
#include <memory>

// 初期化時の振る舞いを定義するインターフェース
class Logger {
public:
    virtual ~Logger() = default;
    virtual void log() const = 0;
};

class Base {
public:
    // 外部から振る舞い(Logger)を受け取る
    explicit Base(std::unique_ptr<Logger> logger) : m_logger(std::move(logger)) {
        if (m_logger) {
            m_logger->log();
        }
    }
    virtual ~Base() {}

private:
    std::unique_ptr<Logger> m_logger;
};

class DerivedLogger : public Logger {
public:
    void log() const override {
        std::cout << "Specialized logging for Derived class." << std::endl;
    }
};

class Derived : public Base {
public:
    // 自身のコンストラクタ引数で、親に必要な部品を渡す
    Derived() : Base(std::make_unique<DerivedLogger>()) {}
};

int main() {
    Derived d;
    return 0;
}

この方法の利点は、クラス間の結合度を下げられることです。

初期化時の振る舞いがBaseDerivedから切り離されているため、テストも容易になります。

デストラクタにおける仮想関数呼び出しの注意点

コンストラクタと同様に、デストラクタ内での仮想関数呼び出しも避けるべきです。

理由はコンストラクタの逆です。

派生クラスのデストラクタが実行された後、基本クラスのデストラクタが呼ばれます。

基本クラスのデストラクタが動いている最中には、派生クラスの部分は既に解体(破棄)されています。

したがって、デストラクタ内で仮想関数を呼んでも、やはり基本クラスの関数が呼ばれるか、純粋仮想関数の場合はランタイムエラー(pure virtual method called)を引き起こします。

リソースの解放処理などは、仮想関数に頼らず明示的に行う設計が求められます。

まとめ

C++において、コンストラクタやデストラクタで仮想関数を呼び出すことは、期待した多態性(ポリモーフィズム)が得られないだけでなく、プログラムの安定性を損なう原因となります。

今回のポイントを整理します。

  • 原因: 基本クラスの構築中はオブジェクトの型が「基本クラス」として扱われ、派生クラスの部分は未初期化であるため。
  • 挙動: 仮想関数を呼んでも、基本クラスで定義された実装が静的に呼び出される。
  • 解決策:
    • init()関数による2段階初期化。
    • 静的ファクトリ関数を用いたカプセル化。
    • コンストラクタ引数によるデータの受け渡し。
    • Strategyパターンによるロジックの外部委譲。

C++のオブジェクト生成プロセスを正しく理解することは、「安全で堅牢なクラス設計」への第一歩です。

言語の仕様を逆手に取るのではなく、仕様に沿った設計パターンを選択することで、保守性の高いコードを実現しましょう。

クラウドSSLサイトシールは安心の証です。

URLをコピーしました!