1. ホーム

C++仮想関数表解析

2022-02-24 17:21:48
<パス

keywords: 仮想関数, 仮想テーブル, 仮想テーブルポインタ, ダイナミックバインディング, ポリモーフィズム

I. 概要

C++でポリモーフィズムを実装するために、C++は動的バインディングという技法を用いている。この技術の中心は、仮想関数テーブル(以下、仮想テーブルと呼ぶ)です。この記事では、仮想関数テーブルがどのように動的バインディングを実装するかを説明します。

II. クラスダミーテーブル

仮想関数を含むすべてのクラスは、仮想テーブルを含んでいます。

あるクラス(A)が他のクラス(B)を継承するとき、クラスAはクラスBの関数を呼び出す権利を継承することが分かっています。つまり、ベースクラスが仮想関数を含んでいれば、その後継のクラスもその仮想関数を呼び出すことができるのです。つまり、あるクラスが仮想関数を含む基底クラスを継承した場合、そのクラスも仮想テーブルを持つことになる。

次のようなコードを見てみましょう。クラスAは仮想関数を含んでいます vfunc1 その vfunc2 クラスAは仮想関数を含むので、クラスAは仮想テーブルを持つ。

class A {
public:
    virtual void vfunc1();
    virtual void vfunc2();
    void func1();
    void func2();
private:
    int m_data1, m_data2;
};


クラスAの仮想テーブルを図1に示す。

図1:クラスAのダミー表現

ダミーテーブルは、ダミー関数へのポインタを要素とするポインタの配列であり、各要素はダミー関数への関数ポインタに対応する。ここで重要なことは、通常の関数、すなわち非仮想関数は、仮想テーブルを介して呼び出す必要がないため、仮想テーブルの要素に通常の関数への関数ポインタは含まれないということである。

仮想テーブル内のエントリ、すなわち仮想関数へのポインタの割り当ては、コンパイラのコンパイル段階で行われるため、コードのコンパイル段階で仮想テーブルを構築することができる。

iii. 仮想テーブルポインタ

ダミーテーブルは、特定のオブジェクトではなく、クラスに属します。1つのクラスには1つのダミーテーブルしか必要ありません。同じクラスのすべてのオブジェクトは、同じ仮想テーブルを使用します。

オブジェクトの仮想テーブルを指定するには、そのオブジェクトが使用する仮想テーブルへの内部ポインタを含めます。仮想テーブルを含むクラスのすべてのオブジェクトが仮想テーブルへのポインタを持つようにするために、コンパイラは、クラス *__vptr を使用することで、仮想テーブルを指し示すことができます。こうすることで、クラスのオブジェクトは生成時にそのポインタを持ち、ポインタの値は自動的にそのクラスの仮想テーブルを指すように設定される。

図2:オブジェクトとその仮想テーブル

前述のように、継承したクラスの基底クラスが仮想関数を持つ場合、その継承したクラスも独自の仮想テーブルを持つので、その継承したクラスのオブジェクトもその仮想テーブルへのポインタを持つことになります。

IV. ダイナミックバインディング

ここで、C++がどのように仮想テーブルと仮想テーブルポインタを利用して動的バインディングを実装しているのかが気になるところです。次のコードを見てみましょう。

class A {
public:
    virtual void vfunc1();
    virtual void vfunc2();
    void func1();
    void func2();
private:
    int m_data1, m_data2;
};

class B : public A {
public:
    virtual void vfunc1();
    void func1();
private:
    int m_data3;
};

class C: public B {
public:
    virtual void vfunc2();
    void func2();
private:
    int m_data1, m_data4;
};


クラスAをベースクラスとし、クラスBはクラスAを継承し、クラスCはクラスBを継承します。クラスA、クラスB、クラスCのオブジェクトモデルは以下の図3のとおりです。

図3:クラスA,クラスB,クラスCのオブジェクトモデル

3つのクラスはすべて仮想関数を持つので、コンパイラはクラスごとに仮想テーブル、すなわちクラスA用の仮想テーブル(A vtbl)、クラスB用の仮想テーブル(B vtbl)、クラスC用の仮想テーブル(C vtbl)を作成します。クラスA、クラスB、クラスCのオブジェクトはすべて、この仮想テーブルへのポインタを持つ。 *__vptr であり、所属するクラスの仮想テーブルを指し示すために使用される。

Aクラスは2つの仮想関数を含むので、A vtblには2つのポインタが含まれます。 A::vfunc1() A::vfunc2() .
クラスBはクラスAを継承しているので、クラスBはクラスAの関数を呼び出すことができますが、クラスBがオーバーライドするため B::vfunc1() 関数を指すので、B vtbl の2つのポインタは B::vfunc1() A::vfunc2() .
クラスCはクラスBを継承しているので、クラスCはクラスBの関数を呼び出すことができますが、クラスCがオーバーライドするのは C::vfunc2() 関数を指すので、C vtblの2つのポインタは B::vfunc1() (最も近い継承クラスを指す関数) と C::vfunc2() .

図3は少し複雑に見えますが、"オブジェクトの仮想テーブルのポインタが所属するクラスの仮想テーブルを指し、仮想テーブルのポインタが継承する最も近いクラスの仮想関数を指すという特徴を捉えれば、これらのクラスのオブジェクトモデルをすぐに頭の中にマッピングすることができます。

非仮想関数は、仮想テーブルを介して呼び出されることはないので、仮想テーブルのポインタがこれらの関数を指す必要はない。

クラスBのオブジェクトを定義するとします。 bObject はクラスBのオブジェクトであるため bObject には、クラスBの仮想テーブルへのポインタが含まれる。

int main() 
{
    B bObject;
}


ここで、クラスAへのポインタを宣言します。 p を指し示すように、オブジェクト bObject . しかし p は基底クラスへのポインタは基底クラスの一部しか指すことができませんが、仮想テーブルへのポインタは基底クラスの一部でもあるので p オブジェクトにアクセスすることができます。 bObject を仮想テーブルポインタで指定します。 bObject ポインタはクラス B の仮想テーブルを指しているので p B vtblには、図3のようにアクセスできる。

int main() 
{
    B bObject;
    A *p = & bObject;
}


を使用する場合 p を呼び出して vfunc1() という関数がありますが、どうなるのでしょうか?

int main() 
{
    B bObject;
    A *p = & bObject;
    p-> vfunc1();
}


プログラムが実行されている p->vfunc1() が見つかります。 p がポインターであり、呼び出された関数がダミー関数である場合、次のような手順で処理されます。

まず、仮想テーブルのポインタ p->__vptr でオブジェクトにアクセスします。 bObject ダミーテーブルに対応する ポインタ p は、ベースクラス A* という型がありますが *__vptr は基底クラスの一部でもあるので、それを介して p->__vptr オブジェクトに対応する仮想テーブルにアクセスすることができる。

そして、仮想テーブルの中から呼び出した関数に対応するエントリーを探します。仮想テーブルはコンパイル段階で構築できるので、呼び出された関数をもとに仮想テーブルの対応するエントリーを探し出すことができる。例えば p->vfunc1() を呼び出すと、B vtblの最初のエントリは vfunc1 エントリに対応する。

最後に、仮想テーブルで見つかった関数ポインタに基づいて、関数が呼び出されます。図3からわかるように、B vtblの最初のエントリは、以下を指しています。 B::vfunc1() そこで p->vfunc1() Substance は B::vfunc1() 関数を使用します。

もし p がクラスAのオブジェクトを指している場合、何が起こるでしょうか?

int main() 
{
    A aObject;
    A *p = &aObject;
    p->vfunc1();
}


いつ aObject が作成されると、その仮想テーブルポインタ __vptr はA vtblを指すように設定されているので p->__vptr はA vtblを指しています。 vfunc1 A vtblのエントリは、以下のエントリに対応する。 A::vfunc1() 関数を使用するため p->vfunc1() Substanceは A::vfunc1() 関数を使用します。

以上、関数を呼び出すための3つのステップは、以下の式で表すことができます。

(*(p->__vptr)[n])(p)


このように、この仮想関数テーブルを利用すれば、たとえベースクラスへのポインタを用いて関数を呼び出す場合でも、実行時に実際のオブジェクトの仮想関数への正しい呼び出しを実現することができます。

仮想テーブルを介して仮想関数を呼び出す処理をダイナミックバインディングと呼び、その現れをランタイムポリモーフィズムと呼ぶ。動的バインディングは、コンパイル段階で関数呼び出しが決定される従来の静的バインディングと呼ばれるものとは一線を画している。

では、関数のダイナミックバインドはどのような場合に実行されるのでしょうか。これには、次の3つの条件を満たす必要がある。

  • ポインタを介して関数を呼び出す
  • ポインタ アップキャスト アップキャスト(継承したクラスをベースクラスに変換することをアップキャストといいます。)
  • 呼び出し先が仮想関数である

関数呼び出しが上記の3つの条件を満たす場合、コンパイラはその関数呼び出しを、仮想テーブルを介して上記のメカニズムに従う動的バインディングとしてコンパイルします。

V. 概要

C++では、仮想関数のテーブルを通じて仮想関数とオブジェクトの動的な結合を可能にすることで、オブジェクト指向プログラミングの基礎を構築している。

リファレンス

  • C++入門 第3版 中国語版 潘愛民他訳
  • http://www.learncpp.com/cpp-tutorial/125-the-virtual-table/
  • 侯潔、"C++ベストプログラミングプラクティス"ビデオ、ギーククラス、2015年
  • アップキャスティングとダウンキャスティング、http://www.bogotobogo.com/cplusplus/upcasting_downcasting.php

添付ファイル:ソースコード

https://github.com/haozlee/vtable/blob/master/main.cpp