虚函数进阶答疑:把上一篇博客评论区里最容易卡住的问题,一次追到底

0 阅读17分钟

这篇文章不是“虚函数基础知识”的重讲,而是我上一篇虚函数入门博客的评论区答疑与进阶复盘

上一篇我主要做了 5 件事:先把虚函数的地图画出来,再去 VS 里看虚调用指令,再去 IDA 里把 vtable / vptr / RTTI 直接扒出来确认,最后把构造/析构虚调用和 dynamic_cast 的基本闭环跑通。

但写完之后,评论区里真正把我往前推了一步的,不是“virtual 是什么”这种题,而是这些更深的问题:

  • 构造/析构里为什么只能按当前层分派?
  • vptr 到底是什么时候写进去、什么时候回退的?
  • 构造/析构里的虚调用,底层到底还走不走“查表”?
  • vtable 为什么通常放只读区?
  • 虚调用对 cache 的影响到底该怎么说,才不空泛?
  • 为什么 Google C++ Style 明确不鼓励 RTTI?
  • 为什么成员函数模板不能是虚函数?
  • vtable 到底是在编译期、链接期还是运行期确定的?

所以这一篇,我不再按“入门概念”写,而是按“评论区问题 → 我的修正理解 → 我怎么把它和反汇编/IDA 对上”来写。

整篇我尽量保持一个固定节奏:

先给结论 → 再讲为什么 → 再讲我现在怎么验证和怎么记。


1. 构造/析构期间虚调用为什么只按“当前层”分派

这个问题是我觉得最值得彻底讲透的。

C++ 标准在 [class.cdtor] 里写得很明确:
如果一个虚函数是在构造函数或析构函数里被调用,而且调用对象正是这个“正在构造或析构的对象”,那么被调用的不是更派生类里的 override,而是“当前这个构造函数/析构函数所属类”中的 final overrider。 也就是说,在 Base::Base()Base::~Base() 里发起的这种虚调用,不会越级跑到 Derived::f() 去。

我现在对这件事的理解,不再是“背规则”,而是先抓住它背后的对象生命周期:

  • 构造时:派生部分还没真正构造完成;
  • 析构时:派生部分已经先被销毁掉了。

所以如果在 Base 阶段还去调用 Derived 版本,就可能读到未初始化成员,或者访问已经析构的资源。标准同一节其实也强调了:在构造开始前去引用对象成员/基类,或者在析构完成后再去引用,都是未定义行为;这正好和“为什么不能越级分派”是同一条逻辑。

所以我现在会把这个问题压成一句很短的话:

构造时,派生部分还没活;析构时,派生部分已经先死了。
因此虚调用只能按当前层分派。

这句话是这篇文章后面很多问题的总前提。


2. vptr 到底什么时候初始化?析构时又什么时候“回退”?

这个问题如果只从语言层面说,很容易抽象;但一旦和反汇编连起来,就会立刻具体起来。

我现在会先把“反初始化”这个词换掉。更准确的说法不是“析构时把 vptr 反初始化/清零”,而是:

随着构造/析构链推进,vptr 会被分阶段写成当前层对应的 vtable。

在构造阶段,典型过程是:

  1. 进入 Derived::Derived
  2. 先调用 Base::Base
  3. Base::Base 开头附近把对象头里的 vptr 写成 Base 的 vtable;
  4. Base::Base 返回后;
  5. 回到 Derived::Derived,再把同一个位置改写成 Derived 的 vtable。

Itanium ABI 在“对象构造期间的虚表”一节说得非常直白:对象在构造各个 proper base subobject 时,会“暂时表现成”那个 base;通常这个行为就是通过在 base 构造函数里,把对象的 virtual table pointer 设到该 base 视角对应的 virtual table 来实现的。遇到虚继承时,还可能用到 construction virtual table 和 VTT。

而在析构阶段,我现在的答案是:

不是最后统一清零,而是“析构到哪一层,就把对象视角回退到哪一层”。

微软 __declspec(novtable) 的文档,反过来把这件事说得很清楚:这个属性会阻止编译器在类的构造函数和析构函数里生成初始化 vfptr 的代码。换句话说,正常情况下,MSVC 本来就会在 ctor/dtor 里做和 vfptr 相关的初始化/切换工作。

这也是为什么我现在不再把“构造/析构按当前层分派”和“vptr 改写时机”当成两个孤立知识点。它们其实是一件事的两面:

  • 语言规则:当前层分派;
  • 常见实现:构造/析构过程中分阶段改写 vptr

3. 构造/析构里调用虚函数,底层一定还走“查虚表 → 间接 call”吗?

这个问题我一开始想得太机械了,总觉得“既然是虚函数,就一定要查表”。后来我才把“语义”和“机器码实现”区分开。

标准真正规定的是:

构造/析构里这次虚调用,语义上该落到哪一个版本。

它规定的是“当前层的 final overrider”,不是“机器码必须先读 vptr 再查表再间接跳”。

所以在很多简单场景下,编译器完全可以直接生成:

call Base::f

或者:

call Derived::f

而不必再绕一次完整的“虚调用路径”。因为在构造/析构体里,当前层该调谁这件事,语义上已经确定了。

所以我现在对这个问题的回答是:

两种实现思路理论上都可以,但在简单场景里,编译器常常会直接 call 当前层实现。

这里我会特地补一句,免得说太死:

“常见地会直接 call”不等于“标准强制必须直接 call”。

复杂继承、虚继承、construction vtable 这些情况,编译器仍然可能借助更复杂的表机制来保证当前层语义。Itanium ABI 专门有 construction virtual table 这一节,就是在处理“正常 vtable 不足以正确服务构造/析构期对象模型”的那些情况。

所以这个问题我现在记成一句话:

构造/析构里的虚调用,关键不是“必须怎么 call”,而是“该调用谁已经被语义提前定死了”。


4. 为什么现实里 vtable 通常放在只读区,而不是代码段?

这个问题很容易一上来就答成“因为不能被篡改”,但那只是答案的一部分。

我现在会先纠正一个前提:

C++ 标准并没有规定必须有 vtable,更没有规定它必须在哪个段。

“vtable 在哪儿”是 ABI 和编译器实现问题,不是语言标准本身的硬性规则。Itanium ABI 只规定了虚表里会有哪些组成项、怎么布局,并没有把“必须在 .rodata”写成语言条文。

那为什么现实里它通常放只读区,或者至少是“重定位后只读”的区域?

因为:

第一,它本质上更像“数据表”,不是代码

Itanium ABI 写得很清楚,vtable 里不只有函数入口,还可能有:

  • vcall offsets
  • vbase offsets
  • offset-to-top
  • RTTI 信息
  • 虚函数指针或调整 thunk 入口

而且它还专门区分了“address point”和“整个虚表起始位置”——对象里的 vptr 指向的并不一定是整张表开头,而是某个地址点。

这说明它本质上不是“一段代码”,而是一份类级元数据表

第二,它通常在运行时主要是只读的

同一个类的大量对象会共享同一份 vtable。既然它是共享元数据,而且运行时主要做读取,不做普通业务意义上的修改,那放到只读区就最自然。

第三,只读更安全

这个是你一开始直觉里抓到的部分:
如果 vtable 可写,那么虚调用目标就更容易被内存破坏或恶意篡改。放在只读区,至少从权限模型上更合理。

所以我现在会把这个问题压成一句特别顺的话:

vtable 与其说是一段代码,不如说是一份类级只读元数据表。

这句话一成立,“为什么更适合放只读区”就顺了。


5. 虚调用到底怎么影响性能?“因为运行时调用所以无法缓存”对吗?

我现在觉得这个问题最容易答空,或者答错。

我一开始的直觉也是:

“虚函数是运行时调用,所以 cache 命中是不是会更低?”

但后来我把这个说法修正成了:

虚函数不是“不能被 cache”,而是它比普通直接调用多了一条访存链路,而且更难被优化器和 CPU 前端处理。

LLVM 关于去虚化的资料里直接把 virtual call 写成了这种形态:

  • 先 load vtable
  • 再 load 虚函数入口
  • 再 call 这个入口

而且它明确说了:去虚化很重要,因为更多内联机会,同时indirect calls are harder to predict。GCC 的优化文档也说明了,编译器会为了更激进的 devirtualization 打开额外信息和优化通路。

所以我现在会把性能问题拆成三层说:

1. 不是“缓存不了”

对象里的 vptr 可以被缓存,vtable 也可以被缓存,目标函数指令照样可以进 I-cache。
所以“运行时调用所以无法缓存”这个因果关系并不成立。

2. 多出来的是额外访存链路

普通直接调用更接近“目标地址已知”;
虚调用通常要经历:

  • 对象 → vptr
  • vptr → vtable 槽位
  • 槽位 → 目标函数地址

这会增加依赖性 load,可能带来额外的 D-cache 压力,但不是“一定命中率暴跌”。

3. 更大的问题常常是“难预测、难内联”

真正更常见的性能损失往往来自:

  • 间接调用更难预测
  • 编译器更难做去虚化
  • 一旦不能去虚化,后面的内联和跨过程优化机会也少很多

所以我现在对这个问题的最短回答是:

虚函数的代价不在于“不能缓存”,而在于“多绕了一层去找目标函数,而且更难预测、更难内联”。


6. 为什么 Google C++ Style 明确不鼓励 RTTI?

这个问题我现在不会再答成“因为 RTTI 不优雅”这么虚了。

Google C++ Style Guide 在 RTTI 一节的态度非常直接:
Avoid using run-time type information (RTTI).
它接着还写了一句我觉得特别关键的话:

在运行时查询对象类型,往往意味着类层次设计有问题。

原文甚至说得更重一些:这通常暗示 class hierarchy 的设计是 flawed 的;而无约束地使用 RTTI,会让代码到处长出“基于类型的 decision tree / switch”,最终导致维护困难。Google 同时也给了替代建议:优先用虚函数;如果逻辑本来就不该放在对象内部,可以考虑 double dispatch / Visitor。

所以我现在对 RTTI 的理解是:

它不是原罪,但它经常像“多态设计没收住时的补丁”。

这句话比“语言设计不完美”更接近工程现实。

我现在会这样概括:

  • 少量 RTTI:可能是现实折中;
  • 到处 dynamic_cast / typeid:往往说明原本该进多态接口的行为,被丢到了对象外部做按类型分支。

因此这个问题我现在的答法是:

Google 不鼓励 RTTI,不是因为它语法难看,而是因为它很容易暴露出类层次设计、可维护性和可扩展性的问题。


7. 为什么成员函数模板不能是虚函数?

这是我后来特别喜欢的一道题,因为它很能区分“表面理解”和“本质理解”。

标准在 [temp.mem] 里直接规定了两件事:

  • 成员函数模板不能声明为 virtual;
  • 成员函数模板的特化也不会 override 基类虚函数。

我一开始的说法是:

“模板偏编译期,虚函数偏运行期,所以不能一起用。”

这句话不算错,但不够本质。

更本质的说法是:

虚函数机制要求先有一个“固定接口”和“固定槽位”;而成员函数模板在实例化前不是一个固定函数,而是一族未来才展开出来的函数。

虚函数为什么能 override?
因为基类先给出一个确定签名,派生类再用相同签名去覆盖它,这样编译器才能在 vtable 里给这个接口安排固定槽位。

而模板函数在实例化前没有唯一签名:

  • f<int>
  • f<double>
  • f<std::string>

这些是不同实例,不是“同一个函数的不同运行时版本”。

所以真正冲突的不是“一个编译期、一个运行期”这么表层,而是:

虚函数要的是“固定接口”;模板函数给的是“函数族”。

没有唯一签名,就没有稳定 override 关系;没有稳定 override 关系,就没法给它安排确定的 vtable 槽位。

所以我现在会把这个问题记成一句很顺的话:

“编译期 vs 运行期”是表象,“固定接口 vs 函数族”才是本质。


8. vtable 到底是在编译期、链接期还是运行期确定的?

这个问题我现在觉得,最重要的是把“确定”拆开。

因为“确定”至少有三层意思:

第一层:编译期,确定“它长什么样”

编译器看到类定义、继承关系、override 关系后,会决定:

  • 这个类需不需要 vtable;
  • 哪些槽位来自基类;
  • 哪些被当前类覆盖;
  • 是否需要 thunk;
  • 表里还有没有 RTTI、offset 等信息。

Itanium ABI 明确说明了,虚表的 address point、offset-to-top、RTTI 字段、虚函数入口这些东西,都是虚表布局的一部分。

第二层:链接期,确定“最终由谁来正式提供”

GCC 的 Vague Linkage 文档把 vtable 放进一个很重要的类别里:
有些 C++ 实体在多个翻译单元里都可能出现候选定义,但最终程序里只保留一份。对 vtable 来说,如果类有非 inline、非 pure 的虚函数,GCC 会选第一个这样的函数作为 key method,并把 vtable 只发射到它定义所在的翻译单元;type_info 对象也会和 vtable 一起写出来,以支持 dynamic_casttypeid

这也是为什么我后来终于想明白了“链接阶段到底在做什么”:

链接器不是在“设计 vtable 布局”,而是在“决定最终保留哪一份、把里面相关地址都收尾好”。

第三层:运行期,确定“对象现在该指向哪张表”

运行期一般不是现算一张全新 vtable,而是:

  • 对象构造时把 vptr 写到当前层对应的那张表上;
  • 析构时再随着析构链推进,回退到当前层对应的表。

微软 novtable 文档正好从反面说明:如果你阻止编译器在 ctor/dtor 中生成 vfptr 初始化代码,那很多情况下连这张 vtable 的引用都会消失,链接器可能直接把它扔掉。

把流程压成 7 步,大概是这样:

第 0 步:先区分“语言规则”和“实现”

  • 语言只说:虚调用要调用 dynamic type 上的 final overrider。
  • ABI/编译器才决定:我到底用 vtable、thunk、construction vtable 还是别的细节来实现。Itanium ABI 就是干这个的。

第 1 步:看到类定义,判断它是否需要 vtable

只要类声明了虚函数,或者继承了虚函数并保持多态性质,编译器就会把它当作需要动态分派的类来处理,并进入 vtable 相关布局计算。Clang 的 vtable 布局代码就是针对 CXXRecordDecl 做这件事。

第 2 步:计算类布局和继承结构

编译器先做对象布局:主基类、非虚基类、虚基类、子对象偏移、是否需要主/次 vtable、地址点等。Clang 的 record layout builder 里就有“lay out the vtable and the non-virtual bases”这样的步骤。

第 3 步:收集虚函数集合,建立覆盖关系

编译器要知道:

  • 哪些虚函数从基类继承而来
  • 哪些被当前类 override 了
  • 哪些需要在当前类中替换槽位
  • 多继承/虚继承下是否需要额外的 thunk 来调 this

Clang 的 computeVTableRelatedInformation 和 thunk 相关结构就是在处理这些问题。

第 4 步:确定 vtable 组件和槽位顺序

在 Itanium ABI 下,vtable 组件不只有函数指针,还有:

  • vcall offsets
  • vbase offsets
  • offset-to-top
  • RTTI/typeinfo 指针
  • 各虚函数入口,必要时是 adjustor thunk 的入口

ABI 还定义了“address point”的概念:对象里的 vptr 不一定指向整张表最开头,而是指向某个地址点。

第 5 步:生成 vtable 的全局初始化器

编译器把上一步的布局结果变成真正的常量全局对象;Clang 里就是 createVTableInitializer 做这件事。

第 6 步:决定在哪个翻译单元发射

这是链接友好性问题。Itanium/GCC 规则里通常跟 key function 有关:
有 key function 时,vtable 通常只在那个定义所在翻译单元发射;没有时就走 COMDAT / vague linkage 路线。

第 7 步:构造对象时写入 vptr

对象真正被创建时,构造函数代码会把对象里的 vptr 设为当前层对应的 vtable 地址;析构时也会按当前层语义处理 vfptr。MSVC 官方文档直接把“在构造函数和析构函数里初始化 vfptr”当成正常行为来描述。

编译期算布局,链接期定归属和去重,运行期让对象的 vptr 指向它。


9. 我现在是怎么把这些问题串成一个闭环的

写到这里,我对“虚函数进阶”这部分的理解,已经和上一篇博客不太一样了。

上一篇我更多是在回答:

  • 虚函数是什么;
  • 普通调用和虚调用在 VS 反汇编里长什么样;
  • vtable / RTTI 在 IDA 里怎么找;
  • 构造/析构虚调用和 dynamic_cast 的基本事实是什么。

这一篇我真正补上的,是这些“为什么”:

第一,构造/析构期间为什么只能按当前层分派

这不是一个孤立规则,而是对象生命周期安全性的直接结果。标准已经把这件事写死了;编译器再用分阶段改写 vptr 去落地它。

第二,vptr 的写入/回退不是独立知识点,而是“当前层语义”的实现

这件事一旦想通,构造/析构里的虚调用、typeiddynamic_cast 为什么在当前层语义下工作,也都顺了。标准同一节其实把 typeiddynamic_cast 在构造/析构期间的语义也一起规定了。

第三,虚函数相关问题一定要分层看

以后我再遇到类似问题,会先问自己三件事:

  1. 这是 标准语义 吗?
  2. 这是 ABI/编译器常见实现 吗?
  3. 这是 某个具体编译器 + 某个优化级别 + 某份反汇编 下的现象吗?

很多“看起来矛盾”的地方,其实都是这三层没分开。


10. 这篇答疑之后,我自己对虚函数的“进阶闭环”

到这里,我现在能把虚函数相关知识压成下面这条线:

语言先规定虚调用语义;编译器/ABI 再用 vptr、vtable、RTTI、thunk、construction vtable、链接规则把它落地;而我在 VS 和 IDA 里看到的那些现象,本质上只是这套机制在某个编译器实现下的具体投影。

换句话说,我现在不再把“虚函数”只看成:

  • 一个 virtual 关键字;
  • 一次“运行时多态”;
  • 一张函数指针表。

而是更愿意把它看成:

一套跨越语言规则、对象模型、编译链接和机器执行的完整机制。

这一篇就先收在这里。