Skip to content
Gains Summary
Main Navigation 首页 / Home
C++ 编程 / C++ Programming
系统与高性能 / Systems & Performance
Web 开发 / Web Development
人工智能 / Artificial Intelligence
工业软件 / Industrial Software
其他内容 / Other Topics
C++ 编程 / C++系统与性能 / SystemsWeb 开发 / Web人工智能 / AI工业软件 / Industrial

外观

Sidebar Navigation

← C++ 编程 / C++ Programming

面向对象与对象模型 / OOP & Object Model

1. C++ 面向对象与对象模型总览 / C++ Object-Oriented Programming Overview

2. 类、对象、this 与对象模型 / Classes, Objects, this, and Object Model

3. 构造、析构与对象生命周期 / Construction, Destruction, and Object Lifetime

4. static、类级状态与存储期 / static Members and Storage Duration

5. 封装、组合与继承 / Encapsulation, Composition, and Inheritance

6. 虚函数、虚析构与运行时多态 / Virtual Functions and Runtime Polymorphism

7. 特殊成员函数、拷贝与移动 / Special Member Functions, Copy, and Move

8. 多重继承、虚继承与菱形问题 / Multiple and Virtual Inheritance

9. RTTI、类型转换与类型擦除 / RTTI, Casts, and Type Erasure

10. 设计原则、模式与多态选择 / Design Principles, Patterns, and Polymorphism Choices

本页目录

虚函数、虚析构与运行时多态 / Virtual Functions and Runtime Polymorphism ​

1. 从一个具体困惑开始 ​

Shape* p = new Circle; p->draw(); 为什么能调用 Circle,而 p->name() 可能还是 Shape? virtual 要解释的是调用目标为什么能在运行时变化,以及这种变化要求怎样的生命周期和析构契约。 但真正的学习线索不是背关键字,而是追问对象在内存和生命周期里发生了什么。

本文采用下面这条学习线索:

text
先写一个小程序
  |
  v
编译并运行
  |
  v
记录输出或错误
  |
  v
解释术语
  |
  v
回到 C++ 规则和工程选择
1
2
3
4
5
6
7
8
9
10
11
12
13

2. 最小可运行程序 ​

先保存一个 Shape/Circle 程序,观察虚函数和普通成员函数在基类指针下的不同输出。

cpp
#include <iostream>
#include <memory>

struct Shape {
    virtual ~Shape() = default;
    virtual void draw() const = 0;
    void label() const { std::cout << "shape\n"; }
};

struct Circle : Shape {
    void draw() const override { std::cout << "circle\n"; }
    void label() const { std::cout << "circle label\n"; }
};

int main() {
    std::unique_ptr<Shape> shape = std::make_unique<Circle>();
    shape->draw();
    shape->label();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

3. 编译和运行命令要读懂 ​

下面的命令用于把本文的小程序编译成可执行文件,并在本机运行观察结果。

bash
g++ -std=c++17 -Wall -Wextra -Wpedantic -g -O0 main.cpp -o app
./app
1
2

逐项拆开看:

  • g++:GNU C++ 编译器,负责把 .cpp 源文件编译并链接成可执行程序。
  • -std=c++17:要求编译器按 C++17 标准解释示例代码;本文避免默认方言差异。
  • -Wall -Wextra -Wpedantic:打开常用、额外和标准严格性警告,让编译器尽早指出可疑写法。
  • -g:生成调试信息,后续用 gdb/lldb 查看源代码行、变量和调用栈。
  • -O0:关闭优化,调试时让源码行和机器执行过程更接近。
  • -fsanitize=address,undefined:启用 AddressSanitizer 和 UndefinedBehaviorSanitizer,运行时检查部分内存错误和未定义行为。
  • -fno-omit-frame-pointer:保留帧指针,让 sanitizer 和调试器输出更清楚的调用栈。
  • main.cpp:输入源文件;如果你的文件名不同,把这里替换成实际文件名。
  • -o app:指定输出可执行程序名为 app;Windows 下可能生成 app.exe。
  • ./app:运行当前目录下的可执行程序,观察它的标准输出和错误输出。

如果你使用 Clang,可以把 g++ 换成 clang++;如果在 Windows PowerShell 中运行,执行文件可能是 ./app.exe。 成功时,编译阶段通常没有输出,运行阶段会打印程序自己的输出。 失败时,先分清是编译错误、链接错误,还是运行时输出不符合预期。

调试版本可以进一步使用 sanitizer:

bash
g++ -std=c++17 -Wall -Wextra -g -O1 -fsanitize=address,undefined -fno-omit-frame-pointer main.cpp -o app
./app
1
2

这条命令的输入仍然是 main.cpp,输出仍然是 app,区别是可执行文件里插入了运行时检查。 Sanitizer 没有报警不等于程序必然正确;它只检查它能覆盖、且本次执行路径真正触发的错误。

4. 先观察现象,再命名术语 ​

运行结果不只是答案,它是理解对象模型的证据。 当输出和直觉不同,不要马上改代码;先问:当前表达式的静态类型是什么,真实对象是什么,生命周期是否已经开始,是否发生了复制或转换。

本文会反复使用下面这张图作为定位坐标:

text
Shape*
  |
  v
+---------------------+
| Circle object       |
|  possible vptr -----+----> Circle virtual table
|  Shape subobject    |       draw -> Circle::draw
|  Circle members     |       dtor -> Circle::~Circle
+---------------------+
1
2
3
4
5
6
7
8
9

图不是为了装饰,而是为了提醒你:源码中的类、运行时对象、子对象、隐藏实现细节和设计契约不是同一层东西。

5. 本文基础术语小注释 ​

下面这些词第一次看可能像黑话。先用朴素解释建立抓手,后面再逐步加精度。

  • virtual 函数:可通过基类指针或引用按动态类型选择最终覆盖实现的成员函数。
  • override:派生函数覆盖基类虚函数时的检查标记,签名不匹配会编译失败。
  • 纯虚函数:写成 = 0 的虚函数,表示基类要求派生类提供实现或保持抽象。
  • 抽象类:至少有一个纯虚函数未被最终覆盖,不能直接实例化的类。
  • vptr:主流 ABI 常用的隐藏指针,用来找到该对象所属动态类型的虚表。
  • vtable:主流 ABI 常用的虚函数入口表;标准规定行为,不规定表的具体布局。

术语的作用不是让文章显得专业,而是让不同问题能被准确区分。 例如“对象还占着内存”和“对象生命周期还存在”不是一回事;“能转换指针”和“转换后调用契约仍安全”也不是一回事。

6. 非虚调用和虚调用的输出差异 ​

这一节讨论 虚函数、虚析构与运行时多态 中的一个关键问题:非虚调用和虚调用的输出差异。 先把结论说成可以检查的形式,再解释它为什么成立。

text
问题表面
  |
  +-- 语法是否允许?
  +-- 对象是否完整?
  +-- 资源由谁拥有?
  +-- 调用者依赖了什么契约?
  |
  v
工程判断
1
2
3
4
5
6
7
8
9

虚函数把“选择哪个实现”从编译期延迟到运行期,但只对虚函数调用生效。 override 是给编译器的检查请求,能避免签名写错却以为已经覆盖。 多态基类若允许通过基类指针删除派生对象,析构函数通常必须是 virtual。 vptr/vtable 是主流实现解释模型,不是 ISO C++ 写死的对象布局。

可以用下面的检查顺序避免只背结论:

  • 写出当前对象的静态类型和可能的动态类型。
  • 确认是否正在创建新对象,还是通过引用/指针观察原对象。
  • 确认对象生命周期是否已经开始,是否已经结束。
  • 确认资源所有权是否唯一、共享、借用,还是根本没有表达清楚。
  • 确认失败时由谁清理,调用者能否看到错误。
  • 确认该结论是 ISO C++ 规则、主流 ABI 习惯,还是某个编译器观察结果。

这一节的常见错误通常不是“语法完全不会”,而是把几个层次混成一个词。 把层次拆开后,很多看似玄学的行为都会变成可以画图、可以编译、可以调试的事实。

一个小实验:只改动程序中的一个变量,例如把按值参数改为引用、把普通函数改成虚函数、把裸指针换成 RAII 成员。 重新编译并记录输出。 如果现象变化了,说明你找到了影响对象模型的关键边界;如果没有变化,说明问题可能不在这一层。

7. 静态类型、动态类型和 final overrider ​

这一节讨论 虚函数、虚析构与运行时多态 中的一个关键问题:静态类型、动态类型和 final overrider。 先把结论说成可以检查的形式,再解释它为什么成立。

text
问题表面
  |
  +-- 语法是否允许?
  +-- 对象是否完整?
  +-- 资源由谁拥有?
  +-- 调用者依赖了什么契约?
  |
  v
工程判断
1
2
3
4
5
6
7
8
9

虚函数把“选择哪个实现”从编译期延迟到运行期,但只对虚函数调用生效。 override 是给编译器的检查请求,能避免签名写错却以为已经覆盖。 多态基类若允许通过基类指针删除派生对象,析构函数通常必须是 virtual。 vptr/vtable 是主流实现解释模型,不是 ISO C++ 写死的对象布局。

可以用下面的检查顺序避免只背结论:

  • 写出当前对象的静态类型和可能的动态类型。
  • 确认是否正在创建新对象,还是通过引用/指针观察原对象。
  • 确认对象生命周期是否已经开始,是否已经结束。
  • 确认资源所有权是否唯一、共享、借用,还是根本没有表达清楚。
  • 确认失败时由谁清理,调用者能否看到错误。
  • 确认该结论是 ISO C++ 规则、主流 ABI 习惯,还是某个编译器观察结果。

这一节的常见错误通常不是“语法完全不会”,而是把几个层次混成一个词。 把层次拆开后,很多看似玄学的行为都会变成可以画图、可以编译、可以调试的事实。

一个小实验:只改动程序中的一个变量,例如把按值参数改为引用、把普通函数改成虚函数、把裸指针换成 RAII 成员。 重新编译并记录输出。 如果现象变化了,说明你找到了影响对象模型的关键边界;如果没有变化,说明问题可能不在这一层。

8. override 为什么应该默认写 ​

这一节讨论 虚函数、虚析构与运行时多态 中的一个关键问题:override 为什么应该默认写。 先把结论说成可以检查的形式,再解释它为什么成立。

text
问题表面
  |
  +-- 语法是否允许?
  +-- 对象是否完整?
  +-- 资源由谁拥有?
  +-- 调用者依赖了什么契约?
  |
  v
工程判断
1
2
3
4
5
6
7
8
9

虚函数把“选择哪个实现”从编译期延迟到运行期,但只对虚函数调用生效。 override 是给编译器的检查请求,能避免签名写错却以为已经覆盖。 多态基类若允许通过基类指针删除派生对象,析构函数通常必须是 virtual。 vptr/vtable 是主流实现解释模型,不是 ISO C++ 写死的对象布局。

可以用下面的检查顺序避免只背结论:

  • 写出当前对象的静态类型和可能的动态类型。
  • 确认是否正在创建新对象,还是通过引用/指针观察原对象。
  • 确认对象生命周期是否已经开始,是否已经结束。
  • 确认资源所有权是否唯一、共享、借用,还是根本没有表达清楚。
  • 确认失败时由谁清理,调用者能否看到错误。
  • 确认该结论是 ISO C++ 规则、主流 ABI 习惯,还是某个编译器观察结果。

这一节的常见错误通常不是“语法完全不会”,而是把几个层次混成一个词。 把层次拆开后,很多看似玄学的行为都会变成可以画图、可以编译、可以调试的事实。

一个小实验:只改动程序中的一个变量,例如把按值参数改为引用、把普通函数改成虚函数、把裸指针换成 RAII 成员。 重新编译并记录输出。 如果现象变化了,说明你找到了影响对象模型的关键边界;如果没有变化,说明问题可能不在这一层。

9. 纯虚函数和抽象接口 ​

这一节讨论 虚函数、虚析构与运行时多态 中的一个关键问题:纯虚函数和抽象接口。 先把结论说成可以检查的形式,再解释它为什么成立。

text
问题表面
  |
  +-- 语法是否允许?
  +-- 对象是否完整?
  +-- 资源由谁拥有?
  +-- 调用者依赖了什么契约?
  |
  v
工程判断
1
2
3
4
5
6
7
8
9

虚函数把“选择哪个实现”从编译期延迟到运行期,但只对虚函数调用生效。 override 是给编译器的检查请求,能避免签名写错却以为已经覆盖。 多态基类若允许通过基类指针删除派生对象,析构函数通常必须是 virtual。 vptr/vtable 是主流实现解释模型,不是 ISO C++ 写死的对象布局。

可以用下面的检查顺序避免只背结论:

  • 写出当前对象的静态类型和可能的动态类型。
  • 确认是否正在创建新对象,还是通过引用/指针观察原对象。
  • 确认对象生命周期是否已经开始,是否已经结束。
  • 确认资源所有权是否唯一、共享、借用,还是根本没有表达清楚。
  • 确认失败时由谁清理,调用者能否看到错误。
  • 确认该结论是 ISO C++ 规则、主流 ABI 习惯,还是某个编译器观察结果。

这一节的常见错误通常不是“语法完全不会”,而是把几个层次混成一个词。 把层次拆开后,很多看似玄学的行为都会变成可以画图、可以编译、可以调试的事实。

一个小实验:只改动程序中的一个变量,例如把按值参数改为引用、把普通函数改成虚函数、把裸指针换成 RAII 成员。 重新编译并记录输出。 如果现象变化了,说明你找到了影响对象模型的关键边界;如果没有变化,说明问题可能不在这一层。

10. 虚析构为什么和多态删除绑定 ​

这一节讨论 虚函数、虚析构与运行时多态 中的一个关键问题:虚析构为什么和多态删除绑定。 先把结论说成可以检查的形式,再解释它为什么成立。

text
问题表面
  |
  +-- 语法是否允许?
  +-- 对象是否完整?
  +-- 资源由谁拥有?
  +-- 调用者依赖了什么契约?
  |
  v
工程判断
1
2
3
4
5
6
7
8
9

虚函数把“选择哪个实现”从编译期延迟到运行期,但只对虚函数调用生效。 override 是给编译器的检查请求,能避免签名写错却以为已经覆盖。 多态基类若允许通过基类指针删除派生对象,析构函数通常必须是 virtual。 vptr/vtable 是主流实现解释模型,不是 ISO C++ 写死的对象布局。

可以用下面的检查顺序避免只背结论:

  • 写出当前对象的静态类型和可能的动态类型。
  • 确认是否正在创建新对象,还是通过引用/指针观察原对象。
  • 确认对象生命周期是否已经开始,是否已经结束。
  • 确认资源所有权是否唯一、共享、借用,还是根本没有表达清楚。
  • 确认失败时由谁清理,调用者能否看到错误。
  • 确认该结论是 ISO C++ 规则、主流 ABI 习惯,还是某个编译器观察结果。

这一节的常见错误通常不是“语法完全不会”,而是把几个层次混成一个词。 把层次拆开后,很多看似玄学的行为都会变成可以画图、可以编译、可以调试的事实。

一个小实验:只改动程序中的一个变量,例如把按值参数改为引用、把普通函数改成虚函数、把裸指针换成 RAII 成员。 重新编译并记录输出。 如果现象变化了,说明你找到了影响对象模型的关键边界;如果没有变化,说明问题可能不在这一层。

11. 构造析构期间虚调用为何不进入派生实现 ​

这一节讨论 虚函数、虚析构与运行时多态 中的一个关键问题:构造析构期间虚调用为何不进入派生实现。 先把结论说成可以检查的形式,再解释它为什么成立。

text
问题表面
  |
  +-- 语法是否允许?
  +-- 对象是否完整?
  +-- 资源由谁拥有?
  +-- 调用者依赖了什么契约?
  |
  v
工程判断
1
2
3
4
5
6
7
8
9

虚函数把“选择哪个实现”从编译期延迟到运行期,但只对虚函数调用生效。 override 是给编译器的检查请求,能避免签名写错却以为已经覆盖。 多态基类若允许通过基类指针删除派生对象,析构函数通常必须是 virtual。 vptr/vtable 是主流实现解释模型,不是 ISO C++ 写死的对象布局。

可以用下面的检查顺序避免只背结论:

  • 写出当前对象的静态类型和可能的动态类型。
  • 确认是否正在创建新对象,还是通过引用/指针观察原对象。
  • 确认对象生命周期是否已经开始,是否已经结束。
  • 确认资源所有权是否唯一、共享、借用,还是根本没有表达清楚。
  • 确认失败时由谁清理,调用者能否看到错误。
  • 确认该结论是 ISO C++ 规则、主流 ABI 习惯,还是某个编译器观察结果。

这一节的常见错误通常不是“语法完全不会”,而是把几个层次混成一个词。 把层次拆开后,很多看似玄学的行为都会变成可以画图、可以编译、可以调试的事实。

一个小实验:只改动程序中的一个变量,例如把按值参数改为引用、把普通函数改成虚函数、把裸指针换成 RAII 成员。 重新编译并记录输出。 如果现象变化了,说明你找到了影响对象模型的关键边界;如果没有变化,说明问题可能不在这一层。

12. 主流 vptr/vtable 模型怎样解释派发 ​

这一节讨论 虚函数、虚析构与运行时多态 中的一个关键问题:主流 vptr/vtable 模型怎样解释派发。 先把结论说成可以检查的形式,再解释它为什么成立。

text
问题表面
  |
  +-- 语法是否允许?
  +-- 对象是否完整?
  +-- 资源由谁拥有?
  +-- 调用者依赖了什么契约?
  |
  v
工程判断
1
2
3
4
5
6
7
8
9

虚函数把“选择哪个实现”从编译期延迟到运行期,但只对虚函数调用生效。 override 是给编译器的检查请求,能避免签名写错却以为已经覆盖。 多态基类若允许通过基类指针删除派生对象,析构函数通常必须是 virtual。 vptr/vtable 是主流实现解释模型,不是 ISO C++ 写死的对象布局。

可以用下面的检查顺序避免只背结论:

  • 写出当前对象的静态类型和可能的动态类型。
  • 确认是否正在创建新对象,还是通过引用/指针观察原对象。
  • 确认对象生命周期是否已经开始,是否已经结束。
  • 确认资源所有权是否唯一、共享、借用,还是根本没有表达清楚。
  • 确认失败时由谁清理,调用者能否看到错误。
  • 确认该结论是 ISO C++ 规则、主流 ABI 习惯,还是某个编译器观察结果。

这一节的常见错误通常不是“语法完全不会”,而是把几个层次混成一个词。 把层次拆开后,很多看似玄学的行为都会变成可以画图、可以编译、可以调试的事实。

一个小实验:只改动程序中的一个变量,例如把按值参数改为引用、把普通函数改成虚函数、把裸指针换成 RAII 成员。 重新编译并记录输出。 如果现象变化了,说明你找到了影响对象模型的关键边界;如果没有变化,说明问题可能不在这一层。

13. 虚函数的典型成本和去虚拟化 ​

这一节讨论 虚函数、虚析构与运行时多态 中的一个关键问题:虚函数的典型成本和去虚拟化。 先把结论说成可以检查的形式,再解释它为什么成立。

text
问题表面
  |
  +-- 语法是否允许?
  +-- 对象是否完整?
  +-- 资源由谁拥有?
  +-- 调用者依赖了什么契约?
  |
  v
工程判断
1
2
3
4
5
6
7
8
9

虚函数把“选择哪个实现”从编译期延迟到运行期,但只对虚函数调用生效。 override 是给编译器的检查请求,能避免签名写错却以为已经覆盖。 多态基类若允许通过基类指针删除派生对象,析构函数通常必须是 virtual。 vptr/vtable 是主流实现解释模型,不是 ISO C++ 写死的对象布局。

可以用下面的检查顺序避免只背结论:

  • 写出当前对象的静态类型和可能的动态类型。
  • 确认是否正在创建新对象,还是通过引用/指针观察原对象。
  • 确认对象生命周期是否已经开始,是否已经结束。
  • 确认资源所有权是否唯一、共享、借用,还是根本没有表达清楚。
  • 确认失败时由谁清理,调用者能否看到错误。
  • 确认该结论是 ISO C++ 规则、主流 ABI 习惯,还是某个编译器观察结果。

这一节的常见错误通常不是“语法完全不会”,而是把几个层次混成一个词。 把层次拆开后,很多看似玄学的行为都会变成可以画图、可以编译、可以调试的事实。

一个小实验:只改动程序中的一个变量,例如把按值参数改为引用、把普通函数改成虚函数、把裸指针换成 RAII 成员。 重新编译并记录输出。 如果现象变化了,说明你找到了影响对象模型的关键边界;如果没有变化,说明问题可能不在这一层。

14. 对象切片如何破坏运行时多态 ​

这一节讨论 虚函数、虚析构与运行时多态 中的一个关键问题:对象切片如何破坏运行时多态。 先把结论说成可以检查的形式,再解释它为什么成立。

text
问题表面
  |
  +-- 语法是否允许?
  +-- 对象是否完整?
  +-- 资源由谁拥有?
  +-- 调用者依赖了什么契约?
  |
  v
工程判断
1
2
3
4
5
6
7
8
9

虚函数把“选择哪个实现”从编译期延迟到运行期,但只对虚函数调用生效。 override 是给编译器的检查请求,能避免签名写错却以为已经覆盖。 多态基类若允许通过基类指针删除派生对象,析构函数通常必须是 virtual。 vptr/vtable 是主流实现解释模型,不是 ISO C++ 写死的对象布局。

可以用下面的检查顺序避免只背结论:

  • 写出当前对象的静态类型和可能的动态类型。
  • 确认是否正在创建新对象,还是通过引用/指针观察原对象。
  • 确认对象生命周期是否已经开始,是否已经结束。
  • 确认资源所有权是否唯一、共享、借用,还是根本没有表达清楚。
  • 确认失败时由谁清理,调用者能否看到错误。
  • 确认该结论是 ISO C++ 规则、主流 ABI 习惯,还是某个编译器观察结果。

这一节的常见错误通常不是“语法完全不会”,而是把几个层次混成一个词。 把层次拆开后,很多看似玄学的行为都会变成可以画图、可以编译、可以调试的事实。

一个小实验:只改动程序中的一个变量,例如把按值参数改为引用、把普通函数改成虚函数、把裸指针换成 RAII 成员。 重新编译并记录输出。 如果现象变化了,说明你找到了影响对象模型的关键边界;如果没有变化,说明问题可能不在这一层。

15. 调试虚调用和调用栈的方法 ​

这一节讨论 虚函数、虚析构与运行时多态 中的一个关键问题:调试虚调用和调用栈的方法。 先把结论说成可以检查的形式,再解释它为什么成立。

text
问题表面
  |
  +-- 语法是否允许?
  +-- 对象是否完整?
  +-- 资源由谁拥有?
  +-- 调用者依赖了什么契约?
  |
  v
工程判断
1
2
3
4
5
6
7
8
9

虚函数把“选择哪个实现”从编译期延迟到运行期,但只对虚函数调用生效。 override 是给编译器的检查请求,能避免签名写错却以为已经覆盖。 多态基类若允许通过基类指针删除派生对象,析构函数通常必须是 virtual。 vptr/vtable 是主流实现解释模型,不是 ISO C++ 写死的对象布局。

可以用下面的检查顺序避免只背结论:

  • 写出当前对象的静态类型和可能的动态类型。
  • 确认是否正在创建新对象,还是通过引用/指针观察原对象。
  • 确认对象生命周期是否已经开始,是否已经结束。
  • 确认资源所有权是否唯一、共享、借用,还是根本没有表达清楚。
  • 确认失败时由谁清理,调用者能否看到错误。
  • 确认该结论是 ISO C++ 规则、主流 ABI 习惯,还是某个编译器观察结果。

这一节的常见错误通常不是“语法完全不会”,而是把几个层次混成一个词。 把层次拆开后,很多看似玄学的行为都会变成可以画图、可以编译、可以调试的事实。

一个小实验:只改动程序中的一个变量,例如把按值参数改为引用、把普通函数改成虚函数、把裸指针换成 RAII 成员。 重新编译并记录输出。 如果现象变化了,说明你找到了影响对象模型的关键边界;如果没有变化,说明问题可能不在这一层。

16. 主题化实验:虚函数与运行时多态 要用证据说话 ​

virtual 的核心不是神秘跳转,而是通过静态类型保留动态对象行为的契约。

这一节专门把前面的规则落到可观察实验里。不要把实验理解成刷题,它的作用是让你知道自己到底在观察哪一层:源码规则、对象状态、ABI 现象,还是工程契约。

ext 提出一个问题 | v 写一个最小程序 | v 只改变一个因素 | v 重新编译运行 | v 记录输出、警告、地址、大小或调用栈 | v 把结果归因到具体 C++ 规则

在 虚函数与运行时多态 里,最容易混淆的是下面这些词:

` ext 虚函数 | +-- 先问它描述源码关系、对象状态,还是运行期行为

动态类型 | +-- 先问它影响谁负责创建、谁负责销毁、谁负责调用

vptr | +-- 先问它是语言保证,还是某个 ABI 上的观察结果

vtable | +-- 先问它失败时会编译报错、运行输出错,还是资源释放错

虚析构 | +-- 先问它适合作为接口承诺,还是只适合作为实现细节 `

16.1 第一个实验:先固定一个对象 ​

先写最小程序,只保留一个对象、一个调用点、一个输出点。 这样做的目的不是让程序真实,而是排除无关干扰。 如果最小程序都解释不清,在大项目里继续猜只会把问题藏得更深。

`cpp #include <iostream>

struct Probe { int value{1};

void touch() {
    ++value;
    std::cout << "value = " << value << '\n';
}

};

int main() { Probe p; p.touch(); p.touch(); } `

这段程序适合观察对象状态如何被成员函数改变。 如果本文主题涉及继承、多态或特殊成员函数,可以在这个程序上逐步增加一个因素。 每次只增加一个因素,才知道现象从哪里来。

编译命令:

ash g++ -std=c++17 -Wall -Wextra -Wpedantic -g -O0 main.cpp -o app ./app

这条命令只包含当前示例实际使用的选项。 g++ 调用 GNU C++ 编译器。 -std=c++17 固定语言标准。 -Wall -Wextra -Wpedantic 打开警告。 -g 生成调试信息。 -O0 关闭优化,方便把运行现象对应回源码。 main.cpp 是输入文件。 -o app 指定输出可执行文件。 ./app 运行当前目录的程序。

如果需要 sanitizer,要显式换成另一条命令:

ash g++ -std=c++17 -Wall -Wextra -Wpedantic -g -O1 -fsanitize=address,undefined -fno-omit-frame-pointer main.cpp -o app ./app

这里 -fsanitize=address,undefined 才是真的打开内存错误和部分未定义行为检查。 不要在没有写进命令时说自己已经启用了 sanitizer。

16.2 第二个实验:把值传递改成引用 ​

很多对象模型问题的分界点都藏在“是否复制对象”里。 值传递会产生一个新对象,引用传递则观察原对象。

`cpp #include <iostream>

struct Item { int id{0}; };

void by_value(Item item) { item.id = 100; std::cout << "inside value: " << item.id << '\n'; }

void by_ref(Item& item) { item.id = 200; std::cout << "inside ref: " << item.id << '\n'; }

int main() { Item x{1}; by_value(x); std::cout << "after value: " << x.id << '\n'; by_ref(x); std::cout << "after ref: " << x.id << '\n'; } `

观察点:

` ext by_value | +-- 形参是一个新对象 +-- 修改形参不修改原对象

by_ref | +-- 形参绑定原对象 +-- 修改形参会影响调用者看到的对象状态 `

把这个实验迁移到 虚函数与运行时多态 时,要问:当前接口到底想复制、借用,还是拥有? 如果答案说不清,类的设计通常也说不清。

16.3 第三个实验:记录构造和析构顺序 ​

对象模型不是只有调用,还有创建和销毁。 给构造和析构加日志,可以把很多隐藏路径变成可见路径。

`cpp #include <iostream>

struct Trace { const char* name;

explicit Trace(const char* n) : name(n) {
    std::cout << "construct " << name << '\n';
}

~Trace() {
    std::cout << "destroy " << name << '\n';
}

};

int main() { Trace first{"first"}; Trace second{"second"}; } `

预期现象:

ext construct first construct second destroy second destroy first

这说明局部对象通常按构造的相反顺序析构。 如果本文主题涉及成员对象、基类子对象或异常,继续在这个程序上加字段和继承关系。 不要直接跳到复杂业务对象。

16.4 第四个实验:把调试器接进来 ​

只看输出有时不够。 调试器可以让程序停在关键点,查看变量和调用栈。

典型动作:

` ext break main 在 main 函数入口设置断点

run 启动程序

next 执行下一行,不进入函数内部

step 进入当前调用的函数

print object 查看对象当前状态

backtrace 查看调用栈 `

调试器不是为了炫技。 它的作用是回答:

ext 当前到底调用了哪个函数? 当前对象的地址是什么? 当前观察的是完整对象还是基类子对象? 对象是在什么时候构造和析构的?

如果你正在研究 vptr,调试器尤其有用。 因为这类问题经常不是源码一眼能看出来的。

17. 常见误解:把词背熟不等于会设计 ​

下面这些误解在 虚函数与运行时多态 中很常见。 它们不一定立刻导致编译错误,却会慢慢变成难查的设计问题。

` ext 误解 1:只要能编译,对象关系就是正确的 实际:编译只检查语法和类型,不检查你的设计意图是否合理

误解 2:类的源码长什么样,对象内存就一定长什么样 实际:布局受成员顺序、对齐、继承和 ABI 影响

误解 3:引用和指针只是写法不同 实际:它们表达的可空性、重绑定能力和调用契约不同

误解 4:继承天然比组合高级 实际:继承暴露替换关系和基类接口,组合通常更局部、更可控

误解 5:虚函数就是运行时多态的全部 实际:多态还涉及生命周期、所有权、虚析构、对象切片和接口边界

误解 6:调试对象问题只要打印几个值 实际:还要观察地址、生命周期、调用栈和构造析构顺序 `

判断一个解释是否真正可靠,可以用三句话检查:

ext 我能画出对象之间的关系吗? 我能写出最小程序复现吗? 我能指出这属于语言规则、库规则、ABI 观察,还是工程约定吗?

如果三句话答不上来,说明还需要回到实验。

18. 复查清单:写类之前先问边界 ​

设计一个类或一组对象关系前,先问这些问题:

` ext

  1. 这个对象拥有资源吗?
  2. 它可以被复制吗?复制后两个对象是否独立?
  3. 它可以被移动吗?移动后源对象处于什么有效状态?
  4. 它是否打算作为基类使用?
  5. 如果通过基类指针删除,析构函数是否应该 virtual?
  6. 它的构造函数是否能建立完整不变量?
  7. 它的成员函数是否会破坏不变量?
  8. 调用者拿到的是值、引用、指针,还是智能指针?
  9. 失败路径是否会泄漏资源或留下半初始化对象?
  10. 是否能用一个小程序验证关键行为? `

这些问题看起来慢,但它们能提前挡住很多后期调试成本。 尤其在 虚函数与运行时多态 里,真正重要的不是多写几个类,而是让对象关系能被解释、能被验证、能被维护。

19. 本文总结 ​

虚函数与运行时多态 的核心不是记住一个孤立语法,而是建立一条从源码到运行期的解释链。

ext 源码写法 | v 类型检查 | v 对象创建 | v 成员访问或函数调用 | v 生命周期结束 | v 资源和不变量被检查

当你遇到面向对象问题时,不要只问“这个关键字怎么用”。 更好的问题是:

ext 谁拥有对象? 谁能观察对象? 对象什么时候有效? 调用目标由静态类型还是动态类型决定? 失败时资源和不变量是否还能保持正确?

如果能围绕这些问题解释 虚函数、动态类型、vptr、vtable 和 虚析构,这篇文章的目标就达到了。

下一篇或下一轮学习时,请继续沿用同样方法:先观察一个小程序,再把现象挂回对象边界和语言规则。

20. 主题案例复盘:从真实设计问题回到小程序 ​

渲染引擎里一组 Shape 通过基类接口绘制,如果析构函数不是 virtual,删除派生对象时资源释放就可能不完整。

复盘时不要急着给结论。先把真实问题拆成三个层次:

ext 业务意图 | +-- 我想表达什么关系?拥有、借用、替换、共享,还是隐藏? | v C++ 接口 | +-- 我用值、引用、指针、智能指针、虚函数、模板,还是组合? | v 运行期事实 | +-- 对象在哪里?谁构造?谁析构?是否复制?调用目标如何决定?

20.1 先写出接口承诺 ​

把类写出来之前,先用一句话描述接口承诺。 如果这句话写不清楚,代码通常也会变得含糊。

ext 这个类型负责拥有资源吗? 这个类型允许被复制吗? 这个类型是否允许通过基类接口使用? 这个类型的析构是否必须经由基类指针正确发生? 这个类型的状态是否只能通过成员函数维护?

对 虚函数与运行时多态 这类主题,接口承诺比语法选择更早。 语法只是把承诺落到编译器能检查的形式。

20.2 再写出对象关系图 ​

用 ASCII 画图能强迫你区分“对象”和“指向对象的东西”。

ext caller | +-- owns value object | | | +-- member state | +-- lifetime tied to scope | +-- borrows polymorphic object | +-- static type controls visible interface +-- dynamic type controls virtual dispatch

如果图里每条线都写成“uses”,说明关系还没有想清楚。 可以进一步标注:

` ext owns 表示负责销毁或释放资源

borrows 表示临时观察,不负责销毁

refers-to-base 表示只看见基类接口

contains 表示成员对象生命周期绑定到外层对象

inherits 表示派生对象包含基类子对象,并承诺可替换使用 `

20.3 最后选择 C++ 机制 ​

常见选择可以这样对照:

` ext 只需要稳定拥有一个成员 -> composition

需要表达可替换接口 -> public inheritance + virtual functions

需要共享只读策略或配置 -> reference / shared immutable object

需要独占资源 -> RAII member / std::unique_ptr

需要隐藏实现细节 -> pImpl or type erasure

需要编译期多态 -> templates / concepts `

这些选择没有绝对排名。 它们分别优化不同东西:局部性、可替换性、编译期检查、运行期扩展、二进制稳定性、测试便利性。

20.4 把错误路径写出来 ​

只写正确路径是不够的。 对象设计真正的压力来自错误路径:

ext 构造失败怎么办? 复制中途失败怎么办? 移动后源对象还能做什么? 派生对象析构是否完整? 基类引用是否会切掉派生部分? 回调保存的对象是否已经销毁?

如果错误路径写不出来,说明这段设计还不能进入真实项目。

20.5 练习:把一个大类拆成关系图 ​

找一个你以前写过的大类,按下面格式拆:

ext ClassName | +-- owns: | +-- resource or member objects | +-- borrows: | +-- external services or temporary views | +-- exposes: | +-- public operations | +-- protects: | +-- invariants | +-- varies by: +-- strategy, implementation, platform, format, policy

拆完后问:

ext 哪些成员其实应该组合出去? 哪些函数破坏了封装? 哪些继承关系只是为了复用代码? 哪些指针没有表达所有权? 哪些析构路径靠人脑记忆?

这组问题比“这里该不该用某个设计模式”更有价值。

21. 延伸实验:让编译器、调试器和日志一起说话 ​

学习 虚函数与运行时多态 时,建议固定三类证据。

第一类是编译期证据:

` ext warning 编译器觉得可疑,但仍允许生成程序

error 违反语法、类型或约束,不能生成程序

static_assert 把你自己的前提写成编译期检查 `

第二类是运行期证据:

ext 构造日志 析构日志 拷贝日志 移动日志 对象地址 sizeof / alignof 虚调用输出 异常路径输出

第三类是工具证据:

ext debugger backtrace sanitizer report nm / objdump 符号观察 编译器诊断信息 单元测试失败信息

把三类证据合在一起,才能避免只凭感觉解释对象行为。

一个推荐记录格式:

` ext 问题: 我原本以为会发生什么?

程序: 最小 main.cpp 是什么?

命令: 我用什么编译选项?

现象: 实际输出、警告或错误是什么?

归因: 这是语言规则、库规则、ABI 观察,还是我的设计假设?

修正: 我应该改接口、改所有权、改继承关系,还是改调用方式? `

这就是从“看懂一段代码”走向“能维护一个 C++ 对象系统”的关键步骤。

最后更新于:

Pager
上一篇5. 封装、组合与继承 / Encapsulation, Composition, and Inheritance
下一篇7. 特殊成员函数、拷贝与移动 / Special Member Functions, Copy, and Move

持续记录,持续成长

Copyright © Tidenflow