虚函数表:面试深挖时的杀手锏,懂这个直接拉开差距

📅 2026/7/15 4:59:56
虚函数表:面试深挖时的杀手锏,懂这个直接拉开差距
上篇聊了纯虚函数和抽象类今天聊一个面试深挖时才会碰到的话题——虚函数表。很多人知道虚函数知道用virtual声明知道能实现多态。但面试官如果追问底层怎么实现的大部分人就开始含糊了。如果你能把虚函数表讲清楚面试官会觉得你不是停留在会用的层面而是真正理解了C的底层机制。虚函数表到底是什么先说结论每个有虚函数的类编译器都会生成一张虚函数表vtable。表里存的是这个类所有虚函数的地址。每个有虚函数的对象编译器会在对象内存里塞一个隐藏指针vptr指向它所属类的vtable。用图来理解最直观LidarSensor对象内存 ┌────────────────────┐ │ vptr ──────────────┼──→ LidarSensor的vtable ├────────────────────┤ ┌──────────────────────┐ │ distance_ │ │ LidarSensor::init │ │ angle_ │ │ LidarSensor::calibrate│ └────────────────────┘ │ LidarSensor::getValue│ └──────────────────────┘当你通过基类指针调用虚函数时程序会通过对象的vptr找到vtable在vtable里查找对应函数的地址调用那个地址这就是动态绑定的全过程。编译时不知道具体调用哪个函数运行时通过查表确定。vptr什么时候初始化vptr在构造函数里初始化。具体来说是在构造函数的最开始编译器自动插入代码把vptr指向当前类的vtable。这个细节很重要因为它解释了为什么构造函数里调用虚函数不会有多态行为class Base { public: Base() { // 编译器在这里插入: vptr Base::vtable func(); // 此时vptr指向Base的vtable调用的是Base::func } virtual void func() { cout Base endl; } }; class Derived : public Base { public: Derived() { // 编译器在这里插入: vptr Derived::vtable // 现在vptr才指向Derived的vtable } void func() override { cout Derived endl; } }; Derived d; // 构造顺序先Base()再Derived() // Base()执行时vptr指向Base的vtablefunc()输出Base // Derived()执行时vptr指向Derived的vtable析构函数也是类似的道理。析构时先执行子类的析构函数再执行父类的。在父类析构函数执行时vptr已经指回父类的vtable了所以虚函数调用也没有多态行为。sizeof能看到vptr吗可以。vptr是一个指针在64位系统上占8字节。class NoVirtual { int x; }; class HasVirtual { int x; virtual void func() {} }; cout sizeof(NoVirtual) endl; // 输出: 4只有int cout sizeof(HasVirtual) endl; // 输出: 164字节int 4字节对齐 8字节vptr面试的时候如果面试官问有虚函数的对象比没有虚函数的对象大多少答案就是多了一个vptr的大小64位系统上是8字节。这也解释了为什么在机器人开发里对于高频创建的小对象比如点云里的每个点一般不加虚函数。几百万个点每个点多8字节就是几千万字节的额外开销。继承中的vtable子类继承父类时子类的vtable会包含父类的所有虚函数地址。如果子类重写了某个虚函数vtable里对应位置就换成子类的函数地址。class Base { public: virtual void func1() {} virtual void func2() {} }; class Derived : public Base { public: void func1() override {} // 重写 // func2没重写沿用Base的 }; // Base的vtable: [Base::func1, Base::func2] // Derived的vtable: [Derived::func1, Base::func2]如果子类新增了虚函数就追加在vtable后面class Derived : public Base { public: void func1() override {} virtual void func3() {} // 新增 }; // Derived的vtable: [Derived::func1, Base::func2, Derived::func3]这个机制保证了通过基类指针调用时vtable里一定能找到对应位置的函数。多重继承的vtable多重继承的情况下子类会有多个vptr每个基类一个class A { virtual void funcA() {} }; class B { virtual void funcB() {} }; class C : public A, public B { virtual void funcA() override {} virtual void funcB() override {} }; // C的对象布局 // ┌──────────────┐ // │ vptr_A ──────┼──→ C的vtable_A: [C::funcA] // ├──────────────┤ // │ vptr_B ──────┼──→ C的vtable_B: [C::funcB] // ├──────────────┤ // │ 成员变量 │ // └──────────────┘两个vptr对象多占16字节。这也是为什么多重继承在性能上不太受欢迎。面试中的虚函数表深挖题面试官深挖的时候可能问这些问题虚函数调用比普通函数调用慢多少慢在一次间接寻址。普通函数调用是直接跳转到固定地址虚函数调用要先读vptr再查vtable再跳转。多了一次内存访问。在现代CPU上如果vtable在缓存里这个开销很小一两个时钟周期。但如果vtable不在缓存里可能会有cache miss的开销。能不能不用vtable实现多态可以。C里有一种技术叫CRTPCuriously Recurring Template Pattern用模板实现静态多态编译时就确定了调用哪个函数没有vtable的开销。这个后面会专门讲。虚函数表存在哪里vtable一般存在只读数据段.rodata是全局的每个类一份。vptr存在对象内部每个对象一份。再聊一个面试深挖时会问到的话题多继承下的虚函数表布局。如果一个类继承了两个有虚函数的基类对象里会有几个vptr——每个基类各一个。这意味着通过不同基类指针访问同一个对象时vptr的偏移量是不同的。你可以用offsetof或者调试器来验证这一点。在实际调试中如果你遇到多继承导致的类型转换后地址不对齐问题GDB里可以用info vtbl命令直接查看对象的虚函数表内容非常直观。另外还有个实用技巧在Linux下用nm命令查看编译后的目标文件可以看到每个类的vtable符号名格式一般是_ZTV类名通过它你能确认虚函数是否被正确导出。这些底层调试技能在面试中很少被直接问到但如果你主动提出来面试官对你的评价会直接上一个档次。给正在准备面试的你一点建议虚函数表不是面试必考的但如果你能讲清楚绝对是加分项。核心要记住三点vtable是每个类一份的函数地址表vptr是每个对象一份的隐藏指针动态绑定就是通过vptr查vtable找到实际函数地址的过程。如果面试官没问你主动提一句虚函数底层其实是通过vtable来实现的会让面试官觉得你的C功底确实不一般。如果这篇文章对你有帮助欢迎点赞、在看、转发三连。 你的支持是我持续更新的最大动力。「机器人软件开发面试·从入门到精通」连载系列上一篇第23篇 纯虚函数与抽象类——接口设计的C实现 下一篇预告第25篇 友元与运算符重载——让自定义类型好用的秘密有任何问题欢迎评论区留言我会尽量回复。