C++ 函数虚表 Virtual Table

之前老是搞不明白这个八股,后面有幸看到 The virtual table 这篇文章,终于搞明白了,特此记录一下。

C++ 标准从来没有规定过如何实现虚函数,都是由编译器自行实现。

Virtual Table 实现

C++ 实现虚函数都是通过 virtual table 的方式,virtual table 是一个维护函数映射的表。虽然虚函数表的具体实现是编译器决定的(即不同的编译器可能有不同的实现方式),但大体上,虚函数表可以被视为一个数组,其中每个元素是一个函数指针,指向类的一个虚函数。

所有含有 virtual function 的类都有 virtual table,virtual table 由编译器在编译期间生成。

比如 class Baseclass A : public Baseclass B : public Base 这三个类,假设 class Base 里面是有 virtual function ,那么上面这个例子中,编译器会生成三个 virtual table,分别指向 Base,A和B。不是说你new了 5 个 Base,就有5个 virtual table。

编译器会在基类里面添加一个隐藏的指针变量 __vptr,用于指向虚表。比如上面例子中的 Base,实际上编译器会生成如下结构:

class Base
{
public:
    VirtualTable* __vptr; // 对所有子类可见
    virtual void xxxxxx() {};
};

这点可以通过下面的代码验证出来:

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

class VirtualA : public VirtualBase {
public:
    int a=0;
 };


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

class NoVirtualA : public NoVirtualBase {
public:
    int a=0;
};

int main(int argc, char**argv)
{
    VirtualA va{};
    std::cout << "VirtualA pointer: " << &va << std::endl;
    std::cout << "VirtualA.a pointer: " << &va.a << std::endl;

    NoVirtualA nva{};
    std::cout << "NoVirtualA pointer: " << &nva << std::endl;
    std::cout << "NoVirtualA.a pointer: " << &nva.a << std::endl;
 }

输出结果是:

VirtualA pointer: 0x16b4c7200
VirtualA.a pointer: 0x16b4c7208
NoVirtualA pointer: 0x16b4c71fc
NoVirtualA.a pointer: 0x16b4c71fc

VirtualAVirtualA.a 两个指针之间差了 8 bytes,因为我是64位系统,一个指针正好就是8 bytes,所以这8 bytes就是 virtual table 的指针。

NoVirtualA 因为不存在虚函数,所以 NoVirtualANoVirtualA.a 指针的地址是一样的。

Virtual Table 如何实现多态

class Base
{
public:
    VirtualTable* __vptr;
    virtual void function1() {};
    virtual void function2() {};
};

class D1: public Base
{
public:
    void function1() override {};
};

class D2: public Base
{
public:
    void function2() override {};
};

上面这个例子,一共会生成3个 virtual table,virtual table 的样子如下:

C++ 函数虚表 Virtual Table

下面看看如何基于 virtual table 实现多态。

创建了一个 d1 实例,d1 里面的 __vptr 指针指向 D1 的 virtual table。

int main()
{
    D1 d1 {};
}

接下来用一个 Base* 指针指向 d1的地址。

int main()
{
    D1 d1 {};
    Base* dPtr = &d1;

    return 0;
}

虽然Base* 指针指向 d1的地址会发生 Object Slicing,但是因为 __vptr 本来就是 Base 里面的变量,所以依然会保留下来,且仍指向 D1 的 virtual table。

所以当你执行dPtr->function1() 时,它会从 D1 的 virtual table 查找 function1() 的函数指针,然后 function1() 的函数指针指向 D1 里面的实现,而不是 Base 上的实现,自此完成了多态。

int main()
{
    D1 d1 {};
    Base* dPtr = &d1;
    dPtr->function1();

    return 0;
}

通过 virtual table 的设计,即使你只是使用基类的指针或引用,编译器都能够确保你调用了正确的虚函数。

性能

因为虚函数多了查询 virtual table 这一步,所以性能上,虚函数肯定会比直接函数调用慢。

直接函数调用(Direct Function Call):

直接调用函数是最简单、最快的方式,因为它只涉及一个操作:

  1. 跳转到函数的地址执行代码:编译器在编译时就已经知道函数的地址,因此生成的机器码中会直接包含这个地址。这意味着调用直接转换为一条跳转指令。

间接函数调用(Indirect Function Call,如通过函数指针)

间接调用函数,如通过函数指针,涉及两个操作:

  1. 读取函数指针的值:首先需要从内存中读取存储函数地址的指针的值。
  2. 跳转到该地址执行代码:一旦有了函数的地址,下一步就是跳转到这个地址执行函数体。

虚函数调用:

虚函数调用相对复杂,因为它支持运行时多态,涉及三个操作:

  1. 访问对象的虚函数表指针(__vptr):每个使用虚函数的类的对象都有一个指向虚函数表(vtable)的指针。首先需要访问这个指针。
  2. 索引虚函数表以找到正确的函数地址:虚函数表是一个函数指针数组,调用哪个函数取决于这个函数在表中的偏移量(即索引)。因此,下一步是使用这个索引来获取实际要调用的函数的地址。
  3. 跳转到该地址执行代码:最后,与间接调用一样,一旦有了函数的地址,就跳转到这个地址执行函数体。

同时注意到,因为任何使用虚函数的类都会有一个*__vptr,因此该类的每个对象都会比原来的对象多一个指针。虚函数很强大,但它们确实有一个性能开销。

参考

The virtual table

ChatGPT

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
搜索