这篇文章不是"虚函数基础知识"的重讲,而是我上一篇虚函数入门博客的评论区答疑与进阶复盘。
上一篇我主要做了 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。
在构造阶段,典型过程是:
- 进入
Derived::Derived; - 先调用
Base::Base; - 在
Base::Base开头附近把对象头里的vptr写成Base的 vtable; Base::Base返回后;- 回到
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 再查表再间接跳"。
所以在很多简单场景下,编译器完全可以直接生成:
arduino
call Base::f
或者:
arduino
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 offsetsvbase offsetsoffset-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_cast 和 typeid。
这也是为什么我后来终于想明白了"链接阶段到底在做什么":
链接器不是在"设计 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 的写入/回退不是独立知识点,而是"当前层语义"的实现
这件事一旦想通,构造/析构里的虚调用、typeid、dynamic_cast 为什么在当前层语义下工作,也都顺了。标准同一节其实把 typeid 和 dynamic_cast 在构造/析构期间的语义也一起规定了。
第三,虚函数相关问题一定要分层看
以后我再遇到类似问题,会先问自己三件事:
- 这是 标准语义 吗?
- 这是 ABI/编译器常见实现 吗?
- 这是 某个具体编译器 + 某个优化级别 + 某份反汇编 下的现象吗?
很多"看起来矛盾"的地方,其实都是这三层没分开。
10. 这篇答疑之后,我自己对虚函数的"进阶闭环"
到这里,我现在能把虚函数相关知识压成下面这条线:
语言先规定虚调用语义;编译器/ABI 再用 vptr、vtable、RTTI、thunk、construction vtable、链接规则把它落地;而我在 VS 和 IDA 里看到的那些现象,本质上只是这套机制在某个编译器实现下的具体投影。
换句话说,我现在不再把"虚函数"只看成:
- 一个
virtual关键字; - 一次"运行时多态";
- 一张函数指针表。
而是更愿意把它看成:
一套跨越语言规则、对象模型、编译链接和机器执行的完整机制。
这一篇就先收在这里。