4、虚函数表(vtable)原理:虚函数表的内存布局、虚函数指针(vptr)、对象内存中的vptr位置、单继承下的vtable结构
好,咱们今天来聊聊C++里最核心、也最让人头疼的一个机制——虚函数表。说实话,我当年刚学C++的时候,看到“vtable”和“vptr”这两个词,第一反应是“这玩意儿到底藏在哪?”后来在项目里踩过几次坑,才真正搞明白它背后的内存布局。
你想想看,一个类里定义了虚函数,编译器到底是怎么做到“运行时多态”的?说白了,就是靠一张表——虚函数表(vtable),和一个指针——虚函数指针(vptr)。
4.1 虚函数表(vtable)是什么?
虚函数表,本质上是一张函数指针数组。每个包含虚函数的类,编译器都会为它生成一张这样的表。表里按声明顺序,存放着该类所有虚函数的地址。
我习惯把vtable理解成“类的名片”。每个类只有一张vtable,所有该类的对象共享它。嗯,这里要注意:vtable是类级别的,不是对象级别的。
核心要点:
- 每个含有虚函数的类,编译器生成一张vtable
- vtable中存放的是函数指针,指向虚函数的实际代码地址
- vtable本身存储在只读数据段(.rodata),运行时不可修改
4.2 虚函数指针(vptr)藏在哪?
每个对象里,编译器会悄悄塞进一个指针——vptr。这个指针指向该对象所属类的vtable。对象有多大?vptr占了4字节(32位)或8字节(64位)。
我曾经在调试一个内存泄漏问题时,发现对象大小比预期多了8个字节。排查了半天,才意识到是vptr搞的鬼。你想想看,如果你用sizeof去算一个含虚函数的类,结果会比成员变量总和多出一个指针的大小。
小技巧: 用 sizeof(MyClass) 可以直观看到vptr占用的空间。如果类里只有int成员,但sizeof返回12(32位)或16(64位),那多出来的就是vptr。
4.3 对象内存中的vptr位置
vptr放在对象的哪个位置?标准没有强制规定,但几乎所有主流编译器(GCC、Clang、MSVC)都把它放在对象的起始地址。也就是说,对象的首地址处,存的就是vptr。
为什么放在开头?我个人觉得,这样访问vtable时效率最高。你想想看,调用虚函数时,编译器只需要从对象首地址取出vptr,再根据偏移量找到函数地址,一步到位。
注意: 不要试图手动修改vptr的值。虽然技术上可行(因为vptr在对象内存中),但这样做会导致程序崩溃或未定义行为。我曾经见过有人用memcpy复制对象时,不小心覆盖了vptr,结果虚函数调用全乱套了。
4.4 单继承下的vtable结构
单继承是最简单的情况。咱们来看个例子:
class Base {
public:
virtual void func1() { cout << "Base::func1" << endl; }
virtual void func2() { cout << "Base::func2" << endl; }
void func3() { cout << "Base::func3" << endl; } // 非虚函数
};
class Derived : public Base {
public:
virtual void func1() override { cout << "Derived::func1" << endl; }
virtual void func4() { cout << "Derived::func4" << endl; }
};
这个例子里,Base类有两个虚函数:func1和func2。Derived类继承了Base,重写了func1,新增了func4。
编译器生成的vtable结构如下:
| 偏移量 | Base的vtable | Derived的vtable |
|---|---|---|
| 0 | Base::func1() | Derived::func1() |
| 1 | Base::func2() | Base::func2() |
| 2 | (结束) | Derived::func4() |
看到了吗?Derived的vtable里,func1的位置被替换成了Derived自己的版本。func2保持不变,因为没被重写。新增的func4追加在末尾。
这就是多态的本质:通过vptr找到vtable,再通过固定偏移量找到正确的函数地址。调用ptr->func1()时,不管ptr指向Base还是Derived,都能调用到正确的版本。
关键理解: 虚函数调用不是直接call某个地址,而是先取vptr,再查vtable,最后间接调用。这多了一层间接性,所以虚函数比普通函数慢一点点(通常多几条指令)。
4.5 用SVG看vtable结构
光说可能不够直观,我画了一张图,帮你理清对象、vptr、vtable之间的关系:
从这张图可以清楚看到:对象开头是vptr,它指向类的vtable。vtable里按顺序存放着虚函数地址。调用虚函数时,编译器会生成类似这样的代码:
// 伪代码:ptr->func1() 的底层实现
// 1. 从对象首地址取出vptr
vptr = *(ptr)
// 2. 从vtable中取出第0个函数指针
func_addr = vptr[0]
// 3. 间接调用
call func_addr
4.6 避坑指南
我在实际项目中遇到过几个跟vtable相关的坑,分享给你:
- 构造函数里别调虚函数:构造时vptr指向的是当前正在构造的类的vtable,不是最终派生类的。我曾经在基类构造函数里调了一个虚函数,结果调的是基类版本,而不是派生类重写后的版本。调试了半天才发现问题。
- 析构函数同理:析构时vptr会逐步回退,从派生类到基类。如果在析构函数里调虚函数,行为可能不符合预期。
- 对象切片问题:按值传递对象时,vptr不会被复制,切片后的对象只有基类部分。所以虚函数调用会走基类的vtable。我建议总是用指针或引用传递多态对象。
个人建议: 如果你在调试虚函数调用异常,可以先用 gdb 或 lldb 打印对象的vptr值,再对比vtable地址。命令类似 p /a *(void**)obj,能直接看到vptr指向的vtable地址。
好了,关于单继承下的vtable原理,咱们就聊到这。记住一句话:vptr在对象开头,vtable在只读数据段,虚函数调用就是“查表跳转”。搞懂这个,多态对你来说就不再神秘了。
公众号:蓝海资料掘金营,微信deep3321