第18.5章 RTTI、typeid 与 dynamic_cast
1. RTTI 到底是什么
RTTI —— 运行时类型信息 —— 是让程序能问“这个对象的动态类型是什么”的机制。
它离不开 virtual:它被嫁接到 vtable 上。
在 Itanium ABI 里,vptr 指向表的槽位 1,
而槽位 0 —— 第一个函数指针之前那个字 —— 指向该类的
type_info 对象。
所以每个多态类在只读数据里至少会有:
- 一个
type_info对象(__cxxabiv1::__class_type_info的 vtable 加上类身份); - 一个
type_info名字对象 —— 被 mangle 过的类型名,当字符串存; - 对派生类来说,还有一个
__cxxabiv1::__si_class_type_info, 它同时指明基类;如果是虚基类或多重基类,就是带基类偏移表的__vmi_class_type_info。
这一切是因为类是多态的而生成,不是因为你用了 typeid:
和第18.4章是同一条规则。
你在类上付费,不在调用上付费。
2. typeid 很便宜,dynamic_cast 不便宜
这两个东西通常被一起提,但在代价上它们毫无共同之处。
typeid 是一次 typeinfo 指针的加载加一次比较。
dynamic_cast 是一次对 C++ 运行时的调用
(__dynamic_cast),它沿着 typeinfo 对象描述的基类图行走、
处理虚基类偏移,返回一个调整过的或空的指针。
它不能被内联、不能被常量折叠,而且它的代价取决于你层次结构的形状。
用 fast_io 自己的时钟来测 —— 不用 ::std::chrono、
不用浮点数,全程整数纳秒:
#include <fast_io.h>
#include <typeinfo>
struct l0 { virtual ~l0() noexcept = default; };
struct l1 : l0 {};
struct l2 : l1 {};
struct l3 : l2 {};
struct l4 : l3 {};
struct l5 : l4 {};
struct l6 : l5 {};
struct l7 : l6 {};
struct l8 : l7 {};
struct l9 : l8 {};
struct unrelated { virtual ~unrelated() noexcept = default; };
static constexpr ::std::size_t n{20000000uz};
volatile int sink{};
::std::int_least64_t now_ns() throws
{
auto t{::fast_io::posix_clock_gettime(::fast_io::posix_clock_id::monotonic_raw)};
return t.tv_sec * 1000000000 + static_cast<::std::int_least64_t>(t.tv_nsec);
}
template <typename F>
::std::int_least64_t ns_per_1000(F &f) throws
{
auto t0{now_ns()};
f();
auto t1{now_ns()};
return (t1 - t0) * 1000 / static_cast<::std::int_least64_t>(n);
}
int main()
{
try
{
l9 obj{};
l0 *p = &obj;
auto null_check = [&] { for (::std::size_t i{}; i != n; ++i) { sink = (p != nullptr); } };
auto stat = [&] { for (::std::size_t i{}; i != n; ++i) { sink = (static_cast<l9 *>(p) != nullptr); } };
auto tid = [&] { for (::std::size_t i{}; i != n; ++i) { sink = (typeid(*p) == typeid(l9)); } };
auto tid_miss = [&] { for (::std::size_t i{}; i != n; ++i) { sink = (typeid(*p) == typeid(l1)); } };
auto dyn1 = [&] { for (::std::size_t i{}; i != n; ++i) { sink = (dynamic_cast<l1 *>(p) != nullptr); } };
auto dyn5 = [&] { for (::std::size_t i{}; i != n; ++i) { sink = (dynamic_cast<l5 *>(p) != nullptr); } };
auto dyn9 = [&] { for (::std::size_t i{}; i != n; ++i) { sink = (dynamic_cast<l9 *>(p) != nullptr); } };
auto dyn_fail = [&] { for (::std::size_t i{}; i != n; ++i) { sink = (dynamic_cast<unrelated *>(p) != nullptr); } };
::fast_io::io::println("nanoseconds per 1000 iterations, ", n, " iterations each:");
::fast_io::io::println("null check ", ns_per_1000(null_check));
::fast_io::io::println("static_cast ", ns_per_1000(stat));
::fast_io::io::println("typeid compare ", ns_per_1000(tid));
::fast_io::io::println("typeid miss ", ns_per_1000(tid_miss));
::fast_io::io::println("dynamic_cast up 1 ", ns_per_1000(dyn1));
::fast_io::io::println("dynamic_cast up 5 ", ns_per_1000(dyn5));
::fast_io::io::println("dynamic_cast up 9 ", ns_per_1000(dyn9));
::fast_io::io::println("dynamic_cast fail ", ns_per_1000(dyn_fail));
}
catch throws (::std::error e)
{
::fast_io::io::perrln(e);
}
}
nanoseconds per 1000 iterations, 20000000 iterations each:
null check 322
static_cast 322
typeid compare 526
typeid miss 4920
dynamic_cast up 1 128181
dynamic_cast up 5 73471
dynamic_cast up 9 6487
dynamic_cast fail 150760
除以 1000 就是每次迭代的纳秒数。仔细读这张表,因为它说了三件不同的事:
typeid基本是免费的:0.53 ns, 只比static_cast的 0.32 ns 高一点,而且这个数字里还包含了时钟本身的成本。 一次指针比较,正如预期。dynamic_cast是 6.5 到 151 ns —— 是typeid比较的十二倍到 280 倍, 最多是static_cast的 470 倍。 最便宜的场合(up 9,请求的类型就是动态类型)仍然要 6.5 ns; 贵的场合正是那些需要行走的:转到一个远处的基类(128 ns), 或者干脆失败(151 ns)。- 代价取决于层次,不取决于调用。更深的类就是更慢的转换,
所以让你需要
dynamic_cast的那个设计,同时也让它变慢。 还要注意:失败的转换是所有情形里最慢的 —— 你最需要那次检查的场合,恰恰是花得最多的场合。
3. 而这个代价正是类型混淆的来源
这才是要紧的部分,而且通常被说反了。人们说“dynamic_cast 是安全的方式,
static_cast 是快的方式,所以正确性重要的地方用
dynamic_cast”。实际发生的是相反的事:
- 恢复类型的需求不会因为你拒绝转换而消失。如果一个设计里存的是
base*,那么迟早有某个地方要问“这是不是circle?”。 dynamic_cast要 151 ns。在一遍十万个条目的模型循环里, 那是每趟 15 毫秒 —— 在热路径里、在帧预算里,这是永恒。- 于是转换被换成
static_cast,它只要 0.32 ns, 而且类型不对时是未定义行为。编译器检查不了, 消毒器只有在布局恰好触发故障时才抓得到, 结果就是一次静默的类型混淆:把另一个类型的成员当成你的成员来读。 - 或者转换被换成手写的标签:一个
enum成员加一个switch。它便宜且正确 —— 而那正是“带标签的类型擦除” (第18.6章), 只不过手写版本紧挨着一张本来就知道类型的 vtable, 于是你维护了同一份信息的两个副本,而它们可以互相矛盾。
被检查的那个操作太贵,才把人逼向不被检查的那个, 而不被检查的那个才产生类型混淆。这就是全部论证。 RTTI 不防止类型混淆;它把安全版本的价格定得那么高,以至于不安全版本获胜。
再补两个细节。指针形式的 dynamic_cast 用返回
nullptr 报告失败,而没有任何东西强迫你去检查 ——
dynamic_cast<circle *>(p)->radius
就是一次会上线的空指针解引用。引用形式用抛
::std::bad_cast 报告失败,那是一个遗留异常;
在赫布异常下它以一个 itanium_exception 载荷出现,
和 new、以及对空指针做 typeid 一模一样:
herbception: [itanium_exception](St10bad_typeid)std::bad_typeid
(最后这行是实测的:对一个空的指针多态指针做 typeid(*p)。)
所以连“安全”的操作都能在你声明为 noexcept 的函数里通过遗留通道失败。
4. 真正能修好它的东西
正确的操作是一种检查代价为常数、而不是需要走图的向下转换。 有两个提案指向这个方向,而且今天都还不在语言里。
静态反射
有了反射(^^ / ::std::meta),编译器知道翻译单元里每个类的完整布局。
向下转换就变成:比较一次类型身份、按编译期已知的偏移调整,完成。
对封闭层次,它可以完全在编译期解决;对开放层次,它是一次比较。
这就是 typeid 的代价,而不是 dynamic_cast 的代价 ——
而且它根本不需要 vtable。
Herb Sutter 的 down_cast
同一个想法,作为库/语言设施:一个被检查的向下转换,它的检查是一次指针或标签比较,
而不是基类图行走,这就是它能用在今天 dynamic_cast 的每一处的原因。
这也是《De-fragmenting C++: Making exceptions and RTTI more affordable and usable》
(CppCon 2019)所主张的方向;Portable-Cpp-Guideline
说“RTTI 的代价完全不必要”时引用的就是那场演讲。
在其中一个成为标准之前,选择是:付 151 ns,或者用 static_cast 接受未定义行为,
或者把转换从程序里设计掉。本书选第三种。
类型擦除(第18.6章)
让这个问题变得无法回答而不是昂贵:具体类型从不离开那个值,
所以没有转换可写、没有标签需要同步、也没有未定义行为可以藏。
5. 为什么这些东西对 C++ iostream 全都不管用
这部分让情况比看上去更糟。假设你明天就拿到了那个便宜的带检查向下转换。
它对标准 I/O 库仍然不管用,因为那个库是这么造的。
来自这套工具链的 <istream>:
class basic_istream : virtual public basic_ios<_CharT, _Traits>
以及同一组头文件里的:
class basic_iostream
: public basic_istream<_CharT, _Traits>,
public basic_ostream<_CharT, _Traits>
iostream 用的是虚继承。basic_istream 和
basic_ostream 都是虚继承 basic_ios,
这样 basic_iostream 和
fstream/stringstream 就只有唯一一个
basic_ios 基类。这个单一决定毁掉了每一个便宜的转换:
- 虚基类没有编译期偏移。它的地址取决于它属于哪个完整对象,
所以偏移必须在运行时从
__vmi_class_type_info表 (或 vtable 里的虚基偏移槽位)读出来。 任何在虚基类和派生类型之间转换的操作都是一次运行期计算 —— 正是常数时间down_cast想要避免的那件事。 - 跨 DLL 时类型身份不保证。在 Itanium ABI 下,
type_info对象在链接期是按名字合并的, 所以同一个类型从两个共享对象里来,可能是也可能不是同一个对象; 在 MSVC 下它们是按指针比较的,所以来自另一个模块的type_info除非那个模块用同样的运行时和同样的选项构建,否则比较起来就是不等。 于是靠比较类型身份的down_cast,对跨共享库边界的对象会失败 —— 而std::cout正好在那儿:它是运行时库里的一个全局对象, 类型(::std::ostream)带虚基类。 - 你没法重新编译它。
::std::cout是::std::ostream,一个你没写过、也不构建的库里的类实例, 而它的类型是标准定死的。你代码里再多反射,也改不了一个虚基偏移 已经编译进libstdc++.so的类的布局。
这就是为什么本书对“我怎样安全地识别一个 iostream?”的答案根本不是一个转换:
不要用 iostream。fast_io 给每个设备一个具体类型,
没有继承、没有虚基类、没有虚析构,所以“这是哪个流?”
这个问题根本不会出现(第11章)。
这也是整个 I/O 章存在的原因:遗留流库是 C++ 里最大的一块建立在
virtual + RTTI + 虚继承 + 跨 DLL 对象之上的代码,
而这四样每一样都是问题。
6. 政策:用 -fno-rtti 构建
-fno-rtti 把每一个 dynamic_cast 和每一个对多态类型的
typeid 变成编译错误:
dc.cc:3:34: error: ‘dynamic_cast’ not permitted with ‘-fno-rtti’
用它编译你自己的代码,补丁里新出现的 dynamic_cast
就从一个设计评审问题变成一个构建失败。它还会从你自己的翻译单元里删掉
每个多态类的 typeinfo 对象:
$ g++ -std=c++26 -fherbceptions -O2 -frtti -c rtti.cc -o rtti_on.o
typeinfo symbols: 6 (shape, square, circle x {type_info, name})
$ g++ -std=c++26 -fherbceptions -O2 -fno-rtti -c rtti.cc -o rtti_off.o
typeinfo symbols: 0
$ size rtti_on rtti_off
text data bss dec
42038 904 16 42958 (-frtti)
41719 720 16 42455 (-fno-rtti)
一个只有两个类的玩具:.text 319 字节、
.data 184 字节。-fno-rtti
不会做的事:删掉虚函数、删掉 __cxa_pure_virtual、
删掉虚基偏移、让 vtable 可移植。它只去掉了四个代价中的一个。
7. 经验法则
- 永远不要写
dynamic_cast。理由不是它不安全 —— 而是它贵到你不会留着它,而你用来替换它的那个正是不安全的那一个。 - 永远不要沿层次向下写
static_cast。 如果类型在调用点证明不了,设计就是错的。 - 如果你需要知道对象的某个属性,把这个属性作为普通函数放进接口
(
kind()、area()),让分派回答它 —— 那是一次表查找,不是一次类型查询。 - 用
-fno-rtti构建。typeid比较是便宜, 但 typeinfo 对象、被 mangle 的名字和运行时依赖都不便宜。 - 不要用 RTTI 做错误分类:用
::std::error域 (第19章 遗留异常和赫布异常)。 也不要用它做序列化:自己写标签,或者等反射成为标准。 - 永远不要假设未来的
down_cast或反射能救一个基于 iostream 的设计。 虚继承和跨 DLL 对象意味着它做不到,而你没法重新编译标准库。换掉那个库。