深蓝黑背景封面。左侧是一条运行时调用链图:最左卡片「对象内存布局」内部第一格以紫色高亮标注 vptr,其余格标注「成员变量」;vptr 引出紫色箭头指向中间的「虚函数表 vtable」,表内三个槽位分别标注「槽 0:Derived::foo」「槽 1:Base::bar」「槽 2:Derived::baz」,其中槽 0 以青色高亮;槽 0 再引出青色箭头指向最右卡片「函数代码段地址」,注「编译期把 base->foo() 改写成查表间接跳转」。底部一行注「指针类型只决定查哪张表,对象里的 vptr 决定最终跳到哪个函数体」。右侧大标题「虚函数表是怎么支撑运行时多态的」,副标题「vptr + vtable:一次间接跳转完成动态绑定」。左上标注「面试经典 · C/C++」,右上圆角胶囊标注「Day 01 · 11-18」。 图 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 等通行实现都遵循这套模型。

深蓝黑背景概念图。主体是上下两张虚表对比:上方「Base 的 vtable」三个槽位依次为 Base::foo、Base::bar、Base::baz;下方「Derived 的 vtable」三个槽位依次为 Derived::foo、Base::bar、Derived::baz,其中发生覆盖的槽 0 与槽 2 以青色高亮并标注「重写覆盖」,未重写的槽 1 以灰色标注「原样继承」,两表之间用虚线箭头标出槽位一一对应、偏移不变。左下卡片「布局三规则」:vptr 位于对象起始位置;虚表按类一张、编译期生成、存于只读数据区;派生类重写的函数占据与基类相同的槽位序号,保证同一偏移可查。右下卡片「调用开销」:一次 vptr 解引用加一次槽位取址加一次间接跳转,相比直接调用多了内存间接层且阻断编译器内联,构造与析构期间 vptr 指向当前构造层级的表、多态失效。底部总结条:多态的本质是「槽位序号编译期固定、表内容运行期随对象而变」。 图 2:基类与派生类虚表槽位对比:覆盖换地址,未覆盖原样继承。

答题的主线与加分项。

建议按「布局—调用—开销」三段推进:先讲 vptr 与 vtable 各归谁所有,再讲一次虚调用的三步改写与槽位不变的覆盖规则,最后主动谈代价(一次间接跳转、阻断内联、对象多 8 字节)和两个陷阱(构造析构期间多态失效、静态函数不进虚表)。能讲到「槽位序号编译期固定、表内容运行期可变」这句话,说明理解的颗粒度到位了。你被追问过虚继承的虚表布局吗?那又是另一层故事了,欢迎评论区接着聊。