# 用 5 个问题学懂 C++ 虚函数(入门级)

这篇文章是我自己学虚函数时的"路径复盘"。我不是从背概念开始,而是用 VS 的反汇编 把虚调用指令看明白、再用 IDA 把二进制里的 vtable/vptr/RTTI 直接扒出来确认。

全文按 5 个问题分 5 个模块,每个模块都尽量给到:关键结论 → 为什么 → 我怎么验证 → 代码/反汇编/IDA 现象,方便我自己复习也方便别人照着走。


模块 1:虚函数基础知识------我先把"地图"画出来

1. 虚函数到底解决什么问题

虚函数的目的就是:让"基类指针/引用"在调用成员函数时,能在运行期决定调用哪个派生类实现,也就是运行时多态。

核心关键词:

  • 动态绑定(dynamic binding)
  • 运行时多态(runtime polymorphism)
  • 接口一致 / 实现可替换

最典型的场景:

ini 复制代码
Base* p = new Derived;
p->f();   // f 是 virtual → 调 Derived::f;不是 virtual → 调 Base::f

2. 两种绑定:静态绑定 vs 动态绑定

  • 非虚函数:编译期就决定目标(静态绑定)
  • 虚函数:运行期根据对象真实类型决定(动态绑定)

我理解它的关键是: "调用点看到的静态类型"≠"对象真实类型"

虚函数就是把"选择哪个函数"的决策推迟到运行时。


3. 虚函数语法规则(必须背熟)

  • virtual 写在 基类 函数声明上即可;派生类建议写 override
  • override:保证你真的在重写(签名不匹配会直接编译报错)
  • final:禁止继续重写(对类/对成员函数都可用)
  • 重写的签名匹配非常严格 :参数、返回类型、const、引用限定符都算签名的一部分
  • 返回类型可协变(返回指针/引用时允许更派生)

4. "重载 vs 重写 vs 隐藏"三者必须分清

  • 重载 overload:同一作用域,参数不同
  • 重写 override:派生类替换基类虚函数实现(签名匹配)
  • 隐藏 hide:派生类声明同名函数会把基类同名都"遮住"(哪怕参数不同)

隐藏的解决:

arduino 复制代码
using Base::f;  // 把 Base::f 引入派生类作用域
// 或者在调用处明确写 Base::f()

5. 虚析构:为什么几乎总要写

我现在的原则很简单:

只要这个类可能作为多态基类使用(可能被继承 + 可能用 Base delete 派生对象),析构函数就应该是 virtual。 *

否则经典坑:

ini 复制代码
Base* p = new Derived;
delete p;  // 只调用 Base 析构 → 资源泄露/UB

一句话结论我会背:

"有虚函数/要做多态基类的类,析构函数通常要是 virtual。"


6. vptr / vtable 的直观模型(理解层面)

  • 多态对象内部通常有一个隐藏指针:vptr
  • vptr 指向一张表:vtable(函数指针数组)
  • 虚调用大致是:obj.vptr[slot](obj, args...)(本质是间接跳转)

我不死记 ABI 细节,但必须理解:虚调用比普通调用多一次"间接寻址/间接跳转"


7. 构造/析构里调用虚函数:到底调谁(这里先留个结论,模块 4 详讲)

结论先记:

构造/析构期间虚调用按"当前层"分派,不会越级到更派生层。


8. 纯虚函数与抽象类

  • virtual void f() = 0; → 纯虚函数 → 类是抽象类,不能直接实例化
  • 抽象类常用于定义接口
  • 纯虚函数也可以有定义(少见,但允许)

9. object slicing(对象切片)

ini 复制代码
Base b = Derived(); // 派生部分被切掉,多态丢失

多态要用指针/引用承载,不要用值类型承载派生对象。


10. dynamic_cast / RTTI(模块 5 详讲)

先记一句:

只有多态类型(通常有虚函数)才有 RTTI 支撑,dynamic_cast 才能工作。


11. 性能与代价(不夸张,但要会说)

  • 对象多一个 vptr(通常 8 字节)
  • 每次虚调用多一次间接跳转,影响内联/分支预测
  • 大多数业务不敏感;真要优化再考虑去虚化、final、LTO、CRTP 等

模块 2:虚函数基本调用原理------我在 VS 反汇编里看到的到底是什么

这个模块我想搞懂的只有两件事:

  1. 普通成员函数调用:汇编里通常是 直接 call 固定符号
  2. 虚函数调用:汇编里通常是 通过 vptr/vtable 查槽位,再间接 call

1) 我用的最小示例代码(Debug 下就够)

arduino 复制代码
#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("%d\n", g_sink);
}

2) Windows x64 调用约定(看反汇编必须知道)

成员函数有隐藏参数 this

  • RCX = this
  • RDX = 第一个显式参数
  • 之后 R8/R9,再往后栈

所以看到:

  • mov rcx, ... 通常是在准备 this
  • mov edx, 2 通常是在准备参数 2

3) 非虚函数调用:我看到的是"直接 call"

语义:p->NonVirtual(1);

反汇编特征:

  • 目标固定:call Base::NonVirtual
  • 没有取 vptr/查表

我对它的总结:

编译器编译期已经决定:这句一定调用 Base::NonVirtual(因为它不是 virtual)。


4) 虚函数调用:我看到的是"取 vptr → 查槽位 → 间接 call"

语义:p->Virtual(2);

我在 VS 里看到过(你也贴过)这种典型序列:

ini 复制代码
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 槽位做间接调用(真正的动态分派发生在这里)

伪代码对应:

ini 复制代码
auto vtable = *(void***)p;
auto fp = (FnType)vtable[0];
fp(p, 2);

Debug 模式里你看到"多读一次 p"(先给 rax 又给 rcx)很常见,是保守生成,不用纠结。


5) 槽位偏移:第二个槽位为什么要 +8

在单继承 + x64 指针 8 字节的场景下:

  • slot0:[vtable + 0]call [rax]
  • slot1:[vtable + 8]call [rax+8]
  • slot2:[vtable + 16]call [rax+16]

公式:

slot_addr = vtable + index * 8

(多继承/虚继承会复杂很多,但我这个入门例子按这个理解完全正确。)


6) 构造函数为什么会出现 call(我当时的疑问)

我当时在反汇编里看到过:

arduino 复制代码
lea rcx, [d]
call Derived::Derived

解释:

  • lea rcx,[d]:把栈上对象 d 的地址算出来传给 RCX(this)
  • call Derived::Derived:调用构造函数入口

关键结论我后来确认了:

不是"因为虚函数所以系统额外分配构造函数",而是:有虚函数→对象需要 vptr→构造时必须初始化 vptr→默认构造不再 trivial→必须生成/执行初始化代码。


7) 构造顺序:先进入派生构造,再构造基类?

从"调用结构"看,确实是 call Derived::Derived 先进入派生构造入口;

但在 Derived::Derived 内部会先 call Base::Base

语义顺序我记成一句:

Base 子对象 → 成员 → Derived 构造体。

"入口先到 Derived"与"语义先构造 Base"并不矛盾,因为 Base 构造发生在 Derived 构造函数内部的最前面阶段。


模块 3:vtable 在二进制哪里、长啥样------我用 IDA 怎么把它"扒出来看懂"

这个模块我想搞清楚两点:

  1. 在 Windows/MSVC 下,vtable 在 PE 的哪个段
  2. vtable 附近到底有哪些数据(虚函数槽位、析构入口、RTTI)

1) vtable 在哪个段(我在 IDA 里看到的结论)

在我这个 Windows/MSVC/PE 的例子里:

  • vtable(MSVC 常叫 vftable)通常在 .rdata 只读数据段
  • RTTI 相关数据也多在 .rdata

我在 IDA 里看到地址前缀 .rdata:,这就是直接证据。


2) 我定位 vtable 的最稳方式:从构造函数写 vptr 反推

我当时 IDA 不熟,但这个方法特别"机械":

  1. 找到 Derived::Derived
  2. 在里面找到写 vptr 的那几行(你也贴过):
  • call Base::Base

  • 然后:

    • lea rcx, ??_7Derived@@6B@
    • mov [this], rcx

这说明对象头的 vptr 被写成 ??_7Derived@@6B@

  1. ??_7Derived@@6B@ 回车 → 直接跳到 vtable 本体。

3) vtable 附近我实际看到的布局(MSVC 典型)

在 IDA 里它往往不是"孤零零一张表",而是:

  • vtable[-1] :RTTI Complete Object Locator(COL)指针
    典型符号:??_R4Derived@@6B@
  • vtable 起点??_7Derived@@6B@(Derived::'vftable')
    vptr 指向这里
  • 从 vtable 起点往下 :一串 dq offset ...(每个 8 字节一个槽位)

我当时还遇到一个疑问:

"为什么我看到第一个是 Base::Virtual?"

后来我确认:那是因为 .rdataBase 和 Derived 的两张表挨着放 ,我当时看到的是 ??_7Base@@6B@ 那张表的 slot0。


4) vtable 里到底有哪些条目(我这个例子:1 个虚函数 + 虚析构)

在 IDA 里我看到 vftable'[3],意味着函数槽位数组长度是 3。对 Derived 来说常见是:

  • slot0:我定义的 Virtual(int)(经常显示成 j_?Virtual...j_ 是 jump thunk)
  • slot1:deleting destructor 相关入口(IDA 常标 scalar deleting destructor,符号常见 ??_G...
  • slot2:另一个 deleting destructor 入口(常见 ??_E... vector deleting destructor 或 thunk;有时 IDA 显示成 dq (offset qword_xxx + ...)

我当时的一个疑问:为什么 Base 表里看起来没有 ??_G

后来我把它当作"实现细节"处理:

  • MSVC 里 ??_E 很多时候是更通用的入口(带 flags)
  • ??_G 可能是 wrapper/thunk,可能被合并/折叠/不以你期待的方式显示
  • 截图里出现的 dq 0 很可能只是 padding/对齐

我的最终经验:

不要只看名字猜,最靠谱是对每个 dq offset ... 回车跟进去,看它最终跳到哪、做了什么。


5) 我最后怎么回答"vtable 在哪 + 里面有什么"

我现在会这样答(更严谨):

在 Windows/MSVC 下,vtable(vftable) 通常位于 PE 的 .rdata 段。对象内含 vptr 指向 ??_7Class@@6B@ 这样的 vtable 起点。vtable 本体是一串 8 字节槽位(函数指针或 thunk),包含类的虚函数入口;若类有虚析构,表里还常包含 MSVC 的 deleting destructor 相关入口(??_E/??_G 或跳板)。vtable 前面通常紧挨着 RTTI 的 Complete Object Locator(??_R4...),用于 typeid/dynamic_cast


模块 4:构造/析构里调虚函数到底调谁------我一开始答错的经典题

我当时的初步回答是:应该调派生类的虚函数

这个回答是不对的(至少在 Base 构造/析构阶段不对)。


1) 标准结论(我现在的最终答案)

构造/析构期间的虚调用只会分派到"当前正在构造/析构的那一层",不会越级到更派生层。

因此:

  • Base 构造/析构里 调用虚函数 → 调 Base 版本
  • Derived 构造/析构里 调用虚函数 → 调 Derived 版本
  • Base 阶段不会跑去调 Derived override

2) 我为什么相信这个结论(我在 IDA 里看到了 vptr 的"分阶段")

我在 Derived::Derived 里看到非常关键的顺序:

  1. call Base::Base

  2. Base 构造返回后,Derived 构造才写:

    • vptr = &??_7Derived@@6B@

这意味着:在 Base::Base 执行期间,vptr 指向 Base 的 vtable

所以 Base 构造里调用虚函数,只能查 Base 表 → 调 Base::Virtual。

析构过程反过来:派生部分先销毁,再进入 Base 析构阶段,虚调用同样不会去调用已经"生命周期结束"的派生实现。


3) 直观记忆法(我用这个解释给别人听)

  • Base 构造时:Derived 部分还没构造好,调用 Derived 虚函数可能访问未初始化成员 → 不安全
  • Base 析构时:Derived 部分已销毁,调用 Derived 虚函数可能访问已销毁资源 → 不安全

所以语言/实现让虚调用"按当前层绑定"。


4) 面试一句话版(我现在背这个)

构造/析构期间虚调用不会向下分派到更派生类,只会落到当前构造/析构层对应的实现。


模块 5:dynamic_cast 是什么、怎么实现、和 vtable 有什么关系

这个模块我想搞懂 3 件事:

  1. dynamic_cast 在语义上到底做什么、失败怎么表现
  2. 它为什么要求"多态类型"
  3. 我在 IDA 里看到的 RTTI(??_R4)与 vtable 的关系是什么

1) dynamic_cast 是什么(解决什么问题)

dynamic_cast运行时安全类型转换,在多态体系里用来:

  • 向下转型Base* -> Derived*
  • 横向转型(cross-cast) :多继承时 A* → B*
  • 转成 void* :得到 most-derived 对象起始地址

它的价值是:运行时检查真实类型,不合法就失败,而不是 UB

对比:

  • static_cast:编译期转换,不做运行时检查(向下转型错了会 UB)
  • reinterpret_cast:比特重解释(几乎不用于类型安全)

2) 规则与失败行为

2.1 只能对"多态类型"做(关键前提)

对类指针/引用做 dynamic_cast,源类型必须是多态类型(至少一个 virtual;最常见是 virtual destructor)。

原因:dynamic_cast 需要 RTTI。没有虚函数通常也就没有可靠的 RTTI 入口。

(补充:编译器关 RTTI,如 MSVC /GR-,这类 dynamic_cast 通常不可用。)

2.2 指针 vs 引用:失败行为不同

  • dynamic_cast<Derived*>(p) 失败返回 nullptr
  • dynamic_cast<Derived&>(r) 失败抛 std::bad_cast

3) 它怎么实现(我用一句话概括)

dynamic_cast 依赖 RTTI;RTTI 通常能通过对象的 vptr 间接找到。运行时读取对象真实类型信息,在继承图里判断目标类型是否合法,并在多继承/虚继承时计算指针调整,成功返回调整后的指针,失败返回空/抛异常。


4) 它和 vtable/vptr 的关系(我在 IDA 里看到的"实锤")

我在 IDA 里看到过:

  • ??_7Derived@@6B@:Derived 的 vtable 起点
  • 它前面紧挨着 ??_R4Derived@@6B@:RTTI Complete Object Locator

这就是关键关系:

  • 对象头有 vptr:vptr = *(void**)this
  • vptr 指向 vtable(.rdata
  • vtable 的"前一格"(常见 vtable[-1])指向 RTTI 的定位结构(COL)
  • dynamic_cast 就是通过这套 RTTI 结构去做类型判断和偏移计算

所以我现在能一句话回答"关系是什么":

dynamic_cast 的 RTTI 信息通常通过 vptr 可达,在 MSVC 下 RTTI 的 COL 常紧挨在 vtable 前面;因此 dynamic_cast 会先经由 vptr 找到 RTTI,再做类型匹配与指针调整。


5) dynamic_cast 的运行时流程(我记成 5 步)

Derived* pd = dynamic_cast<Derived*>(pb); 为例:

  1. pb 指向对象读 vptr
  2. 通过 vptr 找到 RTTI(真实类型 = most-derived type)
  3. 判断真实类型是否包含目标子对象、转换是否语义合法
  4. 若合法:计算指针调整(多继承/虚继承时关键)
  5. 返回调整后的指针;否则返回 nullptr/抛 bad_cast

6) 为什么多继承时 dynamic_cast 特别关键(我用这个例子记)

less 复制代码
struct 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 很有意义。


我自己的最终"复习闭环"

到这里我对虚函数的入门闭环是这样的:

  1. 概念地图:虚函数解决多态,语法规则/隐藏/虚析构/切片/代价
  2. VS 反汇编:非虚 call 固定目标;虚调用查 vptr → vtable → slot 间接 call
  3. IDA 实锤 :vtable 在 .rdata;vptr 写入发生在构造;vtable 附近有 RTTI(??_R4
  4. 构造/析构虚调用:按当前层绑定,不越级
  5. dynamic_cast:靠 RTTI(经由 vptr 可达)做运行时检查 + 指针调整
相关推荐
不想写代码的星星5 小时前
虚函数表:C++ 多态背后的那个男人
c++
端平入洛2 天前
delete又未完全delete
c++
端平入洛3 天前
auto有时不auto
c++
哇哈哈20214 天前
信号量和信号
linux·c++
多恩Stone4 天前
【C++入门扫盲1】C++ 与 Python:类型、编译器/解释器与 CPU 的关系
开发语言·c++·人工智能·python·算法·3d·aigc
蜡笔小马4 天前
21.Boost.Geometry disjoint、distance、envelope、equals、expand和for_each算法接口详解
c++·算法·boost
超级大福宝4 天前
N皇后问题:经典回溯算法的一些分析
数据结构·c++·算法·leetcode
weiabc4 天前
printf(“%lf“, ys) 和 cout << ys 输出的浮点数格式存在细微差异
数据结构·c++·算法
问好眼4 天前
《算法竞赛进阶指南》0x01 位运算-3.64位整数乘法
c++·算法·位运算·信息学奥赛