深井蛙
72.70M · 2026-03-07
虚函数的目的就是:让“基类指针/引用”在调用成员函数时,能在运行期决定调用哪个派生类实现,也就是运行时多态。
核心关键词:
最典型的场景:
Base* p = new Derived;
p->f(); // f 是 virtual → 调 Derived::f;不是 virtual → 调 Base::f
我理解它的关键是: “调用点看到的静态类型”≠“对象真实类型” 。
虚函数就是把“选择哪个函数”的决策推迟到运行时。
virtual 写在 基类函数声明上即可;派生类建议写 overrideoverride:保证你真的在重写(签名不匹配会直接编译报错)final:禁止继续重写(对类/对成员函数都可用)const、引用限定符都算签名的一部分隐藏的解决:
using Base::f; // 把 Base::f 引入派生类作用域
// 或者在调用处明确写 Base::f()
我现在的原则很简单:
否则经典坑:
Base* p = new Derived;
delete p; // 只调用 Base 析构 → 资源泄露/UB
一句话结论我会背:
obj.vptr[slot](obj, args...)(本质是间接跳转)我不死记 ABI 细节,但必须理解:虚调用比普通调用多一次“间接寻址/间接跳转” 。
结论先记:
virtual void f() = 0; → 纯虚函数 → 类是抽象类,不能直接实例化Base b = Derived(); // 派生部分被切掉,多态丢失
多态要用指针/引用承载,不要用值类型承载派生对象。
先记一句:
final、LTO、CRTP 等这个模块我想搞懂的只有两件事:
#include <cstdio>
volatile int g_sink = 0;
struct Base {
void NonVirtual(int x) { g_sink += x + 1; }
virtual int Virtual(int x) { g_sink += x + 10; return g_sink; }
virtual ~Base() = default;
};
struct Derived : Base {
int Virtual(int x) override { g_sink += x + 20; return g_sink; }
};
int main() {
Derived d;
Base* p = &d;
p->NonVirtual(1); // 非虚:静态绑定
p->Virtual(2); // 虚:动态绑定(分派到 Derived::Virtual)
std::printf("%dn", g_sink);
}
成员函数有隐藏参数 this:
所以看到:
mov rcx, ... 通常是在准备 thismov edx, 2 通常是在准备参数 2语义:p->NonVirtual(1);
反汇编特征:
call Base::NonVirtual我对它的总结:
语义:p->Virtual(2);
我在 VS 里看到过(你也贴过)这种典型序列:
mov rax, qword ptr [p] ; rax = p(对象地址)
mov rax, qword ptr [rax] ; rax = *(void**)p(对象头8字节:vptr→vtable地址)
mov edx, 2 ; 参数x=2
mov rcx, qword ptr [p] ; this=p
call qword ptr [rax] ; 间接调用 vtable[0]
逐行理解:
[p]:从局部变量取出对象地址[rax]:从对象内存开头取出 vptr(对象头 8 字节)call qword ptr [rax]:用 vtable 的第 0 槽位做间接调用(真正的动态分派发生在这里)伪代码对应:
auto vtable = *(void***)p;
auto fp = (FnType)vtable[0];
fp(p, 2);
Debug 模式里你看到“多读一次 p”(先给 rax 又给 rcx)很常见,是保守生成,不用纠结。
在单继承 + x64 指针 8 字节的场景下:
[vtable + 0] → call [rax][vtable + 8] → call [rax+8][vtable + 16]→ call [rax+16]公式:
(多继承/虚继承会复杂很多,但我这个入门例子按这个理解完全正确。)
我当时在反汇编里看到过:
lea rcx, [d]
call Derived::Derived
解释:
lea rcx,[d]:把栈上对象 d 的地址算出来传给 RCX(this)call Derived::Derived:调用构造函数入口关键结论我后来确认了:
从“调用结构”看,确实是 call Derived::Derived 先进入派生构造入口;
但在 Derived::Derived 内部会先 call Base::Base。
语义顺序我记成一句:
“入口先到 Derived”与“语义先构造 Base”并不矛盾,因为 Base 构造发生在 Derived 构造函数内部的最前面阶段。
这个模块我想搞清楚两点:
在我这个 Windows/MSVC/PE 的例子里:
.rdata 只读数据段.rdata我在 IDA 里看到地址前缀 .rdata:,这就是直接证据。
我当时 IDA 不熟,但这个方法特别“机械”:
Derived::Derived先 call Base::Base
然后:
lea rcx, ??_7Derived@@6B@mov [this], rcx这说明对象头的 vptr 被写成 ??_7Derived@@6B@。
??_7Derived@@6B@ 回车 → 直接跳到 vtable 本体。在 IDA 里它往往不是“孤零零一张表”,而是:
??_R4Derived@@6B@??_7Derived@@6B@(Derived::'vftable')dq offset ...(每个 8 字节一个槽位)我当时还遇到一个疑问:
“为什么我看到第一个是 Base::Virtual?”
后来我确认:那是因为 .rdata 里Base 和 Derived 的两张表挨着放,我当时看到的是 ??_7Base@@6B@ 那张表的 slot0。
在 IDA 里我看到 vftable'[3],意味着函数槽位数组长度是 3。对 Derived 来说常见是:
Virtual(int)(经常显示成 j_?Virtual...,j_ 是 jump thunk)??_G...)??_E... vector deleting destructor 或 thunk;有时 IDA 显示成 dq (offset qword_xxx + ...))??_G?后来我把它当作“实现细节”处理:
??_E 很多时候是更通用的入口(带 flags)??_G 可能是 wrapper/thunk,可能被合并/折叠/不以你期待的方式显示dq 0 很可能只是 padding/对齐我的最终经验:
我现在会这样答(更严谨):
我当时的初步回答是:应该调派生类的虚函数。
这个回答是不对的(至少在 Base 构造/析构阶段不对)。
因此:
我在 Derived::Derived 里看到非常关键的顺序:
call Base::Base
Base 构造返回后,Derived 构造才写:
vptr = &??_7Derived@@6B@这意味着:在 Base::Base 执行期间,vptr 指向 Base 的 vtable。
所以 Base 构造里调用虚函数,只能查 Base 表 → 调 Base::Virtual。
析构过程反过来:派生部分先销毁,再进入 Base 析构阶段,虚调用同样不会去调用已经“生命周期结束”的派生实现。
所以语言/实现让虚调用“按当前层绑定”。
这个模块我想搞懂 3 件事:
??_R4)与 vtable 的关系是什么dynamic_cast 是 运行时安全类型转换,在多态体系里用来:
Base* -> Derived*它的价值是:运行时检查真实类型,不合法就失败,而不是 UB。
对比:
static_cast:编译期转换,不做运行时检查(向下转型错了会 UB)reinterpret_cast:比特重解释(几乎不用于类型安全)对类指针/引用做 dynamic_cast,源类型必须是多态类型(至少一个 virtual;最常见是 virtual destructor)。
原因:dynamic_cast 需要 RTTI。没有虚函数通常也就没有可靠的 RTTI 入口。
(补充:编译器关 RTTI,如 MSVC /GR-,这类 dynamic_cast 通常不可用。)
dynamic_cast<Derived*>(p) 失败返回 nullptrdynamic_cast<Derived&>(r) 失败抛 std::bad_cast我在 IDA 里看到过:
??_7Derived@@6B@:Derived 的 vtable 起点??_R4Derived@@6B@:RTTI Complete Object Locator这就是关键关系:
vptr = *(void**)this.rdata)所以我现在能一句话回答“关系是什么”:
以 Derived* pd = dynamic_cast<Derived*>(pb); 为例:
pb 指向对象读 vptrnullptr/抛 bad_caststruct A { virtual ~A(){} };
struct B { virtual ~B(){} };
struct D : A, B {};
A* pa = new D;
B* pb = dynamic_cast<B*>(pa); // cross-cast,需要运行时算 B 子对象偏移
这里 pa 指向的是 D 里 A 子对象那块内存,要转成 B* 必须做“指针平移”。
这个平移量只有 RTTI 可靠知道,所以这类场景 dynamic_cast 很有意义。
到这里我对虚函数的入门闭环是这样的:
.rdata;vptr 写入发生在构造;vtable 附近有 RTTI(??_R4)