狙击手挑战
105.14M · 2026-03-12
这篇文章不是“虚函数基础知识”的重讲,而是我上一篇虚函数入门博客的评论区答疑与进阶复盘。
上一篇我主要做了 5 件事:先把虚函数的地图画出来,再去 VS 里看虚调用指令,再去 IDA 里把 vtable / vptr / RTTI 直接扒出来确认,最后把构造/析构虚调用和 dynamic_cast 的基本闭环跑通。
但写完之后,评论区里真正把我往前推了一步的,不是“virtual 是什么”这种题,而是这些更深的问题:
vptr 到底是什么时候写进去、什么时候回退的?所以这一篇,我不再按“入门概念”写,而是按“评论区问题 → 我的修正理解 → 我怎么把它和反汇编/IDA 对上”来写。
整篇我尽量保持一个固定节奏:
这个问题是我觉得最值得彻底讲透的。
C++ 标准在 [class.cdtor] 里写得很明确:
如果一个虚函数是在构造函数或析构函数里被调用,而且调用对象正是这个“正在构造或析构的对象”,那么被调用的不是更派生类里的 override,而是“当前这个构造函数/析构函数所属类”中的 final overrider。 也就是说,在 Base::Base() 或 Base::~Base() 里发起的这种虚调用,不会越级跑到 Derived::f() 去。
我现在对这件事的理解,不再是“背规则”,而是先抓住它背后的对象生命周期:
所以如果在 Base 阶段还去调用 Derived 版本,就可能读到未初始化成员,或者访问已经析构的资源。标准同一节其实也强调了:在构造开始前去引用对象成员/基类,或者在析构完成后再去引用,都是未定义行为;这正好和“为什么不能越级分派”是同一条逻辑。
所以我现在会把这个问题压成一句很短的话:
这句话是这篇文章后面很多问题的总前提。
vptr 到底什么时候初始化?析构时又什么时候“回退”?这个问题如果只从语言层面说,很容易抽象;但一旦和反汇编连起来,就会立刻具体起来。
我现在会先把“反初始化”这个词换掉。更准确的说法不是“析构时把 vptr 反初始化/清零”,而是:
在构造阶段,典型过程是:
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。这个问题我一开始想得太机械了,总觉得“既然是虚函数,就一定要查表”。后来我才把“语义”和“机器码实现”区分开。
标准真正规定的是:
它规定的是“当前层的 final overrider”,不是“机器码必须先读 vptr 再查表再间接跳”。
所以在很多简单场景下,编译器完全可以直接生成:
call Base::f
或者:
call Derived::f
而不必再绕一次完整的“虚调用路径”。因为在构造/析构体里,当前层该调谁这件事,语义上已经确定了。
所以我现在对这个问题的回答是:
这里我会特地补一句,免得说太死:
复杂继承、虚继承、construction vtable 这些情况,编译器仍然可能借助更复杂的表机制来保证当前层语义。Itanium ABI 专门有 construction virtual table 这一节,就是在处理“正常 vtable 不足以正确服务构造/析构期对象模型”的那些情况。
所以这个问题我现在记成一句话:
这个问题很容易一上来就答成“因为不能被篡改”,但那只是答案的一部分。
我现在会先纠正一个前提:
“vtable 在哪儿”是 ABI 和编译器实现问题,不是语言标准本身的硬性规则。Itanium ABI 只规定了虚表里会有哪些组成项、怎么布局,并没有把“必须在 .rodata”写成语言条文。
那为什么现实里它通常放只读区,或者至少是“重定位后只读”的区域?
因为:
Itanium ABI 写得很清楚,vtable 里不只有函数入口,还可能有:
vcall offsetsvbase offsetsoffset-to-top而且它还专门区分了“address point”和“整个虚表起始位置”——对象里的 vptr 指向的并不一定是整张表开头,而是某个地址点。
这说明它本质上不是“一段代码”,而是一份类级元数据表。
同一个类的大量对象会共享同一份 vtable。既然它是共享元数据,而且运行时主要做读取,不做普通业务意义上的修改,那放到只读区就最自然。
这个是你一开始直觉里抓到的部分:
如果 vtable 可写,那么虚调用目标就更容易被内存破坏或恶意篡改。放在只读区,至少从权限模型上更合理。
所以我现在会把这个问题压成一句特别顺的话:
这句话一成立,“为什么更适合放只读区”就顺了。
我现在觉得这个问题最容易答空,或者答错。
我一开始的直觉也是:
但后来我把这个说法修正成了:
LLVM 关于去虚化的资料里直接把 virtual call 写成了这种形态:
而且它明确说了:去虚化很重要,因为更多内联机会,同时indirect calls are harder to predict。GCC 的优化文档也说明了,编译器会为了更激进的 devirtualization 打开额外信息和优化通路。
所以我现在会把性能问题拆成三层说:
对象里的 vptr 可以被缓存,vtable 也可以被缓存,目标函数指令照样可以进 I-cache。
所以“运行时调用所以无法缓存”这个因果关系并不成立。
普通直接调用更接近“目标地址已知”;
虚调用通常要经历:
vptrvptr → vtable 槽位这会增加依赖性 load,可能带来额外的 D-cache 压力,但不是“一定命中率暴跌”。
真正更常见的性能损失往往来自:
所以我现在对这个问题的最短回答是:
这个问题我现在不会再答成“因为 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 的理解是:
这句话比“语言设计不完美”更接近工程现实。
我现在会这样概括:
dynamic_cast / typeid:往往说明原本该进多态接口的行为,被丢到了对象外部做按类型分支。因此这个问题我现在的答法是:
这是我后来特别喜欢的一道题,因为它很能区分“表面理解”和“本质理解”。
标准在 [temp.mem] 里直接规定了两件事:
我一开始的说法是:
这句话不算错,但不够本质。
更本质的说法是:
虚函数为什么能 override?
因为基类先给出一个确定签名,派生类再用相同签名去覆盖它,这样编译器才能在 vtable 里给这个接口安排固定槽位。
而模板函数在实例化前没有唯一签名:
f<int>f<double>f<std::string>这些是不同实例,不是“同一个函数的不同运行时版本”。
所以真正冲突的不是“一个编译期、一个运行期”这么表层,而是:
没有唯一签名,就没有稳定 override 关系;没有稳定 override 关系,就没法给它安排确定的 vtable 槽位。
所以我现在会把这个问题记成一句很顺的话:
这个问题我现在觉得,最重要的是把“确定”拆开。
因为“确定”至少有三层意思:
编译器看到类定义、继承关系、override 关系后,会决定:
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,而是:
vptr 写到当前层对应的那张表上;微软 novtable 文档正好从反面说明:如果你阻止编译器在 ctor/dtor 中生成 vfptr 初始化代码,那很多情况下连这张 vtable 的引用都会消失,链接器可能直接把它扔掉。
把流程压成 7 步,大概是这样:
只要类声明了虚函数,或者继承了虚函数并保持多态性质,编译器就会把它当作需要动态分派的类来处理,并进入 vtable 相关布局计算。Clang 的 vtable 布局代码就是针对 CXXRecordDecl 做这件事。
编译器先做对象布局:主基类、非虚基类、虚基类、子对象偏移、是否需要主/次 vtable、地址点等。Clang 的 record layout builder 里就有“lay out the vtable and the non-virtual bases”这样的步骤。
编译器要知道:
thisClang 的 computeVTableRelatedInformation 和 thunk 相关结构就是在处理这些问题。
在 Itanium ABI 下,vtable 组件不只有函数指针,还有:
ABI 还定义了“address point”的概念:对象里的 vptr 不一定指向整张表最开头,而是指向某个地址点。
编译器把上一步的布局结果变成真正的常量全局对象;Clang 里就是 createVTableInitializer 做这件事。
这是链接友好性问题。Itanium/GCC 规则里通常跟 key function 有关:
有 key function 时,vtable 通常只在那个定义所在翻译单元发射;没有时就走 COMDAT / vague linkage 路线。
vptr对象真正被创建时,构造函数代码会把对象里的 vptr 设为当前层对应的 vtable 地址;析构时也会按当前层语义处理 vfptr。MSVC 官方文档直接把“在构造函数和析构函数里初始化 vfptr”当成正常行为来描述。
写到这里,我对“虚函数进阶”这部分的理解,已经和上一篇博客不太一样了。
上一篇我更多是在回答:
dynamic_cast 的基本事实是什么。这一篇我真正补上的,是这些“为什么”:
这不是一个孤立规则,而是对象生命周期安全性的直接结果。标准已经把这件事写死了;编译器再用分阶段改写 vptr 去落地它。
vptr 的写入/回退不是独立知识点,而是“当前层语义”的实现这件事一旦想通,构造/析构里的虚调用、typeid、dynamic_cast 为什么在当前层语义下工作,也都顺了。标准同一节其实把 typeid 和 dynamic_cast 在构造/析构期间的语义也一起规定了。
以后我再遇到类似问题,会先问自己三件事:
很多“看起来矛盾”的地方,其实都是这三层没分开。
到这里,我现在能把虚函数相关知识压成下面这条线:
换句话说,我现在不再把“虚函数”只看成:
virtual 关键字;而是更愿意把它看成:
这一篇就先收在这里。
Vite 凭什么比 Webpack 快50%?揭秘闪电构建背后的黑科技
我用 OpenClaw 搭了一套运营 Agent,每天自动生产内容、分发、追踪数据——独立开发者的运营平替
2026-03-12
2026-03-12