第18.5章 RTTI、typeid 与 dynamic_cast

English

1. RTTI 到底是什么

RTTI —— 运行时类型信息 —— 是让程序能问“这个对象的动态类型是什么”的机制。 它离不开 virtual:它被嫁接到 vtable 上。 在 Itanium ABI 里,vptr 指向表的槽位 1, 而槽位 0 —— 第一个函数指针之前那个字 —— 指向该类的 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 就是每次迭代的纳秒数。仔细读这张表,因为它说了三件不同的事:

3. 而这个代价正是类型混淆的来源

这才是要紧的部分,而且通常被说反了。人们说“dynamic_cast 是安全的方式, static_cast 是快的方式,所以正确性重要的地方用 dynamic_cast”。实际发生的是相反的事:

被检查的那个操作太贵,才把人逼向不被检查的那个, 而不被检查的那个才产生类型混淆。这就是全部论证。 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 基类。这个单一决定毁掉了每一个便宜的转换:

这就是为什么本书对“我怎样安全地识别一个 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. 经验法则