这一篇解决什么问题
类一旦有了虚函数,对象内部就多了一样看不见的东西。这一篇回答:
- 这个看不见的东西(vptr)放在对象哪里,占多大?
- 虚表(vtable)长什么样,同类对象之间是什么关系?
- 一次虚调用在汇编层面到底执行了什么?
虚函数机制的三个要点
要点 1:有虚函数的类,对象开头多一个 vptr。
vptr 是一个指针(x64 下 8 字节),放在对象偏移 0 处,指向本类的虚表。所以 Shape 只有一个 int 成员,sizeof 却是 16(8 字节 vptr + 4 字节 int + 4 字节 padding),不是 4。
要点 2:虚表按类共享,重写就换表。
虚表是一张函数指针数组,按虚函数声明顺序排列。它是编译期生成的静态数据,同类所有对象指向同一张表。派生类重写了某个虚函数,编译器就为派生类生成一张新表——布局一样,但被重写的表项换成了派生类的函数地址;没重写的表项原样保留。
要点 3:虚调用是一次间接跳转。
普通成员调用是 call 函数地址(编译期定死)。虚调用是:取对象头的 vptr → 从表里取第 N 项 → call 这个表项。运行时查表,所以同一个 p->draw(),p 指向谁就调谁的版本——多态的全部秘密就在这一层间接里。
验证工程
1 | // 虚函数机制(上): vptr 位置, 虚表按类共享, 虚调用是间接跳转. |
预测输出
动手跑之前,我先按三个要点把预期结果推一遍:
- sizeof 都是 16:vptr 8 + int 4 + padding 4。Circle 没有新数据成员,重写虚函数不改变对象大小,只换 vptr 指向的表。
vptr(a) == vptr(b):同类共享虚表,表是静态数据,地址形如00007ff...(在 exe 的只读数据段,和栈、堆地址形态都不同)。vptr(c) != vptr(a):Circle 重写了 draw,编译器给 Circle 单独造了一张表。p->draw()输出Circle::draw(表项被替换),p->area()输出Shape::area(表项保留)。
实测输出
VS2022 x64 Debug 下实际运行:
1 | sizeof(Shape)=16 sizeof(Circle)=16 |
逐行比对
- sizeof 16、16:命中。
vptr(a) == vptr(b) == ...ADC8:命中,同类对象共享同一张虚表。vptr(c) = ...AE08:命中,与 a 不同。两个值只差0x40(64 字节)——两张虚表在只读数据段里挨着放,每张表 2 个函数指针(16 字节)再加上表前的 RTTI 定位信息,两张表正好隔 64 字节。Circle::draw、Shape::area:命中,重写的换、没重写的留。
在反汇编里看间接调用
在 p->draw() 打断点,转到反汇编(Ctrl+K,G),实际看到:
1 | mov rax, qword ptr [p] ; 取出对象地址 |
和我预测的三条相比,多了一条 mov rcx, [p]——调用成员函数前要把 this 装进 rcx(第二篇讲过 x64 前四个参数走 rcx/rdx/r8/r9),我当时漏算了这一步,其余完全一致。核心是最后那条 call qword ptr [rax]:call 的目标不在指令里,要运行时才从表里查出来。
和第二篇 call Point::show(直接调用,地址写死)对比,这一层间接就是虚函数”多态”和”略慢”的共同来源。
小结
1 | vptr: 对象偏移 0 处, 8 字节, 指向本类虚表 |