RTTI、类型转换与类型擦除 / RTTI, Casts, and Type Erasure
1. 从一个具体困惑开始
Base* 里可能是 Circle,也可能是 Square。什么时候可以安全地问它到底是什么? RTTI 和类型擦除都在处理静态类型看不见的东西,只是一个选择运行期询问,一个选择隐藏具体类型。 但真正的学习线索不是背关键字,而是追问对象在内存和生命周期里发生了什么。
本文采用下面这条学习线索:
先写一个小程序
|
v
编译并运行
|
v
记录输出或错误
|
v
解释术语
|
v
回到 C++ 规则和工程选择2. 最小可运行程序
先写一个 dynamic_cast 失败的小程序,观察失败时指针和引用的行为差异。
#include <iostream>
#include <memory>
#include <typeinfo>
struct Shape {
virtual ~Shape() = default;
};
struct Circle : Shape { void radius() const { std::cout << "circle\n"; } };
struct Square : Shape {};
int main() {
std::unique_ptr<Shape> shape = std::make_unique<Square>();
if (auto* circle = dynamic_cast<Circle*>(shape.get())) {
circle->radius();
} else {
std::cout << "not a circle: " << typeid(*shape).name() << '\n';
}
}3. 编译和运行命令要读懂
下面的命令用于把本文的小程序编译成可执行文件,并在本机运行观察结果。
g++ -std=c++17 -Wall -Wextra -Wpedantic -g -O0 main.cpp -o app
./app逐项拆开看:
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:
g++ -std=c++17 -Wall -Wextra -g -O1 -fsanitize=address,undefined -fno-omit-frame-pointer main.cpp -o app
./app这条命令的输入仍然是 main.cpp,输出仍然是 app,区别是可执行文件里插入了运行时检查。 Sanitizer 没有报警不等于程序必然正确;它只检查它能覆盖、且本次执行路径真正触发的错误。
4. 先观察现象,再命名术语
运行结果不只是答案,它是理解对象模型的证据。 当输出和直觉不同,不要马上改代码;先问:当前表达式的静态类型是什么,真实对象是什么,生命周期是否已经开始,是否发生了复制或转换。
本文会反复使用下面这张图作为定位坐标:
caller owns erased handle
|
v
+--------------------+
| wrapper object |
| storage/pointer | ----> concrete T
| operation table | ----> call copy/destroy/invoke for T
+--------------------+
caller sees only the wrapper contract, not T's type name.图不是为了装饰,而是为了提醒你:源码中的类、运行时对象、子对象、隐藏实现细节和设计契约不是同一层东西。
5. 本文基础术语小注释
下面这些词第一次看可能像黑话。先用朴素解释建立抓手,后面再逐步加精度。
- RTTI:Run-Time Type Information,C++ 为多态类型保留的运行时类型信息,用于 typeid 和 dynamic_cast。
- 向下转型 downcast:从基类指针或引用尝试转成某个派生类型。
- static_cast:按编译期可证明或程序员承诺的关系转换,不做运行时类型检查。
- dynamic_cast:对多态层次做运行时安全检查,指针失败返回 nullptr,引用失败抛 bad_cast。
- reinterpret_cast:低级重新解释位模式或地址的转换,几乎不表达对象语义。
- 类型擦除 type erasure:把不同具体类型包装成同一个非模板接口,同时保留需要的操作。
术语的作用不是让文章显得专业,而是让不同问题能被准确区分。 例如“对象还占着内存”和“对象生命周期还存在”不是一回事;“能转换指针”和“转换后调用契约仍安全”也不是一回事。
6. 失败 downcast 的最小复现
这一节讨论 RTTI、类型转换与类型擦除 中的一个关键问题:失败 downcast 的最小复现。 先把结论说成可以检查的形式,再解释它为什么成立。
问题表面
|
+-- 语法是否允许?
+-- 对象是否完整?
+-- 资源由谁拥有?
+-- 调用者依赖了什么契约?
|
v
工程判断RTTI 是运行时类型信息,主要服务于多态对象的 typeid 和 dynamic_cast。 dynamic_cast 失败方式很重要:指针返回空,引用抛异常。 命名 cast 不是为了让强转更帅,而是把风险类型写出来。 类型擦除的目标是隐藏具体类型,同时保留一组可调用操作。
可以用下面的检查顺序避免只背结论:
- 写出当前对象的静态类型和可能的动态类型。
- 确认是否正在创建新对象,还是通过引用/指针观察原对象。
- 确认对象生命周期是否已经开始,是否已经结束。
- 确认资源所有权是否唯一、共享、借用,还是根本没有表达清楚。
- 确认失败时由谁清理,调用者能否看到错误。
- 确认该结论是 ISO C++ 规则、主流 ABI 习惯,还是某个编译器观察结果。
这一节的常见错误通常不是“语法完全不会”,而是把几个层次混成一个词。 把层次拆开后,很多看似玄学的行为都会变成可以画图、可以编译、可以调试的事实。
一个小实验:只改动程序中的一个变量,例如把按值参数改为引用、把普通函数改成虚函数、把裸指针换成 RAII 成员。 重新编译并记录输出。 如果现象变化了,说明你找到了影响对象模型的关键边界;如果没有变化,说明问题可能不在这一层。
7. RTTI 需要多态类型才能发挥关键作用
这一节讨论 RTTI、类型转换与类型擦除 中的一个关键问题:RTTI 需要多态类型才能发挥关键作用。 先把结论说成可以检查的形式,再解释它为什么成立。
问题表面
|
+-- 语法是否允许?
+-- 对象是否完整?
+-- 资源由谁拥有?
+-- 调用者依赖了什么契约?
|
v
工程判断RTTI 是运行时类型信息,主要服务于多态对象的 typeid 和 dynamic_cast。 dynamic_cast 失败方式很重要:指针返回空,引用抛异常。 命名 cast 不是为了让强转更帅,而是把风险类型写出来。 类型擦除的目标是隐藏具体类型,同时保留一组可调用操作。
可以用下面的检查顺序避免只背结论:
- 写出当前对象的静态类型和可能的动态类型。
- 确认是否正在创建新对象,还是通过引用/指针观察原对象。
- 确认对象生命周期是否已经开始,是否已经结束。
- 确认资源所有权是否唯一、共享、借用,还是根本没有表达清楚。
- 确认失败时由谁清理,调用者能否看到错误。
- 确认该结论是 ISO C++ 规则、主流 ABI 习惯,还是某个编译器观察结果。
这一节的常见错误通常不是“语法完全不会”,而是把几个层次混成一个词。 把层次拆开后,很多看似玄学的行为都会变成可以画图、可以编译、可以调试的事实。
一个小实验:只改动程序中的一个变量,例如把按值参数改为引用、把普通函数改成虚函数、把裸指针换成 RAII 成员。 重新编译并记录输出。 如果现象变化了,说明你找到了影响对象模型的关键边界;如果没有变化,说明问题可能不在这一层。
8. typeid 对指针、引用和非多态对象的差异
这一节讨论 RTTI、类型转换与类型擦除 中的一个关键问题:typeid 对指针、引用和非多态对象的差异。 先把结论说成可以检查的形式,再解释它为什么成立。
问题表面
|
+-- 语法是否允许?
+-- 对象是否完整?
+-- 资源由谁拥有?
+-- 调用者依赖了什么契约?
|
v
工程判断RTTI 是运行时类型信息,主要服务于多态对象的 typeid 和 dynamic_cast。 dynamic_cast 失败方式很重要:指针返回空,引用抛异常。 命名 cast 不是为了让强转更帅,而是把风险类型写出来。 类型擦除的目标是隐藏具体类型,同时保留一组可调用操作。
可以用下面的检查顺序避免只背结论:
- 写出当前对象的静态类型和可能的动态类型。
- 确认是否正在创建新对象,还是通过引用/指针观察原对象。
- 确认对象生命周期是否已经开始,是否已经结束。
- 确认资源所有权是否唯一、共享、借用,还是根本没有表达清楚。
- 确认失败时由谁清理,调用者能否看到错误。
- 确认该结论是 ISO C++ 规则、主流 ABI 习惯,还是某个编译器观察结果。
这一节的常见错误通常不是“语法完全不会”,而是把几个层次混成一个词。 把层次拆开后,很多看似玄学的行为都会变成可以画图、可以编译、可以调试的事实。
一个小实验:只改动程序中的一个变量,例如把按值参数改为引用、把普通函数改成虚函数、把裸指针换成 RAII 成员。 重新编译并记录输出。 如果现象变化了,说明你找到了影响对象模型的关键边界;如果没有变化,说明问题可能不在这一层。
9. dynamic_cast 指针和引用的失败方式
这一节讨论 RTTI、类型转换与类型擦除 中的一个关键问题:dynamic_cast 指针和引用的失败方式。 先把结论说成可以检查的形式,再解释它为什么成立。
问题表面
|
+-- 语法是否允许?
+-- 对象是否完整?
+-- 资源由谁拥有?
+-- 调用者依赖了什么契约?
|
v
工程判断RTTI 是运行时类型信息,主要服务于多态对象的 typeid 和 dynamic_cast。 dynamic_cast 失败方式很重要:指针返回空,引用抛异常。 命名 cast 不是为了让强转更帅,而是把风险类型写出来。 类型擦除的目标是隐藏具体类型,同时保留一组可调用操作。
可以用下面的检查顺序避免只背结论:
- 写出当前对象的静态类型和可能的动态类型。
- 确认是否正在创建新对象,还是通过引用/指针观察原对象。
- 确认对象生命周期是否已经开始,是否已经结束。
- 确认资源所有权是否唯一、共享、借用,还是根本没有表达清楚。
- 确认失败时由谁清理,调用者能否看到错误。
- 确认该结论是 ISO C++ 规则、主流 ABI 习惯,还是某个编译器观察结果。
这一节的常见错误通常不是“语法完全不会”,而是把几个层次混成一个词。 把层次拆开后,很多看似玄学的行为都会变成可以画图、可以编译、可以调试的事实。
一个小实验:只改动程序中的一个变量,例如把按值参数改为引用、把普通函数改成虚函数、把裸指针换成 RAII 成员。 重新编译并记录输出。 如果现象变化了,说明你找到了影响对象模型的关键边界;如果没有变化,说明问题可能不在这一层。
10. static_cast 什么时候安全,什么时候只是赌
这一节讨论 RTTI、类型转换与类型擦除 中的一个关键问题:static_cast 什么时候安全,什么时候只是赌。 先把结论说成可以检查的形式,再解释它为什么成立。
问题表面
|
+-- 语法是否允许?
+-- 对象是否完整?
+-- 资源由谁拥有?
+-- 调用者依赖了什么契约?
|
v
工程判断RTTI 是运行时类型信息,主要服务于多态对象的 typeid 和 dynamic_cast。 dynamic_cast 失败方式很重要:指针返回空,引用抛异常。 命名 cast 不是为了让强转更帅,而是把风险类型写出来。 类型擦除的目标是隐藏具体类型,同时保留一组可调用操作。
可以用下面的检查顺序避免只背结论:
- 写出当前对象的静态类型和可能的动态类型。
- 确认是否正在创建新对象,还是通过引用/指针观察原对象。
- 确认对象生命周期是否已经开始,是否已经结束。
- 确认资源所有权是否唯一、共享、借用,还是根本没有表达清楚。
- 确认失败时由谁清理,调用者能否看到错误。
- 确认该结论是 ISO C++ 规则、主流 ABI 习惯,还是某个编译器观察结果。
这一节的常见错误通常不是“语法完全不会”,而是把几个层次混成一个词。 把层次拆开后,很多看似玄学的行为都会变成可以画图、可以编译、可以调试的事实。
一个小实验:只改动程序中的一个变量,例如把按值参数改为引用、把普通函数改成虚函数、把裸指针换成 RAII 成员。 重新编译并记录输出。 如果现象变化了,说明你找到了影响对象模型的关键边界;如果没有变化,说明问题可能不在这一层。
11. const_cast 和 const 正确性
这一节讨论 RTTI、类型转换与类型擦除 中的一个关键问题:const_cast 和 const 正确性。 先把结论说成可以检查的形式,再解释它为什么成立。
问题表面
|
+-- 语法是否允许?
+-- 对象是否完整?
+-- 资源由谁拥有?
+-- 调用者依赖了什么契约?
|
v
工程判断RTTI 是运行时类型信息,主要服务于多态对象的 typeid 和 dynamic_cast。 dynamic_cast 失败方式很重要:指针返回空,引用抛异常。 命名 cast 不是为了让强转更帅,而是把风险类型写出来。 类型擦除的目标是隐藏具体类型,同时保留一组可调用操作。
可以用下面的检查顺序避免只背结论:
- 写出当前对象的静态类型和可能的动态类型。
- 确认是否正在创建新对象,还是通过引用/指针观察原对象。
- 确认对象生命周期是否已经开始,是否已经结束。
- 确认资源所有权是否唯一、共享、借用,还是根本没有表达清楚。
- 确认失败时由谁清理,调用者能否看到错误。
- 确认该结论是 ISO C++ 规则、主流 ABI 习惯,还是某个编译器观察结果。
这一节的常见错误通常不是“语法完全不会”,而是把几个层次混成一个词。 把层次拆开后,很多看似玄学的行为都会变成可以画图、可以编译、可以调试的事实。
一个小实验:只改动程序中的一个变量,例如把按值参数改为引用、把普通函数改成虚函数、把裸指针换成 RAII 成员。 重新编译并记录输出。 如果现象变化了,说明你找到了影响对象模型的关键边界;如果没有变化,说明问题可能不在这一层。
12. reinterpret_cast 为什么通常是最后选择
这一节讨论 RTTI、类型转换与类型擦除 中的一个关键问题:reinterpret_cast 为什么通常是最后选择。 先把结论说成可以检查的形式,再解释它为什么成立。
问题表面
|
+-- 语法是否允许?
+-- 对象是否完整?
+-- 资源由谁拥有?
+-- 调用者依赖了什么契约?
|
v
工程判断RTTI 是运行时类型信息,主要服务于多态对象的 typeid 和 dynamic_cast。 dynamic_cast 失败方式很重要:指针返回空,引用抛异常。 命名 cast 不是为了让强转更帅,而是把风险类型写出来。 类型擦除的目标是隐藏具体类型,同时保留一组可调用操作。
可以用下面的检查顺序避免只背结论:
- 写出当前对象的静态类型和可能的动态类型。
- 确认是否正在创建新对象,还是通过引用/指针观察原对象。
- 确认对象生命周期是否已经开始,是否已经结束。
- 确认资源所有权是否唯一、共享、借用,还是根本没有表达清楚。
- 确认失败时由谁清理,调用者能否看到错误。
- 确认该结论是 ISO C++ 规则、主流 ABI 习惯,还是某个编译器观察结果。
这一节的常见错误通常不是“语法完全不会”,而是把几个层次混成一个词。 把层次拆开后,很多看似玄学的行为都会变成可以画图、可以编译、可以调试的事实。
一个小实验:只改动程序中的一个变量,例如把按值参数改为引用、把普通函数改成虚函数、把裸指针换成 RAII 成员。 重新编译并记录输出。 如果现象变化了,说明你找到了影响对象模型的关键边界;如果没有变化,说明问题可能不在这一层。
13. std::function、std::any、std::shared_ptr deleter 中的类型擦除
这一节讨论 RTTI、类型转换与类型擦除 中的一个关键问题:std::function、std::any、std::shared_ptr deleter 中的类型擦除。 先把结论说成可以检查的形式,再解释它为什么成立。
问题表面
|
+-- 语法是否允许?
+-- 对象是否完整?
+-- 资源由谁拥有?
+-- 调用者依赖了什么契约?
|
v
工程判断RTTI 是运行时类型信息,主要服务于多态对象的 typeid 和 dynamic_cast。 dynamic_cast 失败方式很重要:指针返回空,引用抛异常。 命名 cast 不是为了让强转更帅,而是把风险类型写出来。 类型擦除的目标是隐藏具体类型,同时保留一组可调用操作。
可以用下面的检查顺序避免只背结论:
- 写出当前对象的静态类型和可能的动态类型。
- 确认是否正在创建新对象,还是通过引用/指针观察原对象。
- 确认对象生命周期是否已经开始,是否已经结束。
- 确认资源所有权是否唯一、共享、借用,还是根本没有表达清楚。
- 确认失败时由谁清理,调用者能否看到错误。
- 确认该结论是 ISO C++ 规则、主流 ABI 习惯,还是某个编译器观察结果。
这一节的常见错误通常不是“语法完全不会”,而是把几个层次混成一个词。 把层次拆开后,很多看似玄学的行为都会变成可以画图、可以编译、可以调试的事实。
一个小实验:只改动程序中的一个变量,例如把按值参数改为引用、把普通函数改成虚函数、把裸指针换成 RAII 成员。 重新编译并记录输出。 如果现象变化了,说明你找到了影响对象模型的关键边界;如果没有变化,说明问题可能不在这一层。
14. 手写类型擦除的对象表模型
这一节讨论 RTTI、类型转换与类型擦除 中的一个关键问题:手写类型擦除的对象表模型。 先把结论说成可以检查的形式,再解释它为什么成立。
问题表面
|
+-- 语法是否允许?
+-- 对象是否完整?
+-- 资源由谁拥有?
+-- 调用者依赖了什么契约?
|
v
工程判断RTTI 是运行时类型信息,主要服务于多态对象的 typeid 和 dynamic_cast。 dynamic_cast 失败方式很重要:指针返回空,引用抛异常。 命名 cast 不是为了让强转更帅,而是把风险类型写出来。 类型擦除的目标是隐藏具体类型,同时保留一组可调用操作。
可以用下面的检查顺序避免只背结论:
- 写出当前对象的静态类型和可能的动态类型。
- 确认是否正在创建新对象,还是通过引用/指针观察原对象。
- 确认对象生命周期是否已经开始,是否已经结束。
- 确认资源所有权是否唯一、共享、借用,还是根本没有表达清楚。
- 确认失败时由谁清理,调用者能否看到错误。
- 确认该结论是 ISO C++ 规则、主流 ABI 习惯,还是某个编译器观察结果。
这一节的常见错误通常不是“语法完全不会”,而是把几个层次混成一个词。 把层次拆开后,很多看似玄学的行为都会变成可以画图、可以编译、可以调试的事实。
一个小实验:只改动程序中的一个变量,例如把按值参数改为引用、把普通函数改成虚函数、把裸指针换成 RAII 成员。 重新编译并记录输出。 如果现象变化了,说明你找到了影响对象模型的关键边界;如果没有变化,说明问题可能不在这一层。
15. 用 cast 气味反推设计问题
这一节讨论 RTTI、类型转换与类型擦除 中的一个关键问题:用 cast 气味反推设计问题。 先把结论说成可以检查的形式,再解释它为什么成立。
问题表面
|
+-- 语法是否允许?
+-- 对象是否完整?
+-- 资源由谁拥有?
+-- 调用者依赖了什么契约?
|
v
工程判断RTTI 是运行时类型信息,主要服务于多态对象的 typeid 和 dynamic_cast。 dynamic_cast 失败方式很重要:指针返回空,引用抛异常。 命名 cast 不是为了让强转更帅,而是把风险类型写出来。 类型擦除的目标是隐藏具体类型,同时保留一组可调用操作。
可以用下面的检查顺序避免只背结论:
- 写出当前对象的静态类型和可能的动态类型。
- 确认是否正在创建新对象,还是通过引用/指针观察原对象。
- 确认对象生命周期是否已经开始,是否已经结束。
- 确认资源所有权是否唯一、共享、借用,还是根本没有表达清楚。
- 确认失败时由谁清理,调用者能否看到错误。
- 确认该结论是 ISO C++ 规则、主流 ABI 习惯,还是某个编译器观察结果。
这一节的常见错误通常不是“语法完全不会”,而是把几个层次混成一个词。 把层次拆开后,很多看似玄学的行为都会变成可以画图、可以编译、可以调试的事实。
一个小实验:只改动程序中的一个变量,例如把按值参数改为引用、把普通函数改成虚函数、把裸指针换成 RAII 成员。 重新编译并记录输出。 如果现象变化了,说明你找到了影响对象模型的关键边界;如果没有变化,说明问题可能不在这一层。
16. 主题化实验:RTTI、cast 与类型擦除 要用证据说话
向下转型和类型擦除都在回答同一个问题:静态类型不够时,运行期信息由谁保存、谁检查。
这一节专门把前面的规则落到可观察实验里。不要把实验理解成刷题,它的作用是让你知道自己到底在观察哪一层:源码规则、对象状态、ABI 现象,还是工程契约。
ext 提出一个问题 | v 写一个最小程序 | v 只改变一个因素 | v 重新编译运行 | v 记录输出、警告、地址、大小或调用栈 | v 把结果归因到具体 C++ 规则
在 RTTI、cast 与类型擦除 里,最容易混淆的是下面这些词:
` ext RTTI | +-- 先问它描述源码关系、对象状态,还是运行期行为
dynamic_cast | +-- 先问它影响谁负责创建、谁负责销毁、谁负责调用
static_cast | +-- 先问它是语言保证,还是某个 ABI 上的观察结果
type erasure | +-- 先问它失败时会编译报错、运行输出错,还是资源释放错
std::function | +-- 先问它适合作为接口承诺,还是只适合作为实现细节 `
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 | +-- 形参绑定原对象 +-- 修改形参会影响调用者看到的对象状态 `
把这个实验迁移到 RTTI、cast 与类型擦除 时,要问:当前接口到底想复制、借用,还是拥有? 如果答案说不清,类的设计通常也说不清。
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 当前到底调用了哪个函数? 当前对象的地址是什么? 当前观察的是完整对象还是基类子对象? 对象是在什么时候构造和析构的?
如果你正在研究 static_cast,调试器尤其有用。 因为这类问题经常不是源码一眼能看出来的。
17. 常见误解:把词背熟不等于会设计
下面这些误解在 RTTI、cast 与类型擦除 中很常见。 它们不一定立刻导致编译错误,却会慢慢变成难查的设计问题。
` ext 误解 1:只要能编译,对象关系就是正确的 实际:编译只检查语法和类型,不检查你的设计意图是否合理
误解 2:类的源码长什么样,对象内存就一定长什么样 实际:布局受成员顺序、对齐、继承和 ABI 影响
误解 3:引用和指针只是写法不同 实际:它们表达的可空性、重绑定能力和调用契约不同
误解 4:继承天然比组合高级 实际:继承暴露替换关系和基类接口,组合通常更局部、更可控
误解 5:虚函数就是运行时多态的全部 实际:多态还涉及生命周期、所有权、虚析构、对象切片和接口边界
误解 6:调试对象问题只要打印几个值 实际:还要观察地址、生命周期、调用栈和构造析构顺序 `
判断一个解释是否真正可靠,可以用三句话检查:
ext 我能画出对象之间的关系吗? 我能写出最小程序复现吗? 我能指出这属于语言规则、库规则、ABI 观察,还是工程约定吗?
如果三句话答不上来,说明还需要回到实验。
18. 复查清单:写类之前先问边界
设计一个类或一组对象关系前,先问这些问题:
` ext
- 这个对象拥有资源吗?
- 它可以被复制吗?复制后两个对象是否独立?
- 它可以被移动吗?移动后源对象处于什么有效状态?
- 它是否打算作为基类使用?
- 如果通过基类指针删除,析构函数是否应该 virtual?
- 它的构造函数是否能建立完整不变量?
- 它的成员函数是否会破坏不变量?
- 调用者拿到的是值、引用、指针,还是智能指针?
- 失败路径是否会泄漏资源或留下半初始化对象?
- 是否能用一个小程序验证关键行为? `
这些问题看起来慢,但它们能提前挡住很多后期调试成本。 尤其在 RTTI、cast 与类型擦除 里,真正重要的不是多写几个类,而是让对象关系能被解释、能被验证、能被维护。
19. 本文总结
RTTI、cast 与类型擦除 的核心不是记住一个孤立语法,而是建立一条从源码到运行期的解释链。
ext 源码写法 | v 类型检查 | v 对象创建 | v 成员访问或函数调用 | v 生命周期结束 | v 资源和不变量被检查
当你遇到面向对象问题时,不要只问“这个关键字怎么用”。 更好的问题是:
ext 谁拥有对象? 谁能观察对象? 对象什么时候有效? 调用目标由静态类型还是动态类型决定? 失败时资源和不变量是否还能保持正确?
如果能围绕这些问题解释 RTTI、dynamic_cast、static_cast、type erasure 和 std::function,这篇文章的目标就达到了。
下一篇或下一轮学习时,请继续沿用同样方法:先观察一个小程序,再把现象挂回对象边界和语言规则。
20. 主题案例复盘:从真实设计问题回到小程序
事件系统里拿到 BaseEvent 后想判断是不是 MouseEvent;任务队列里想保存各种 callable。这两类需求对应不同技术路线。
复盘时不要急着给结论。先把真实问题拆成三个层次:
ext 业务意图 | +-- 我想表达什么关系?拥有、借用、替换、共享,还是隐藏? | v C++ 接口 | +-- 我用值、引用、指针、智能指针、虚函数、模板,还是组合? | v 运行期事实 | +-- 对象在哪里?谁构造?谁析构?是否复制?调用目标如何决定?
20.1 先写出接口承诺
把类写出来之前,先用一句话描述接口承诺。 如果这句话写不清楚,代码通常也会变得含糊。
ext 这个类型负责拥有资源吗? 这个类型允许被复制吗? 这个类型是否允许通过基类接口使用? 这个类型的析构是否必须经由基类指针正确发生? 这个类型的状态是否只能通过成员函数维护?
对 RTTI、cast 与类型擦除 这类主题,接口承诺比语法选择更早。 语法只是把承诺落到编译器能检查的形式。
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. 延伸实验:让编译器、调试器和日志一起说话
学习 RTTI、cast 与类型擦除 时,建议固定三类证据。
第一类是编译期证据:
` ext warning 编译器觉得可疑,但仍允许生成程序
error 违反语法、类型或约束,不能生成程序
static_assert 把你自己的前提写成编译期检查 `
第二类是运行期证据:
ext 构造日志 析构日志 拷贝日志 移动日志 对象地址 sizeof / alignof 虚调用输出 异常路径输出
第三类是工具证据:
ext debugger backtrace sanitizer report nm / objdump 符号观察 编译器诊断信息 单元测试失败信息
把三类证据合在一起,才能避免只凭感觉解释对象行为。
一个推荐记录格式:
` ext 问题: 我原本以为会发生什么?
程序: 最小 main.cpp 是什么?
命令: 我用什么编译选项?
现象: 实际输出、警告或错误是什么?
归因: 这是语言规则、库规则、ABI 观察,还是我的设计假设?
修正: 我应该改接口、改所有权、改继承关系,还是改调用方式? `
这就是从“看懂一段代码”走向“能维护一个 C++ 对象系统”的关键步骤。