之前老是搞不明白这个八股,后面有幸看到 The virtual table 这篇文章,终于搞明白了,特此记录一下。
C++ 标准从来没有规定过如何实现虚函数,都是由编译器自行实现。
Virtual Table 实现
C++ 实现虚函数都是通过 virtual table 的方式,virtual table 是一个维护函数映射的表。虽然虚函数表的具体实现是编译器决定的(即不同的编译器可能有不同的实现方式),但大体上,虚函数表可以被视为一个数组,其中每个元素是一个函数指针,指向类的一个虚函数。
所有含有 virtual function 的类都有 virtual table,virtual table 由编译器在编译期间生成。
比如 class Base
,class A : public Base
,class 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
VirtualA
和 VirtualA.a
两个指针之间差了 8 bytes,因为我是64位系统,一个指针正好就是8 bytes,所以这8 bytes就是 virtual table 的指针。
NoVirtualA
因为不存在虚函数,所以 NoVirtualA
和 NoVirtualA.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 的样子如下:
下面看看如何基于 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):
直接调用函数是最简单、最快的方式,因为它只涉及一个操作:
- 跳转到函数的地址执行代码:编译器在编译时就已经知道函数的地址,因此生成的机器码中会直接包含这个地址。这意味着调用直接转换为一条跳转指令。
间接函数调用(Indirect Function Call,如通过函数指针):
间接调用函数,如通过函数指针,涉及两个操作:
- 读取函数指针的值:首先需要从内存中读取存储函数地址的指针的值。
- 跳转到该地址执行代码:一旦有了函数的地址,下一步就是跳转到这个地址执行函数体。
虚函数调用:
虚函数调用相对复杂,因为它支持运行时多态,涉及三个操作:
- 访问对象的虚函数表指针(__vptr):每个使用虚函数的类的对象都有一个指向虚函数表(vtable)的指针。首先需要访问这个指针。
- 索引虚函数表以找到正确的函数地址:虚函数表是一个函数指针数组,调用哪个函数取决于这个函数在表中的偏移量(即索引)。因此,下一步是使用这个索引来获取实际要调用的函数的地址。
- 跳转到该地址执行代码:最后,与间接调用一样,一旦有了函数的地址,就跳转到这个地址执行函数体。
同时注意到,因为任何使用虚函数的类都会有一个*__vptr
,因此该类的每个对象都会比原来的对象多一个指针。虚函数很强大,但它们确实有一个性能开销。
参考
ChatGPT
原创文章,作者:Smith,如若转载,请注明出处:https://www.inlighting.org/archives/c-plus-plus-virtual-table