再谈C++多态中的虚函数表

C++中的虚函数表

之前的 C++ 继承中已经说过多态基本概念,这里不再赘述。文章中多处给出了类实例对象的内存布局,查看其内存布局时,使用 VS 工具 /d1 reportAllClassLayout 进行查看,关于这个工具的详细介绍,请点击这里

虚函数表的原理解析

C++ 虚函数表主要出现在多态情况下。这里我们先从单继承下说明其虚函数表的原理,后面再说多继承情况下的。

每个有虚函数的类的实例对象都有一个虚函数表指针成员,该虚函数指针一般位于对象存储空间的起始位置(相较于对象存储空间的起始位置偏移量为 0),该虚函数表指针指向实例的虚函数表,该虚函数表就是实现多态的关键。实际上,任何继承于包含虚函数的基类的派生类的实例对象都有虚函数表指针成员,并且虚函数表中的函数地址都是基类的,其操作是由编译器自动添加到构造函数中的指令完成的。

接下来将从下面一段简单的代码:

class Animal
{
public:
    virtual void eat() { cout << "Animal::eat" << endl; }
    virtual void sleep() { cout << "Animal::sleep" << endl; }
    virtual void play() { cout << "Animal::play" << endl; }

private:
    string m_name;
    int m_age = 0;
};

class Cat : public Animal
{
public:
    virtual void eat() { cout << "Cat::eat" << endl; }
    virtual void sleep() { cout << "Cat::sleep" << endl; }
    virtual void play() { cout << "Cat::play" << endl; }
};

利用上面说的 VS 查看类实例的内存布局的功能,我们先来看一下 Animal 实例对象的内存布局如下:

> cl /d1 reportSingleClassLayoutAnimal CppTest.cpp
class Animal    size(48):
        +---
 0      | {vfptr}
 8      | ?$basic_string@DU?$char_traits@D@std@@V?$allocator@D@2@ m_name
40      | m_age
        | <alignment member> (size=4)
        +---

Animal::$vftable@:
        | &Animal_meta
        |  0
 0      | &Animal::eat
 1      | &Animal::sleep
 2      | &Animal::play

Animal::eat this adjustor: 0
Animal::sleep this adjustor: 0
Animal::play this adjustor: 0

上面说过,只要一个类中含有虚函数成员,其实例对象一定包含一个虚函数表,可以看到,首地址成员是一个 vfptr 虚函数表指针,其虚函数表中的成员就有基类中定义的三个函数。

现在我们仅使用派生类 Cat 继承于基类 Animal,不重写基类的方法,而且保持派生类的成员为空,将派生类改写成如下:

class Cat : public Animal
{
};

再看派生类 Cat 实例对象的内存布局如下:

> cl /d1 reportSingleClassLayoutCat CppTest.cpp
class Cat       size(48):
        +---
 0      | +--- (base class Animal)
 0      | | {vfptr}
 8      | | ?$basic_string@DU?$char_traits@D@std@@V?$allocator@D@2@ m_name
40      | | m_age
        | | <alignment member> (size=4)
        | +---
        +---

Cat::$vftable@:
        | &Cat_meta
        |  0
 0      | &Animal::eat
 1      | &Animal::sleep
 2      | &Animal::play

可以证明,只要继承了包含虚函数成员的基类的派生类实例对象都包含有一个虚函数表指针成员。如果我们并没有重写基类的虚函数,其派生类中的虚函数表的中的虚函数地址全部都是基类的。

接下列,将派生类改写成如下:

class Cat : public Animal
{
public:
    virtual void eat() { cout << "Cat::eat" << endl; }
    virtual void sleep() { cout << "Cat::sleep" << endl; }
};

现在再讨论派生类 Cat 的实例对象内存布局,这个是重点讨论的对象,先看其内存布局如下:

> cl /d1 reportSingleClassLayoutCat CppTest.cpp
class Cat       size(48):
        +---
 0      | +--- (base class Animal)
 0      | | {vfptr}
 8      | | ?$basic_string@DU?$char_traits@D@std@@V?$allocator@D@2@ m_name
40      | | m_age
        | | <alignment member> (size=4)
        | +---
        +---

Cat::$vftable@:
        | &Cat_meta
        |  0
 0      | &Cat::eat
 1      | &Cat::sleep
 2      | &Animal::play

Cat::eat this adjustor: 0
Cat::sleep this adjustor: 0

代码中,派生类 Cat 中重写了基类的 Animal 的虚函数 eat() 和 sleep(),我们看到派生类 Cat 的实例对象的虚函数表中的 eat 和 sleep 方法已经是派生类的实现的函数地址,没有重写基类的 play() 虚函数,在派生类的实例对象的虚函数表中的 play 方法还是基类中的函数地址。

想要达到多态的前提是:必须使用基类的指针变量指向派生类实例对象的地址,或者基类的引用变量引用派生类的实例,比如实例代码:

int main()
{
    Animal& c_animal = Cat();
    c_animal.eat();
    Animal* pAnimal = new Cat();
    pAnimal->sleep();
    return 0;
}

那么编译器是如何利用虚函数表指针与虚函数表实现多态?

派生类继承于基类,构造派生类时需要先构造基类,当构造含有虚函数的基类对象时,编译器将该对象的虚函数表指针指向基类的虚函数表,当构造派生类对象时,编译器会将该对象的虚函数表指针指指向派生类的虚函数表,而如果派生类中重写了基类的虚函数,那么派生类虚函数表中的函数地址就是派生类的函数地址,如果没有重写基类的虚函数,那么派生类虚函数表中的函数地址依然是基类的函数地址。而且构造派生类实例对象中只能有一个虚函数表指针,并且是在构造基类时就创建的。

那么,在运行时,基类的指针指向了派生类的实例对象地址,调用某个虚函数就需要在当前指向的对象的虚函数表中查找该虚函数地址,并调用该函数的的一些指令。

看一下虚函数表查询的过程:

Animal* pAnimal = new Cat();
pAnimal->sleep();

假设上面的 pAnimal 的类型是 Animal* ,则 pAnimal->sleep() 这条语句的过程是:

  1. 取出 pAnimal 的前 4 个字节(虚函数表指针成员,64 位下为 8 字节)。注:这个操作会取出虚函数表的地址,如果 pAnimal 指向的是类 Animal 的对象,则这个地址就是类 Animal 的虚函数表地址,如果是类 Cat 的对象,那么这个地址就是类 Cat 的虚函数表地址。
  2. 根据取到的虚函数表的地址找到虚函数表,在其中查找要调用的虚函数地址。可以认为虚函数表就是根据函数名作为索引进行查找,但是编译器肯定会使用更高效的查找方法。如果 pAnimal 指向的是类 Animal 的实例对象,自然就会在类 Animal 的实例对象中查出 Animal::sleep 的地址,若 pAnimal 指向的是类 Cat 的实例对象,自然就会在类 Cat 的实例对象中查出 Cat::sleep 的地址,若类 Cat 中没有自己的 sleep 函数,因此在类 Cat 虚函数表中保存的是 Animal::sleep,这样即使 pAnimal 指向的是类 Cat 的实例对象,也能在虚函数表中找到 Animal::sleep 的函数地址。
  3. 根据找到的虚函数地址调用该虚函数。

从上面的过程看到,如果通过基类指针或引用调用虚函数的语句,就一定是多态的,而且一定会执行上面的查表过程,哪怕这个虚函数仅在基类中有,而派生类中没有。

多态机制能够提高程序的开发效率,但是也增加了程序运行时的开销。其虚函数表,各个实例对象中包含的虚函数表的地址都是空间上的额外开销,而查虚函数表的过程则是时间上的额外开销。

在计算基发展的早期,计算机非常昂贵稀有,运行速度慢,计算的内存和时间是非常宝贵的,不惜多花人力编写运行速度更快,更节省内存的程序;如今,计算机的运行时间和内存没有人的时间宝贵,因此在可以接受的前提下,降低程序的运行效率而提升人员的开发效率是非常值得的。而“多态”就是一个典型的例子。

派生类中新增虚函数

上面的实例中,都是派生类继承基类并重写基类的虚函数,但是如果派生类不仅重写了基类的虚函数,而且派生类中有新增加了自己的虚函数,我们上文中说过,如果一个类中有虚函数,就会有虚函数表指针成员和虚函数表,那么这种情况下的内存布又是怎样呢?我们看以下如下程序:

class Animal
{
public:
    virtual void eat() { cout << "Animal::eat" << endl; }
    virtual void sleep() { cout << "Animal::sleep" << endl; }
    virtual void play() { cout << "Animal::play" << endl; }

private:
    string m_name;
    int m_age = 0;
};

class Cat : public Animal
{
public:
    virtual void eat() { cout << "Cat::eat" << endl; }
    virtual void sleep() { cout << "Cat::sleep" << endl; }
    virtual void drink() { cout << "Cat::attack" << endl; } //派生类中自己的虚函数
};

查看它的内存布局如下:

> cl /d1 reportSingleClassLayoutCat CppTest.cpp
class Cat       size(48):
        +---
 0      | +--- (base class Animal)
 0      | | {vfptr}
 8      | | ?$basic_string@DU?$char_traits@D@std@@V?$allocator@D@2@ m_name
40      | | m_age
        | | <alignment member> (size=4)
        | +---
        +---

Cat::$vftable@:
        | &Cat_meta
        |  0
 0      | &Cat::eat
 1      | &Cat::sleep
 2      | &Animal::play
 3      | &Cat::drink

Cat::eat this adjustor: 0
Cat::sleep this adjustor: 0
Cat::drink this adjustor: 0

我们可以看到,派生类自己的虚函数还是在当前实例对象的虚函数表中,而且在基类或父派生类的虚函数后面,所以可以总结两点如下:

  1. 虚函数是按照其声明的顺序放在虚函数表中的。
  2. 基类的虚函数在派生类的虚函数前面。

多继承情况下的虚函数表

上面已经说明了单继承下多态于虚函数表的原理,以及派生类中新增了自己的虚函数的情况,再看看如果是多继承情况下,又有哪些差别。有如下实例程序:

class Animal
{
public:
    virtual void eat() { cout << "Animal::eat" << endl; }
    virtual void sleep() { cout << "Animal::sleep" << endl; }
    virtual void play() { cout << "Animal::play" << endl; }

private:
    string m_name;
    int m_age = 0;
};

class Action
{
public:
    virtual void eat() { cout << "Action::eat" << endl; } //Animal类中也有同名成员函数
    virtual void jump() { cout << "Action::jump" << endl; }
    virtual void attack() { cout << "Action::attack" << endl; }
    virtual void defend() { cout << "Action::defend" << endl; }
    
};

class Cat : public Animal, public Action
{
public:
    virtual void eat() { cout << "Cat::eat" << endl; }
    virtual void sleep() { cout << "Cat::sleep" << endl; }
    virtual void attack() { cout << "Cat::attack" << endl; }
    virtual void drink() { cout << "Cat::attack" << endl; } //派生类中自己的虚函数
};

我们看一下其内存布局:

> cl /d1 reportSingleClassLayoutCat CppTest.cpp
class Cat       size(56):
        +---
 0      | +--- (base class Animal)
 0      | | {vfptr}
 8      | | ?$basic_string@DU?$char_traits@D@std@@V?$allocator@D@2@ m_name
40      | | m_age
        | | <alignment member> (size=4)
        | +---
48      | +--- (base class Action)
48      | | {vfptr}
        | +---
        +---

Cat::$vftable@Animal@:
        | &Cat_meta
        |  0
 0      | &Cat::eat
 1      | &Cat::sleep
 2      | &Animal::play
 3      | &Cat::drink

Cat::$vftable@Action@:
        | -48
 0      | &thunk: this-=48; goto Cat::eat
 1      | &Action::jump
 2      | &Cat::attack
 3      | &Action::defend

Cat::eat this adjustor: 0
Cat::sleep this adjustor: 0
Cat::attack this adjustor: 48
Cat::drink this adjustor: 0

其中,派生类中,我们不仅增加了属于自己的 drink() 虚函数,而且在两个基类中有个同名的 eat() 函数。从内存布局中我们可以看到:

  1. 每个基类都有自己对应的虚函数表
  2. 派生类类成员中新增的虚函数被放到了第一个基类的表中(其顺序是按继承时的顺序),这样做的目的是不同的基类类型指针指向同一个派生类实例,能够调用到实际的函数。
  3. 两个基类虚函数表中的虚函数 eat() 都被替换成派生类的函数指针了。

访问虚函数表

常规访问虚函数表

首先因为虚函数表存在于基类实例对象的地址的起始地址,我们只要取前 4 个字节(32位下),然后再取地址,再取前 4 个字节,将其强转为函数指针类型就可以访问了,实例程序如下:

class Base
{
private:
    virtual void f() { cout << "Base::f" << endl; }
};

class Derive : public Base
{
};

typedef void (*Fun)(void);

void main()
{
    Derive d;
    Fun pFun = (Fun) * ((int *)*(int *)(&d) + 0);
    pFun();
}

通过基类类型指针访问派生类自己的虚函数

首先我们知道派生类类自己的虚函数是不能直接通过基类类型指针访问的,编译就不能通过,但是上面我们说过,派生类的实例对象中,派生类和基类的虚函数在一个虚函数表中,并且派生类的虚函数在基类的虚函数后面。但在运行时,我们可以通过指针的方式访问虚函数表来达到违反 C++ 语义的行为。

访问 non-public 的虚函数

如果基类的虚函数不是 public 的,但这些非 public 的虚函数同样会存在于需表中,所以通过上面的方式同样可以访问。

访问多重继承的虚函数表

多重继承下,因为虚函数表不止一个,所以访问时需要增加一个维度,更像是一个二维数组,其访问思想如下:

typedef void (*Fun)(void);
pFun = (Fun)*((int*)*(int*)((int*)&d+0)+0);
pFun = (Fun)*((int*)*(int*)((int*)&d+0)+1);

pFun = (Fun)*((int*)*(int*)((int*)&d+1)+0);
pFun = (Fun)*((int*)*(int*)((int*)&d+1)+1);

总体*问思路还是和上面的类似。

参考:
C++ 虚函数表解析
C++虚函数表(多态的实现原理)

上一篇:Dart学习记录(二)——对象


下一篇:JS实现继承