banner
cells

cells

为美好的世界献上 bug

虚関数メカニズム

静的バインディング#

C++ における関数のデフォルトバインディングは静的バインディング、または早期バインディングおよびコンパイル時バインディングと呼ばれます。

静的バインディングは、関数の検索および関連付けのプロセスをコンパイル時に完了させるため、プログラムの実行時のパフォーマンスを向上させることができます。

動的バインディング#

#include <iostream>

class Base {
public:
    void foo() {
        std::cout << "Base::foo" << std::endl;
    }
};

class Derive1 : public Base {
public:
    void foo() {
        std::cout << "Derive1::foo" << std::endl;
    }
};

int main() {
    Base *base = new Derive1();
    base->foo();

    delete base;
    return 0;
}

実行結果:

Base::foo

ここでは派生クラスの関数は呼び出されていません。なぜなら、コンパイラはここで静的バインディングをデフォルトで行い、コンパイル段階では Base 型ポインタがどの型を指すかを特定できないため、オブジェクトポインタが実際に指しているオブジェクトの型に基づいて関数を呼び出すことができません。

したがって、関数のバインディングをコンパイル段階から実行段階に遅延させる必要があります。これにより、オブジェクトポインタが実際に指している型を指すことができるようになります。これが動的バインディングであり、遅延バインディングおよび実行時バインディングとも呼ばれます。

基底クラスの静的バインディングを行わないメンバー関数を virtual 仮想関数として宣言することで、動的バインディングを実現します。

#include <iostream>

class Base {
public:
    virtual void foo() {
        std::cout << "Base::foo" << std::endl;
    }
    
    virtual ~Base() {}
};

class Derive1 : public Base {
public:
    void foo() {
        std::cout << "Derive1::foo" << std::endl;
    }
};

int main() {
    Base *base = new Derive1();
    base->foo();

    delete base;
    return 0;
}

実行結果:

Derive1::foo
Derive2::foo

仮想関数テーブル#

仮想関数の動的バインディングは仮想関数テーブル(vftable)に基づいて実装されます。クラスに仮想関数が存在する場合、コンパイラはオブジェクトの最初のデータメンバーの位置に vfptr ポインタを挿入し、すべての仮想関数のアドレスを含む配列を指します。

image-20240619090111400

コンパイラは、仮想関数を含む各クラスのために仮想関数テーブルを生成します。そのクラスのすべてのオブジェクトは 1 つの仮想関数テーブルを共有します。以下のコードを観察してください:

#include <iostream>

class Base {
public:
    virtual void foo() {}
};

int main() {
    Base b1;
    void **b1_vfptr = *(void ***)&b1;

    Base b2;
    void **b2_vfptr = *(void ***)&b2;

    std::cout << (b1_vfptr == b2_vfptr) << std::endl;

    return 0;
}

実行結果:

1

仮想関数テーブルは、そのクラスおよび基底クラスのすべての仮想関数のアドレスを含んでいます。以下のコードを観察してください:

#include <iostream>

class Base {
public:
    virtual void foo() {
        std::cout << "Base::foo" << std::endl;
    }

    virtual ~Base() {}
};

class Derive : public Base {
public:
    void foo() override {
        std::cout << "Derive::foo" << std::endl;
    }

    virtual void foo2() {
        std::cout << "Derive::foo2" << std::endl;
    }
};

int main() {
    Base *base = new Derive;

    void **vfptr = *(void ***)base;

    // [0] &Base::foo
    // [1] &Base::~Base
    // [2] &Derive::foo
    // [3] &Derive::foo2
    void (*f)() = (void(*)())vfptr[3];

    f();

    delete base;
    return 0;
}

実行結果:

Derive::foo2

仮想関数テーブルはコンパイラによって初期化および破棄され、コンストラクタで初期化され、デストラクタで破棄されます。

多重継承の下では、複数の仮想関数テーブルが存在する可能性があります。

#include <iostream>

class Base1 {
public:
    virtual void foo() {} 
};

class Base2 {
public:
    virtual void foo() {}
};

class Derive : public Base1, public Base2 {
public:
    void foo() override {}
};

int main() {
    std::cout << sizeof(Derive) << std::endl;
    return 0;
}

64 ビット OS の実行結果:

16

子クラスが親クラスの仮想関数をオーバーライドしていない場合、子クラスの仮想関数テーブルは親クラスの仮想関数のアドレスを継承します。

子クラスが親クラスの仮想関数をオーバーライドした場合、子クラスが親クラスから継承した仮想関数テーブルは対応するオーバーライドの動作を生成します。

#include <iostream>

class Base {
public:
    virtual void foo() {
        std::cout << "Base::foo" << std::endl;
    }

    virtual ~Base() {}
};

class Derive1 : public Base {
public:
    
};

class Derive2 : public Base {
public:
    virtual void foo() override {
        std::cout << "Derive2::foo" << std::endl;
    }
};

int main() {
    Base *base = new Derive1();
    base->foo();

    delete base;
  
    base = new Derive2();
    base->foo();

    delete base;
    return 0;
}

実行結果:

Base::foo
Derive2::foo

実行時型識別#

C++ は静的型言語であり、データ型はコンパイル段階で決定されますが、特定のシーン(ポリモーフィズム)では、コンパイル段階ではデータ型を特定できず、実行段階で決定する必要があります。

実行時型識別(RTTI)は、実行段階でデータ型を特定するメカニズムです。

typeid#

typeid 演算子は変数の型を取得するために使用され、コンパイル段階で変数の型を取得することも、実行段階で変数の型を取得することもできます。

実行段階で変数の型を特定する:

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

class Derive : public Base {
public:
    
};

int main() {
    Base *base = new Derive;
    std::cout << typeid(*base).name() << std::endl;
    delete base;
    return 0;
}

g++ 実行結果:

6Derive

dynamic_cast#

dynamic_cast は型変換に使用され、継承関係のある親子型のポインタや参照の型変換が安全かどうかを検出できます。コンパイル段階で変換することも、実行段階で変換することもできます。

コンパイル段階の変換:

#include <iostream>

class Base {};

class Derive : public Base {};

int main() {
    Derive *derive = new Derive();
    Base *base = dynamic_cast<Base *>(derive);

    delete base;
    return 0;
}

より大きなアドレス範囲のポインタをより小さな範囲に変換することは、メモリの越境を引き起こさないため、安全です。

実行段階の変換:

#include <iostream>

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

class Derive : public Base {};

int main() {
    Base *base1 = new Base(); // base1 は基底クラスを指す
    Derive *derive1 = dynamic_cast<Derive *>(base1); // 基底クラスを派生クラスに変換
    std::cout << derive1 << std::endl; // 0 変換失敗

    Base *base2 = new Derive(); // base2 は派生クラスを指す
    Derive *derive2 = dynamic_cast<Derive *>(base2); // 派生クラスを基底クラスに変換
    std::cout << derive2 << std::endl; // 非0 変換成功

    delete derive1;
    delete derive2;
    return 0;
}

より小さなアドレス範囲のポインタをより大きな範囲に変換することは、メモリの越境を引き起こす可能性があるため、安全ではありません。

typeiddynamic_cast は実行段階で型識別を行う際に、仮想関数メカニズムに依存します。

ポリモーフィズムの状況では、親クラスのポインタが子クラスのオブジェクトを指す場合が発生する可能性があります。

仮想関数テーブルの各ポインタは通常 std::type_info オブジェクトを指し、vfptr を通じてオブジェクトの型情報 &class_meta にアクセスできます。

仮想関数メカニズムの利点と欠点#

利点:

  • 動的ポリモーフィズム: 基底クラスのポインタまたは参照を通じて、派生クラスでオーバーライドされた仮想関数を呼び出すことを許可します。
  • コードの再利用: 基底クラスが共通インターフェースを定義し、派生クラスが必要に応じて仮想関数をオーバーライドして異なる機能を実現できます。
  • 拡張性: 新しい派生クラスを追加し、仮想関数をオーバーライドすることができ、基底クラスのコードを変更する必要がありません。
  • デカップリング: 仮想関数メカニズムはコードのデカップリングを助け、コードの保守と変更を容易にします。

欠点:

  • メモリオーバーヘッド: 仮想関数を含む各クラスには仮想関数テーブルがあり、仮想関数テーブルは関数ポインタの配列であり、64 ビットオペレーティングシステムでは 8 バイトのメモリを消費します。
  • 呼び出しオーバーヘッド: 仮想関数を呼び出す際には、仮想関数テーブルを通じて実際に呼び出す関数のアドレスを検索するため、関数アドレスを直接呼び出すのに比べてわずかに遅くなります。
  • 最適化に影響を与える可能性: 静的バインディングはコンパイル段階で呼び出す関数のアドレスを特定でき、コンパイラはインライン最適化を行うことができます。しかし、動的バインディングの場合、コンパイラはコンパイル段階で具体的な関数実装を特定できず、実行段階で仮想関数テーブルを通じて実際に呼び出す関数のアドレスにアクセスする必要があります。
読み込み中...
文章は、創作者によって署名され、ブロックチェーンに安全に保存されています。