如果你问我C++里哪个概念最“背得出原理却讲不明白”,多态一定排前三。上周群里有人贴了一张sizeof截图:一个带两个int的类,加了一个virtual关键字,从8字节变成16字节。有人下意识说出“因为多了虚函数表指针”,但再往下问——这个指针指向哪里?虚函数表里到底存了什么东西?多继承时为什么会出现两个指针?他就答不上来了。这篇文章就是要把这几层一次性拆透。我会用可编译的例子、GDB、以及直接打印对象内存的方式,把C++多态从调用规则讲到字节级的内存布局,覆盖单继承、多继承、构造析构期间的vptr切换,以及虚函数调用真正的性能成本。如果你正在准备面试,或者被线上偶发崩溃折磨,应该都能找到点有用的东西。
1. 为什么说多态是一种“延迟绑定”:从静态调用到间接寻址
1.1 编译期写死地址和运行期“查表”的差别
普通成员函数,比如 obj.run(),编译期会根据 obj 的静态类型直接算出函数地址,生成的指令通常是 call Base::run。虚函数就不同:你在 Base* 上写 p->run(),编译器不会直接把 run 的地址写死,因为 p 指向的究竟是 Base 还是 Derived,编译期间无法确定。它只能退一步,让程序在运行期从对象的内存里取出某个隐藏指针,再通过这个指针找到真正该调用的函数。
这个决策顺序,就是“绑定时机”的差别。函数重载在编译期就按参数类型选定了版本;虚函数覆盖要到运行期根据对象动态类型决定。C++ 的 virtual 关键字,本质上是请求编译器在自己身上开一个“间接层”:把直接调用的地址替换成一次查表、一次间接跳转。
1.2 这层间接是怎么被拨通的:vptr 与 vtable 的分工
对象的隐藏指针通常叫 vptr,指向一张“表”,这张表就是 vtable——本质上是一个函数指针数组。每个有虚函数的类在编译期都会生成自己的 vtable,里面按固定顺序排着当前类实际生效的各虚函数地址。当对象被构造时,vptr 会被赋值为当前动态类型对应的 vtable 地址。
调用 p->run() 时,编译器生成的伪代码大致是:
text复制load vptr from p
load slot[0] from vptr
call slot[0]
所以“多态的魔法”就是两级间接:vptr 定位到表,表定位到函数。标准没有规定 vptr 叫什么、占多大、放在哪儿,但在常见的 Itanium C++ ABI 和 MSVC 对象模型里,vptr 就是对象开头一个指针大小的字段,vtable 是链接产物里的只读数据。
1.3 静态类型和动态类型:先搞清楚这个再看内存
Base* p = &derived; 中 p 的静态类型是 Base*,动态类型是 Derived。普通调用按静态类型决定,虚调用按动态类型决定。这个概念很多人背得滚瓜烂熟,但一遇到“构造函数里调虚函数”就翻车。原因恰恰是动态类型在构造和析构过程中会“来回变”,后面第4章专门讲。
因为单继承的对象布局就是“基类子对象在最前面,派生类在基类后面拼接”,Base* 指向 Derived 时,地址数值和 Derived* 是相等的。一旦进入多继承,你会发现“相等”这条路走不通了,于是就有了第3章里那些调偏移的戏码。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 单继承下的字节级拆解:vptr 藏在你对象的哪个位置
2.1 sizeof 为什么会变大:从8字节到16字节的账
cpp复制#include <cstdio>
class Base {
public:
virtual void f() { std::puts("Base::f"); }
virtual void g() { std::puts("Base::g"); }
private:
int m_a = 1;
};
class Derived : public Base {
public:
void f() override { std::puts("Derived::f"); }
void h() { std::puts("Derived::h"); }
private:
int m_b = 2;
};
我故意没写虚析构函数,是为了让 vtable 先只有 f 和 g 两个槽位,打印起来更直观。工程代码里,基类析构函数一定要补 virtual。
sizeof(Base) 会输出多少,取决于 ABI。以常见64位平台为例:vptr 是 8 字节,加上 int m_a 4 字节,按 8 字节对齐,实际占用 16 字节。Derived 比 Base 多一个 int m_b,但 8 + 4 + 4 正好 16,所以 Derived 也是 16 字节。你可以用下面这段代码打印对象开头好几个字节:
cpp复制template <typename T>
void dump(const T& obj) {
auto* p = reinterpret_cast<const unsigned char*>(&obj);
for (size_t i = 0; i < sizeof(T); ++i) {
std::printf("%02x ", p[i]);
}
std::puts("");
}
如果跑起来,留心第一行的前8个字节,那就是 vptr 的内存值。单继承、非虚继承时,它通常在对象偏移0处。
注意:C++ 标准从未规定 vptr 必须存在,也没规定它放在对象的开头。上面的描述是 Itanium C++ ABI 和 MSVC 对象模型在主流平台上的实现,线上代码依赖的恰恰就是这套约定。
2.2 把 vtable 里的槽位打印出来
想真正“看见”表中存了什么,可以先取出 vptr,把它当成 uintptr_t* 数组:
cpp复制using Fn = void(*)();
auto vptr = *reinterpret_cast<void**>(&base); // 拿到 vptr
auto slots = reinterpret_cast<uintptr_t*>(vptr);
for (int i = 0; i < 2; ++i) {
auto fn = reinterpret_cast<Fn>(slots[i]);
fn();
}
这会依次打印 Base::f 和 Base::g。槽位0对应 f,槽位1对应 g,顺序就是类中虚函数声明顺序。派生类覆盖了 f,但没覆盖 g,所以槽位0在 Derived 里变成 Derived::f,槽位1仍然是 Base::g。新增虚函数 h 会接在已有槽位后面。
这里要特别提醒:上面 reinterpret_cast<Fn>(slots[i]) 这类写法只是观察用,把一个整数强转成函数指针并不是标准规定的可移植行为,正式项目别这么写。看 ABI 布局可以这样折腾,写业务代码要避开。
2.3 派生类的 vtable 到底“继承”了什么
每个有虚函数的类都有自己独立的 vtable。Derived 的 vtable 并不是“复制” Base 的,而是在编译期重新生成一张表:被覆盖函数的槽位写入新地址,没被覆盖的槽位仍写 Base 的函数地址,新增虚函数追加到尾部。这就是为什么同一个虚函数在 Base* 上调用,运行时会落到不同实现。
只要 Base 里 vptr 在偏移0,Derived 的 vptr 也在偏移0,所以父类指针无需任何额外调整就能复用同一套取表逻辑。这也是单继承“优雅”的地方。后面你会看到,多继承想做到这点就难了。
3. 多继承的真实内存:两个 vptr 和那些恶心的 this 调整
3.1 一个多继承对象的内存排布
cpp复制struct A {
virtual void fa();
int x;
};
struct B {
virtual void fb();
int y;
};
struct D : A, B {
void fa() override;
void fb() override;
int z;
};
D 的对象内存大体是这样:先完整放一个 A 子对象(vptrA + x),紧挨着放 B 子对象(vptrB + y),最后才是 D 自己的 z。因为 A 和 B 各自有虚函数,D 继承了它们,于是不止一个 vptr。在这样的布局下,D* pd = &d 的指针值等于 A* pa = pd,但 B* pb = pd 就不同了——pb 必须指向 D 里的 B 子对象,地址要相对 D 起始位置后移一个 A 子对象的距离,通常是16字节(vptrA 8字节 + x 4字节 + 对齐)。
这就带来一个很直接的问题:当编译器看到一个 B* 指向 D 时,这个指针的地址几乎“不像”D 的地址。如果你在 B 的虚函数调用里取 this,看到的 B* 和真正 D* 不是同一个数值,必须做一次指针调整才能让 D::fb() 正常运行。
3.2 thunk:那个在虚表里帮你“搬指针”的跳板
假设 D 覆盖了 B 的 fb()。B 的 vtable 里槽位对应 D::fb()。但是,pb->fb() 进入 D::fb() 时,this 当前是 B*,而 D::fb() 期望的 this 是 D*。编译器不能直接把 D::fb 的普通入口放进去,否则 this 就错了。
所以编译器生成一个很小的“垫片”,通常叫 thunk。这个 thunk 的职责是:先把 this 从 B* 调整为 D*(说人话就是 this 减去16字节),然后跳转到真正的 D::fb()。反汇编看起来类似:
asm复制sub rdi, 16
jmp D::fb()
这里 rdi 是 Itanium ABI 下成员函数传 this 的寄存器。在第二个基类 vtable 里,槽位指向的是这个 thunk,而不是 D::fb() 本体。
这也是很多人面试“多继承为什么慢”时答不出的点。不是说虚调用本身多几条汇编就完了,而是多继承下的 this 调整、多个 vptr、以及编译器可能生成的多个 thunk,都让对象模型复杂一个量级。
3.3 dynamic_cast 怎么在这种布局里找到最派生类
有了上述偏移调整,从 B* 安全转换回 D* 不再是一个偏移搞定的事。dynamic_cast<D*>(pb) 需要借助 RTTI,先找到最派生类,再根据类型信息判断能否转换、偏移多少。这也是为什么 dynamic_cast 走起来比 static_cast 慢不少,因为它的背后有一条运行时类型遍历链。
工程上的建议是:能不用多继承就不用,尤其是大量动态转换的场景。非要继承时,尽量把“具有虚函数”的边控制在单继承上,附带的多继承只放不带虚函数、纯粹作为接口聚合的类,减少 thunk 和 RTTI 的复杂度。这里如果涉及虚继承,偏移计算还会再多一层间接,情况更复杂,绝大多数业务代码用到它的概率不高,等真踩到再单独研究也不迟。
4. 构造和析构期间的那点“不虚”:vptr 切换与经典坑
4.1 vptr 什么时候被赋值
很多人以为 vptr 只在“构造完”时赋值一次。实际上,从基类到派生类的构造过程中,vptr 会被赋多次。构造 Derived 对象时流程大致是:
- 先进入
Base构造函数。此刻对象的动态类型在 C++ 语义上还是Base,vptr 被赋值为Base的 vtable。 - 依次初始化成员变量和基类子对象。
- 进入
Derived构造函数体之前,vptr 才被切换成Derived的 vtable。 - 执行
Derived构造函数体。
析构方向相反:先执行 Derived 析构体,再把 vptr 切回 Base 的 vtable,然后执行 Base 析构体。之所以这样设计,是保证构造和析构期间,一旦调用虚函数,调用到的一定是“当前正在构造/析构的那个类”的版本,不会向下访问到还没构造或已经析构的成员。
4.2 构造函数里的虚调用为什么不是多态
cpp复制class Base {
public:
Base() { init(); }
virtual void init() { std::puts("Base::init"); }
};
class Derived : public Base {
public:
void init() override { std::puts("Derived::init"); }
};
int main() {
Derived d; // 打印 Base::init
}
这段代码不会按很多新手的预期打印 Derived::init。因为在 Base 构造函数执行时,vptr 还指着 Base 的 vtable,调用 init 查出来就是 Base::init。这不算“多态失效”,而是标准明确规定的行为:构造/析构期间,对象的动态类型被视为当前正在构造/析构的类型。
这个坑在真实项目里经常以“基类构造函数调用了 virtual init()”的形式出现,尤其在异步初始化、配置加载的场景。预期是子类 override 后自动生效,实际是基类版本被悄悄调用,很难排查。我见过不止一次线上配置没生效,最后定位到是基类构造函数里的虚调用。
4.3 实用规避法:别在构造/析构里等待多态
正确姿势是:
- 不要在构造函数和析构函数里调用虚函数,尤其不要让它们在派生类里被覆盖后依赖多态行为。
- 如果一定想“初始化时调 override”,把初始化拆到构造完成之后:写一个非虚的
init()接口,由外部在构造完对象后调用,或者通过工厂函数构造完再初始化。 - 如果要给子类留自定义点,用 NVI(非虚接口)惯用法:公开非虚接口,内部调用受保护的虚函数。这样外层的调用时机可以由你控制,但核心原则仍是“动态分派发生在对象完全构造之后”。
cpp复制class Base {
public:
void setup() { doSetup(); }
protected:
virtual void doSetup() {}
};
这个模式下,外部调用 setup() 时对象已构造完成,vptr 指向最终动态类型,doSetup() 才能走真正的多态。
5. 虚函数调用到底慢在哪:成本、优化和替代方案
5.1 一次虚调用的执行代价
在优化关闭时,一次虚调用大概是这样:先从 this 加载 vptr,再从 vptr 偏移处加载函数指针,最后间接调用。比普通直接调用的确多了一两次内存访问和一次间接跳转。但因为现代 CPU 的分支预测和缓存对这类跳转有一定吸收能力,单次调用的绝对开销通常不大。真正的成本是“编译器看不见目标函数”。
一旦编译器在编译期无法确定调用目标,它就不能内联、不能做常量传播、不能跨函数优化。在热循环里,本来一个能被内联成几行代码的函数,被强制变成一次未知跳转,连同周边代码的优化空间也一起丢掉了。所以“
