0%

C++ 对象模型实战(五):虚函数机制(下)——构造中的 vptr 切换、虚析构与纯虚

这一篇解决什么问题

上一篇弄清了虚表机制。这一篇看三个和虚表机制直接相关的”坑点”:

  1. 在构造函数里调虚函数,为什么不多态?
  2. 通过基类指针 delete 派生对象,为什么基类析构必须是 virtual?
  3. 纯虚函数和 __purecall 报错是怎么回事。

原理:vptr 是在构造过程中被逐层换掉的

上一篇说”对象的 vptr 指向本类的虚表”,但构造一个 Derived 对象时,vptr 不是一开始就是 Derived 的表。编译器在每一层构造函数的入口处把 vptr 设置成当前这一层的表:

1
2
3
4
5
new Derived 的过程:
1. 分配内存(此时 vptr 未初始化, 是垃圾)
2. 进入 Base::Base() → vptr = Base 的虚表 → 这里调 f() 落在 Base::f
3. 进入 Derived::Derived() → vptr = Derived 的虚表 → 这里调 f() 落在 Derived::f
4. 构造完成, vptr 停留在 Derived 的表

析构对称:进入 ~Derived 时 vptr 还是 Derived 的表,跑完后被设回 Base 的表,再执行 ~Base。

“构造中虚函数不多态”不是语言的特殊规定,而是 vptr 逐层切换的直接后果:在 Base 构造函数执行期间,这个对象的 vptr 指向的就是 Base 的表,f() 当然落在 Base::f。语言标准只是把这个实现事实规定成了行为。

虚析构:delete 基类指针的正确性

delete p(p 是 Base* 指向 Derived 对象)要做两件事:调析构、释放内存。调谁的析构?

  • 基类析构是 virtual:查 vptr 的虚表 → 找到 Derived::~Derived → 先跑派生析构,再链条式跑基类析构。正确。
  • 基类析构不是 virtual:编译器按 p 的静态类型直接 call Base::~Base,Derived 的析构被跳过——派生部分泄漏,行为未定义。

经验法则:一个类只要打算被基类指针 delete,析构就必须是 virtual。有虚函数的类基本都在此列。

纯虚函数与 __purecall

virtual void f() = 0; 让类成为抽象类,不能实例化。但有一种路径仍能调到纯虚函数:在 Base 的构造/析构里调 f()(此时 vptr 是 Base 的表,表项里没有实现)。MSVC 给这种表项填的是 __purecall 处理函数的地址,一旦调到就报运行时错误 “pure virtual function call” 并终止程序。遇到这个报错,第一嫌疑就是构造/析构链条里有人调了虚函数。

验证工程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// 虚函数机制(下): 构造/析构中 vptr 切换, 虚析构, 纯虚函数.
#include <cstdio>
#include <cstring>

class Base {
public:
Base() {
printf("Base ctor calls f() -> "); // Base 构造里调 f().
f(); // 此时对象还是 Base, 不多态.
show_vptr("Base ctor");
}
virtual ~Base() { printf("Base dtor\n"); } // 实验: 去掉 virtual 再跑一次.
virtual void f() { printf("Base::f\n"); }

void show_vptr(const char* where) {
void* p;
memcpy(&p, this, sizeof p);
printf(" vptr(%s) = %p\n", where, p);
}
};

class Derived : public Base {
public:
Derived() {
printf("Derived ctor calls f() -> "); // Derived 构造里调 f().
f(); // 此时 vptr 已换成 Derived 的表.
show_vptr("Derived ctor");
}
~Derived() { printf("Derived dtor\n"); }
void f() override { printf("Derived::f\n"); }
};

int main() {
printf("--- new Derived ---\n");
Base* p = new Derived;
printf("--- delete via base pointer ---\n");
delete p; // 基类析构是 virtual, 先跑 Derived dtor 再跑 Base dtor.
}
// 附: 若 Base::f 是纯虚(= 0), Base ctor 里调 f 会触发 __purecall 运行时报错.

预测输出

动手跑之前先推一遍:

  • Base 构造函数里 f() 输出 Base::f:此刻 vptr 是 Base 的表,尽管这个对象”将来”是 Derived。
  • 两行 vptr 打印地址不同:构造过程中 vptr 被换了一次,肉眼可见。
  • delete 后先 Derived dtor 再 Base dtor:虚析构让 delete 找到了正确的入口。
  • 追加实验:把 ~Base() 的 virtual 去掉再跑一次,预测输出只剩 Base dtor,Derived dtor 消失——派生析构被静默跳过。

实测输出

基类析构是虚函数(工程原样):

1
2
3
4
5
6
7
8
--- new Derived ---
Base ctor calls f() -> Base::f
vptr(Base ctor) = 00007FF610E7ACB0
Derived ctor calls f() -> Derived::f
vptr(Derived ctor) = 00007FF610E7AD38
--- delete via base pointer ---
Derived dtor
Base dtor

基类析构不是虚函数(去掉 virtual 再跑):

1
2
3
4
5
6
7
--- new Derived ---
Base ctor calls f() -> Base::f
vptr(Base ctor) = 00007FF7EE72ACB0
Derived ctor calls f() -> Derived::f
vptr(Derived ctor) = 00007FF7EE72AD38
--- delete via base pointer ---
Base dtor

逐行比对

  • Base ctor calls f() -> Base::f:命中。此刻 vptr 指向 Base 的表,对象”将来”是 Derived 也没用。
  • 两行 vptr 不同(...ACB0 → ...AD38):命中,构造过程中 vptr 被逐层换掉,从打印直接看到。
  • 虚析构版本先 Derived dtor 再 Base dtor:命中。
  • 非虚析构版本只剩 Base dtor:命中,Derived dtor 被静默跳过——编译器按指针的静态类型 Base* 直接 call Base::~Base,根本没查表。

有意思的是对比两次运行:上半部分(vptr 切换)一字不差,唯一的差别就在 delete 那两行。”基类析构必须是 virtual”这条经验法则,背后是删不删得干净的问题。

小结

1
2
3
4
vptr 逐层切换: 每层 ctor 入口把 vptr 设为本层的表
推论: 构造/析构中调虚函数 = 调当前层的版本, 不多态
delete 基类指针: 基类析构必须 virtual, 否则派生析构被跳过
构造/析构中调到纯虚 → __purecall 运行时终止