1. 一个考倒无数人的面试题:虚函数的动态绑定是怎么发生的
很多C++开发者第一次接触“虚函数”这个术语,是在学多态的时候。教科书会告诉你:用virtual修饰的成员函数就是虚函数,通过基类指针或引用调用虚函数时,会发生动态绑定,运行时会根据对象的实际类型调用对应的函数版本。这个解释对不对?对,但远远不够。真正让人头疼的问题是紧接着的那一句——“那它是怎么实现的?”
我记得我给团队做内部技术分享的时候,随手写了一段代码:
cpp复制class Base {
public:
virtual void func() { std::cout << "Base::func" << std::endl; }
int x;
};
class Derived : public Base {
public:
void func() override { std::cout << "Derived::func" << std::endl; }
int y;
};
然后我问底下的同事:“sizeof(Derived)是多少?Derived对象在内存里长什么样?当Base* p = &d; p->func();这一行执行的时候,CPU到底做了什么?”现场安静了很久。有人猜sizeof(Derived)应该是sizeof(int)加sizeof(int),也就是8(假设int是4字节)。实际跑一下,在64位平台上答案是16——两个int加一个隐藏的指针。
这个指针就是虚函数实现技术的核心,也就是面试环节里大家常说的vptr(虚指针)。它指向一张vtable(虚函数表),而这张表,才是动态绑定真正的幕后推手。
这篇博文,我想把C++虚函数的实现技术从头到尾捋一遍,重点讲清楚下面几件事:vptr和vtable的对象模型到底怎么排布;不同的编译器(特别是MSVC和GCC/Clang)在虚表布局上有什么不一样;构造函数和析构函数里调用虚函数为什么是个坑;以及纯度更高的“纯虚函数”在底层是怎么表达的。适合的读者是那种已经会写C++,但没仔细看过对象内存布局的开发者,尤其是准备秋招春招的应届生,或者日常工作里跟多继承、插件接口打过交道的朋友。把这篇看完,你不仅能应付面试,还能在项目里更清楚地判断“该不该用虚函数”以及“用了之后性能代价在哪儿”。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. vptr和vtable的物模型:对象里多出来的那个“隐形指针”
2.1 编译器在幕后生成了哪些东西
先来解答前面那个问题。当你在类里声明了哪怕一个虚函数,编译器就会为这个类悄悄做两件事:
- 生成一张虚函数表(vtable),本质是一个函数指针数组,数组里按一定顺序存放着这个类所有虚函数的地址。
- 在每个对象实例中,自动增加一个虚指针(vptr),这个指针是对象的一部分,隐藏的,你没法直接访问它(某些编译器下可以通过内存偏移摸到,但语言层面它不存在),它会在对象构造时被初始化,指向该类对应的虚表。
你可能会问:一个类有几个虚表?每个对象都要带一个vptr吗?
答案是:vptr是对象级的,每个对象实例里都有一份(想精确一点说,每个多态类对象中至少有一个vptr,多重继承下可能有多个)。而vtable是类级的,同类所有对象共享同一张,不随对象个数增加而膨胀。
还是用代码说话。我写一个最小的多态类,然后直接打印对象的前几个字节,看看内存里到底存了什么:
cpp复制#include <iostream>
class Base {
public:
virtual void f() {}
virtual void g() {}
int a;
};
int main() {
Base b;
int* p = reinterpret_cast<int*>(&b);
for (int i = 0; i < 3; ++i) {
std::cout << "word[" << i << "] = " << p[i] << std::endl;
}
return 0;
}
在64位系统上,Base对象的大小是16字节:vptr占8字节,int占4字节,内存对齐补4字节。前8字节就是vptr,它指向全局只读数据区里的一张虚表。虚表里依次存着&Base::f和&Base::g的地址。Derived的虚表则不同,它存的是&Derived::f和&Base::g——只要Derived没有重写g,表里就还是基类那个g的地址。
2.2 虚表的“作息时间表”类比
想把虚表这个概念讲给完全没有底层经验的人,我最常用的类比是学校教室的作息时间表。
每个班级(类)都贴了一张作息表(虚表),上面写好了每节课(虚函数)对应的时间段(函数地址)。但学生手里拿的是一张校园卡(对象),校园卡里记录了你是哪个班的(vptr指向你的班级对应的作息表)。上课铃响了(发起一次虚函数调用),教务处根据你的卡号查出你是哪个班的,再翻到那张作息表上“这节课”的位置,才知道该去哪个教室。如果你转班了(派生类重写了虚函数),你的卡号还是那一张,但系统翻到新班级的作息表,看到的时间段就跟原来不一样了。
类比的关键点在于:调用方(教务处)不知道也不关心你卡的班级信息,它只负责“拿着卡去查表”。这就是动态绑定——绑定发生的时候,程序已经运行到了那个“上课铃响”的moment,查表行为发生
