C++ RTTI 与 typeid:运行期类型识别的账
本文要回答的问题
typeid到底靠什么知道一个对象的"真实类型"?运行时查了什么表?- 为什么对多态对象和非多态对象,
typeid的表现完全不同? typeid(b).name()打出来的4Base是什么?怎么读?dynamic_cast为什么慢?什么场合才必须用它?-fno-rtti能省多少体积?什么时候值得关?
一、RTTI 是什么:一个运行时查类型的工具箱
RTTI(Run-Time Type Information,运行期类型信息)是 C++ 让程序在运行期回答"这个对象到底是什么类型"的机制,就两个工具:typeid(查类型)和 dynamic_cast(安全地向下/侧向转换)。
为什么需要它?因为继承体系里你手里常常只有基类指针:Base* p 到底指着 Base、Mid 还是 Leaf?静态类型只说了"它是 Base 或它的子孙",运行期的真相要用 RTTI 问出来。虚函数把"行为"按类型分派了,但"类型本身是谁"这个信息,虚函数不提供——这正是 RTTI 的席位。
先看一个最小的实测(完整代码见 demos/cpp-expert/rtti-typeid/,g++ 4.8.5 实测输出):
typeid: 4Base
typeid: 3Mid
typeid: 4Leaf
p1 动态类型是 Base? 1
p2 动态类型是 Mid? 1
p3 动态类型是 Leaf? 1
p1 -> Leaf*: null
p3 -> Leaf*: okdump(l) 传的是 const Base&,typeid(b).name() 却报出 4Leaf——引用/指针指到的对象,动态类型在运行期被查了出来。dynamic_cast<Leaf*>(p1) 失败返回 null,p3 成功——向下转换的合法性也靠运行期检查。
二、原理:typeinfo 藏在 vtable 背后
RTTI 不是魔法。在 虚函数、虚表与多态机制 里我们知道:多态对象带着 vptr,vptr 指向 vtable。RTTI 的秘密是——vtable 的槽位 0 专门放着一个指向 typeinfo 对象的指针。每个多态类都有一份 typeinfo(类名、继承关系等元信息),vtable 第一个槽位就是通往它的入口。

上图是 Itanium ABI 下的标准布局(GCC/Clang 都遵守):typeid(*p) 的求值路径是 p → vptr → vtable[0] → typeinfo,一次指针追逐,拿到动态类型的元信息。用 nm -C 实测,每个多态类都有一套符号:
0000000000400e60 V vtable for Leaf
0000000000400f08 V typeinfo name for Leaf
0000000000400f10 V typeinfo for Leaf
0000000000400f50 V typeinfo for Base
0000000000602080 V vtable for __cxxabiv1::__class_type_info@@CXXABI_1.3typeinfo for Leaf(_ZTI4Leaf)和 typeinfo name for Leaf(_ZTS4Leaf)是两个独立的只读数据对象——前者是 std::type_info 实例(含名字指针与继承关系钩子),后者只是名字字符串。最后一行很有意思:typeinfo 体系自身也有 vtable(__cxxabiv1::__class_type_info),typeinfo 之间也靠虚函数区分具体种类(普通类 / 单继承 / 多继承 / 虚继承的 typeinfo 类型各不相同)——连类型信息本身都长着一副面向对象的样子。
三、typeid 的两种形态:编译期类型 vs 运行期类型
typeid 的行为取决于操作数的静态类型:
- 多态左值(引用/指针解引用):走 vtable 查动态类型——这就是上面的
4Leaf。注意必须是"多态"(有虚函数),编译器才能靠 vptr 找到运行期真相。 - 非多态类型 / 非左值(如
typeid(int)、普通对象):静态类型就是答案,编译器直接当常量处理,运行时没有查找。实测里对const Plain&打typeid,输出Z4mainE5Plain——main::Plain的 mangled 名,编译期就定死了。
typeid(Base) // 编译期:类型操作数
typeid(plain) // 编译期:非多态对象
typeid(*p) // 运行期:多态左值,走 vtable → 动态类型两个容易踩的坑:其一,typeid(p)(指针本身)给的是"指针类型",不是"指向的类型"——必须解引用 typeid(*p) 才查动态类型;其二,空指针解引用进 typeid 会抛 std::bad_typeid(多数编译器可能直接崩溃或 UB,别指望它优雅)。
还有个反直觉的细节:非多态的 main::Plain 也生成了 typeinfo(实测 r typeinfo for main::Plain),因为 typeid 被使用了——它没有 vtable(非多态),但有类型名记录。所以"typeid 不用于多态就不花任何钱"不完全对,typeinfo 只要被 typeid 引用就会生成,只是没有动态识别能力、开销也小得多。
四、typeid(x).name():mangled 名与 c++filt
name() 返回的是实现定义的、通常 mangled 过的类型名:4Base 是长度前缀 + 类名(4 表示名字长 4 个字符),Z4mainE5Plain 是 main::Plain 的完整 mangled 名。它们在符号表里对应 _ZTS* 符号(_ZTS4Leaf),和 C++ ABI 与 name mangling 讲的是同一套编码。
所以日志里看到 typeid(...).name() 输出一串怪名字别慌,用 c++filt 或 nm -C 解码即可:
$ c++filt Z4mainE5Plain
main::Plain实践中 name() 只适合调试和日志,不要拿它做类型比较或哈希键——标准只保证同一类型 name() 相同,不保证跨编译器一致,可读性也没有保证。类型比较请用 typeid(a) == typeid(b)(比较 typeinfo 对象的地址/身份),它才是语言保证的语义。
五、dynamic_cast:安全向下转换的账
dynamic_cast<T*>(p) 运行时判断"p 实际指向的东西是不是 T 或 T 的派生",是就返回调整好的指针,不是就返回 nullptr(引用版本抛 std::bad_cast)。它比 static_cast 安全,但安全有价格:
- 成本:从
T的 typeinfo 出发,沿继承链做"是否可达"检查,还要处理多重继承的指针偏移。实测中一次失败的向下转换开销通常是几十到几百纳秒量级——对单次调用无感,在热循环里反复dynamic_cast就是实打实的开销。 - 必要性:单继承体系里,向下转换用
static_cast编译期就能算偏移(基类在前,地址相同或固定偏移),正确性靠程序员保证;只有多重继承/跨层次转换(指针需要运行时调整)或确实无法确定动态类型时,dynamic_cast才不可替代。规范做法:能用虚函数解决就别dynamic_cast,能用static_cast(且保证安全)就别dynamic_cast。 - 替代:最常见的替代是给每个类一个
virtual类型枚举/查询函数,或干脆用访问者模式把"按类型做事"改成虚函数分派——把运行期查类型的需求,用编译期就定案的虚调用取代。
| 转换 | 检查 | 成本 | 失败行为 |
|---|---|---|---|
static_cast(向下) | 无,编译期算偏移 | 零 | 未定义行为(可能错用) |
dynamic_cast(向下) | 运行期沿继承链查 | 几十~几百 ns | 指针版返回 nullptr |
dynamic_cast(向上/侧向) | 运行期检查 | 同上 | 无失败(向上必然成功) |
六、RTTI 的成本与关闭它
RTTI 的成本分三块:typeinfo 表的只读数据(每个多态类一个)、typeid 一次 vtable 查找(动态类型场景)、dynamic_cast 的继承链遍历。第一块是静态占用,后两块是运行期开销。
低层/嵌入式/游戏引擎场景常直接 -fno-rtti -fno-exceptions 关掉它。实测对比(同一类层次,-O0 -g):
== rtti(开 RTTI) ==
text data bss dec hex filename
4145 652 192 4989 137d rtti (文件 22200 B)
== nortti(-fno-rtti -fno-exceptions) ==
text data bss dec hex filename
2402 604 4 3010 bc2 nortti (文件 14952 B)text 段少了约 42%、文件小了约 7 KB——对"以 KB 计生死"的嵌入式固件,这是可观的收益。代价是代码里不能出现任何 typeid/dynamic_cast(编译直接报错),运行期类型识别只能靠虚函数协议自造。
但注意一个务实结论:对大多数应用,RTTI 的静态成本是 KB 级、运行期成本是每次几十 ns,为了它放弃 typeid/dynamic_cast 的便利不值;该关的是那种"每个类都开、却几乎不用"的浪费——典型如为动态类型比较狂写 typeid 的代码,换成虚函数往往既快又干净。
七、不用 RTTI 的替代方案
关掉 RTTI 的世界怎么活?方案其实早有传统:
| 方案 | 原理 | 成本 | 适用 |
|---|---|---|---|
| 虚函数分派(默认首选) | 行为差异交给虚调用 | 一次间接跳转 | 多数"按类型做不同事" |
| 虚函数 + 每类返回类型 id | 自己维护枚举 id | 手写维护 + 一次虚调用 | 需要"类型相等/比较"时 |
| 访问者模式(双派发) | 两个对象的动态类型组合决定行为 | 两层虚调用 | 类型组合爆炸的算法结构 |
| 手写类型标签字段 | 构造时记下类型 tag | 内存 + switch | 简单、类型有限 |
一句话原则:RTTI 是"查类型",虚函数是"做行为";能"做行为"解决的,别"查类型"。用 dynamic_cast 写出的 if/else if 链,往往是一段虚函数能消灭的代码味道。
八、与本站性能主线衔接
RTTI 与 vtable 同源,都建立在对象内存布局之上(衔接 对象内存布局与编译期优化 的 vptr 讨论)。剖析时的几个直接落点:
perf annotate里若dynamic_cast/__dynamic_cast(libstdc++ 符号)出现在热点,多半是热循环里反复查类型——优先改成虚函数分派;这是把运行期类型查找换回编译期定案的典型优化。- typeinfo 是只读数据,
nm/size能直接量化它占了多少(.rodata里的_ZTI*符号);要瘦身先看这里。 - RTTI 的"一次 vtable 查找"和虚函数调用同路径,命中缓存时开销可忽略;真正贵的永远是循环内的重复查找,而不是单次成本。
回到开头的问题:typeid 靠 vtable[0] 的 typeinfo 指针回答"你是谁",这是对象布局给的免费信息;dynamic_cast 用继承链换安全;-fno-rtti 用功能换体积。RTTI 不是性能问题,滥用 RTTI 才是——把类型分派写成动态查找,等于把编译器能做的事推给运行期。
一句话总结
RTTI = typeid + dynamic_cast,原理是 vtable 槽位 0 的 typeinfo 指针——多态左值走 vtable 查动态类型,非多态类型编译期就定案;typeid.name() 返回 mangled 名(_ZTS* 符号),比较类型请用 typeid(a)==typeid(b);dynamic_cast 沿继承链安全转换但每次几十~几百 ns;-fno-rtti 实测可省 40% 左右 text 段,但日常应用更该改的是"用虚函数代替查类型",而不是关掉整个机制。
上一篇:constexpr 与编译期计算 下一篇:协程机制