图 1:本文封面,从对象里的 vptr 到虚表槽位再到函数地址的一次间接跳转。
先说明属性:这是一篇经典题解析,题目出自本地历史面经资料整理,与任何公司的当前招聘无关。「虚函数的底层实现」是 C++ 面试里被问得最细的题目之一,光说「通过虚表实现多态」远远不够,面试官要听的是布局、槽位和调用开销这三个层面。
布局层:每个对象多一个指针,每个类多一张表。
只要类里声明了虚函数,编译器就会做两件事。第一,给这个类生成一张虚函数表 vtable:一个函数指针数组,按声明顺序给每个虚函数分配固定槽位,表在编译期生成、放在只读数据区,整个类一张,所有对象共享。第二,在每个对象的起始位置安插一个虚表指针 vptr,对象构造时把它指向自己实际类型的那张表。这就是多态的全部物质基础:表是类的,指针是对象的。
调用层:编译期改写,运行期查表。
通过基类指针调用虚函数时,编译器并不知道指针实际指向哪个派生类,于是把 p->foo() 改写成三段动作:从对象起始位置取出 vptr,按 foo 的固定槽位序号从表里取出函数地址,再做一次间接跳转。槽位序号在编译期就固定了,运行期可变的只是「对象里的 vptr 指向哪张表」。派生类重写虚函数时,新函数占据与基类相同的槽位序号——这保证同一偏移在任何子类表上都查得到正确入口;没重写的槽位则原样继承基类的函数地址。多态的本质可以压成一句话:槽位序号编译期固定,表内容运行期随对象而变。
开销层:多了什么,少了什么。
相比直接调用,虚调用多了一次 vptr 解引用和一次间接跳转,更要命的是阻断编译器内联——跳转目标运行期才确定,函数体无法嵌入调用点。对象体积上,每个对象多付一个指针(64 位平台 8 字节),哪怕只有一个虚函数也一样。还有两个高频追问:构造函数和析构函数执行期间,vptr 指向「当前正在构造的层级」的表,此时调虚函数不会发生你期待的多态;静态成员函数没有 this 指针、不经过对象,自然进不了虚表。
用代码把布局扒出来看。
下面的程序用 Clang 编译运行,直接观察 sizeof 变化、动态绑定结果,并把对象起始 8 字节的 vptr 和虚表槽位地址打出来对照(clang++ -std=c++17 vtable.cpp && ./a.out):
#include <cstdio>
struct Base {
virtual void foo() { std::puts("Base::foo"); }
virtual void bar() { std::puts("Base::bar"); }
virtual ~Base() = default;
int x = 0;
};
struct Derived : Base {
void foo() override { std::puts("Derived::foo"); } // 重写槽 0
};
struct NoVirt { int x = 0; };
int main() {
std::printf("sizeof(NoVirt) = %zu\n", sizeof(NoVirt));
std::printf("sizeof(Base) = %zu\n", sizeof(Base));
Base b; Derived d;
Base* p = &d;
p->foo(); // 查 Derived 的表,槽 0
p->bar(); // 槽 1 未重写,原样继承
void** vtB = *reinterpret_cast<void***>(&b); // Base 的 vtable
void** vtD = *reinterpret_cast<void***>(&d); // Derived 的 vtable
std::printf("两对象的 vptr 相同吗: %s\n", vtB == vtD ? "相同" : "不同");
std::printf("槽0(foo)被覆盖: %s\n", vtB[0] != vtD[0] ? "是" : "否");
std::printf("槽1(bar)原样继承: %s\n", vtB[1] == vtD[1] ? "是" : "否");
}
实测输出:
sizeof(NoVirt) = 4
sizeof(Base) = 16
Derived::foo
Base::bar
两对象的 vptr 相同吗: 不同
槽0(foo)被覆盖: 是
槽1(bar)原样继承: 是
没有虚函数时对象只有 4 字节的一个 int;加了虚函数变成 16 字节(8 字节 vptr 加 4 字节 int 加 4 字节对齐填充)。Base 指针指向 Derived 对象时,foo 跳到派生类实现、bar 落到基类实现,槽位地址对比证实:重写的槽换了内容,没重写的槽两个表指向同一个函数。需要说明,虚表布局属于编译器实现细节而非语言标准条文,但 GCC 与 Clang 等通行实现都遵循这套模型。
图 2:基类与派生类虚表槽位对比:覆盖换地址,未覆盖原样继承。
答题的主线与加分项。
建议按「布局—调用—开销」三段推进:先讲 vptr 与 vtable 各归谁所有,再讲一次虚调用的三步改写与槽位不变的覆盖规则,最后主动谈代价(一次间接跳转、阻断内联、对象多 8 字节)和两个陷阱(构造析构期间多态失效、静态函数不进虚表)。能讲到「槽位序号编译期固定、表内容运行期可变」这句话,说明理解的颗粒度到位了。你被追问过虚继承的虚表布局吗?那又是另一层故事了,欢迎评论区接着聊。