A virtual method table (VMT), virtual function table, virtual call table, dispatch table, vtable, or vftable is a mechanism used in a programming language to support dynamic dispatch (or run-time method binding). ——WIKIPEDIA

上面是维基百科对虚函数表的解释,那么递归学习来了……

什么是 dynamic dispatch?

大一上C++基础课时有说过,虚表的引入就是为了实现动态联编。联编(dispatch)指的是找到该调用哪一个函数的过程,即:当你给某个类编写了某个函数时,编译器会找到这个函数的定义,并在每次调用它时,准确地执行它。

考虑这样的情况:

#include <iostream>

class A {
public:
    void foo();
};

void A::foo() {
    std::cout << "foo!" << std::endl;
}

编译器会创建 foo() 的调用路径,并记住 foo() 的地址。这样,每次对 A 的实例调用 foo() 时都会按照固定的调用路径执行。也就是说,对于每一个这样的类成员方法,其调用路径是唯一的。这个过程被称之为 静态联编(static dispatch)。编译器在编译阶段就能明确地知道函数的调用路径。

虚表又和这有什么关系呢?

静态联编的情况太“死板”了,太理想化了,很多时候,编译器并不能在编译器就知道函数具体的调用路径。比如下面这种情况:

#include <iostream>

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

void Base::foo() {
    std::cout << "Base.foo!" << std::endl;
}

void Base::bar() {
    std::cout << "Base.bar!" << std::endl;
}

class Derived : public Base {
public:
    void bar() override;
};

void Derived::bar() {
    std::cout << "Derived.bar!" << std::endl;
}

上面是基类 Base 与派生类 Derived 的声明与定义。需要注意的是,Base 中的两个成员函数均以 virtual 关键字被声明为虚函数,而 Derived 中重写了 bar()

现在,我们使用 Base 类型的指针指向 Derived 的对象,并尝试调用 bar()

Base *b = new Derived();
b->bar();

结果是输出了 Derived.bar!。如果上面是静态联编的话,b->bar() 就会执行 Base::bar(),因为站在编译器角度,b 指向的是 Base 的一个实例对象,那么执行 bar() 的路径也是固定的。然而这是完全违背程序本意的,因为 b 指向的明明是一个 Derived 的对象,希望被调用的也应该是 Derived::bar()

在这种情况下,通过指针(或者引用)执行虚函数时,我们需要不同于静态联编的查找函数执行路径的方法,这种方法当然没法在编译期就确定函数执行路径。我们称这种方法为 动态联编(dynamic dispatch)

动态联编具体如何实现?

对于每一个包含虚函数的类,编译器都会为其构造一个 虚表(virtual table, vtable)。虚表中记录了每一个可访问的虚函数的入口,并储存了指向这个函数的指针。虚表中的各个入口会指向①该类声明的成员虚函数,或是②继承的父类虚函数。

在之前的例子中,BaseDerived 的虚表是这样的:

vmt-01

Base 的虚表中记录了两个入口,分别指向 Base::foo()Base::bar()

Derived 的虚表中也记录了两个入口,分别指向 Base::foo()Derived::bar()。指向 Base::foo() 是因为 Derived 是由 Base 派生而来,继承了父类的虚函数。而由于 Derived 重写了虚函数 bar(),原本继承的 Base::bar() 变成了 Derived::bar()

需要注意的是,虚表这一概念是在类这一层级上的,也就是说,一个类的虚表是全局唯一的,它会被该类的所有实例共享。

vpointer

你可能会问,当编译器看到 b->bar() 时,它就去查 Base 的虚表,那还是会访问到 Base::bar() 而不是 Derived::bar() 啊。

那么,vpointer 就该上场了。当编译器发现某个类包含虚函数时,它就为这个类创建了一个 vpointer 成员指针,这个指针就保存了该类的虚表地址。

回到上例,编译器看到 b->bar() 时,他就去找当前这个对象的 vpointer,再访问 vpointer 指向的虚表,即 Derived 虚表,这样就能找到 Derived::bar() 了。

一定要注意的是,vpointer 是一个成员属性,它会增加每一个对象的内存占用。

#include <iostream>

class ClassWithVirtualMethod {
public:
    virtual void foobar() {}
};

class ClassWithNoVirtualMethod {
public:
    void foobar() {}
};

int main() {
    ClassWithVirtualMethod cwvm;
    ClassWithNoVirtualMethod cwnvm;
    std::cout << sizeof(cwvm) << std::endl;     // 8
    std::cout << sizeof(cwnvm) << std::endl;    // 1
    return 0;
}

由于 vpointer 和内存对齐的共同影响,cwvm 的大小达到了 8 字节;而 cwnvm 仅仅只有 1 字节。

动态联编常见场景

自然是 析构函数 了。这里不再多讲,几乎每本c++的书都会提到这个。

总结一下

  • 面向对象的多态特性导致没法在编译时就静态联编虚函数
  • 虚函数只能在运行时进行联编
  • 虚表是实现动态联编的一种主流方法
  • 对于每个声明或继承了虚函数的类,编译器都会为其创建虚表
  • 虚表保存了每个虚函数具体实现的地址
  • 对于每个有虚表的类,编译器都会为其增加一个成员指针 vpointer,指向其虚表