Ch18.5: RTTI, typeid and dynamic_cast

中文(简体)

1. What RTTI Actually Is

RTTI — run-time type information — is the machinery that lets a program ask "what is the dynamic type of this object?" It cannot exist without virtual: it is grafted onto the vtable. In the Itanium ABI the vptr points at slot 1 of the table, and slot 0 — the word before the first function pointer — holds a pointer to a type_info object for the class.

So every polymorphic class gets, in read-only data, at least:

All of that is emitted because the class is polymorphic, not because you used typeid: the same rule as Ch18.4. You pay on the class, not on the call.

2. typeid Is Cheap. dynamic_cast Is Not.

These two are usually spoken of together, and they have nothing in common on the cost side. typeid is a load of the typeinfo pointer followed by a comparison. dynamic_cast is a call into the C++ runtime (__dynamic_cast) that walks the base-class graph described by the typeinfo objects, handles virtual-base offsets, and returns an adjusted or null pointer. It cannot be inlined, it cannot be constant-folded, and its cost depends on the shape of your hierarchy.

Measured with fast_io's own clock — no ::std::chrono, no floating point, integer nanoseconds throughout:


#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

Divide by 1000 for nanoseconds per iteration. Read the table carefully, because it says three separate things:

3. And That Cost Is What Creates the Type Confusion

This is the part that matters, and it is usually said backwards. People say "dynamic_cast is the safe way, static_cast is the fast way, so use dynamic_cast where correctness matters". What actually happens is the opposite:

The expense of the checked operation is what forces people into the unchecked one, and the unchecked one is what produces type confusion. That is the whole argument. RTTI does not prevent type confusion; it prices the safe version of the operation so high that the unsafe version wins.

Two more details complete the picture. The pointer form of dynamic_cast reports failure by returning nullptr, and nothing makes you look — dynamic_cast<circle *>(p)->radius is a null dereference that ships. The reference form reports failure by throwing ::std::bad_cast, a legacy exception; under herbceptions it arrives as an itanium_exception payload, exactly like new and typeid on a null pointer:


herbception: [itanium_exception](St10bad_typeid)std::bad_typeid

(That last line is measured: typeid(*p) on a null polymorphic pointer.) So even the "safe" operation can fail through the legacy channel inside a function you declared noexcept.

4. What Would Actually Fix It

The right operation is a downcast whose checking cost is a constant, not a graph walk. Two proposals point in that direction, and neither is in the language today.

Static reflection

With reflection (^^ / ::std::meta), the compiler knows the complete layout of every class in the translation unit. A downcast becomes: compare a type identity, adjust by a compile-time-known offset, done. For a closed hierarchy it can be resolved entirely at compile time, and for an open one it is one compare. That is typeid's cost, not dynamic_cast's — and it requires no vtable at all.

Herb Sutter's down_cast

The same idea as a library/language facility: a checked downcast whose check is a pointer or tag comparison rather than a base-graph walk, which is why it can be used everywhere dynamic_cast is used today. This is the direction argued in De-fragmenting C++: Making exceptions and RTTI more affordable and usable (CppCon 2019), which is also the talk Portable-Cpp-Guideline cites when it says the cost of RTTI is completely unnecessary.

Until one of those is standard, the choice is: pay 151 ns, or use static_cast and accept UB, or design the cast out of the program. This book chooses the third. Type erasure (Ch18.6) makes the question unanswerable rather than expensive: the concrete type never leaves the value, so there is no cast to write, no tag to keep in sync, and no UB to hide.

5. Why None of This Works on C++ Iostreams

Here is the part that makes the situation worse than it looks. Suppose you get your cheap checked downcast tomorrow. It still will not work on the standard I/O library, because of how that library is built. From this toolchain's <istream>:


class basic_istream : virtual public basic_ios<_CharT, _Traits>

and, from the same header set:


class basic_iostream
: public basic_istream<_CharT, _Traits>,
  public basic_ostream<_CharT, _Traits>

Iostreams use virtual inheritance. basic_istream and basic_ostream both derive virtually from basic_ios, so that basic_iostream and fstream/stringstream have exactly one basic_ios base. That single decision breaks every cheap cast:

That is why the book's answer to "how do I safely identify an iostream?" is not a cast at all: do not use iostreams. fast_io gives you one concrete type per device with no inheritance, no virtual bases, and no virtual destructor, so the question "which stream is this?" never arises (Ch11). This is also why the whole I/O chapter exists: the legacy stream library is the largest single piece of C++ that is built on virtual + RTTI + virtual inheritance + cross-DLL objects, and every one of those four is a problem.

6. The Policy: Build With -fno-rtti

-fno-rtti turns every dynamic_cast and every polymorphic typeid into a compile error:


dc.cc:3:34: error: ‘dynamic_cast’ not permitted with ‘-fno-rtti’

Compile your own code with it, and a new dynamic_cast in a diff becomes a build failure rather than a design review. It also removes the typeinfo objects from every polymorphic class in your own translation units:


$ 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)

319 bytes of .text and 184 bytes of .data for a toy with two classes. What -fno-rtti does not do: remove virtual functions, remove __cxa_pure_virtual, remove virtual-base offsets, or make the vtable portable. It removes one of the four costs.

7. Rules of Thumb