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:
- a
type_infoobject (the vtable of__cxxabiv1::__class_type_infoplus the class identity); - a
type_infoname object — the mangled type name as a string; - for a derived class, a
__cxxabiv1::__si_class_type_infothat also names its base class, and for a virtual or multiple base, a__vmi_class_type_infowith the base-offset table.
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:
typeidis essentially free: 0.53 ns, barely above the 0.32 ns of astatic_cast, and the cost of the clock itself is in that number. A pointer comparison, as expected.dynamic_castis 6.5 to 151 ns — twelve to 280 times atypeidcomparison, and up to 470 times astatic_cast. The cheapest case (up 9, where the requested type is the dynamic type) is still 6.5 ns; the expensive ones are the ones that have to walk: casting to a far base (128 ns) or failing altogether (151 ns).- The cost depends on the hierarchy, not on the call. A deeper class is
a slower cast, so the design that makes
dynamic_castnecessary also makes it slow. Note also that the failing cast is the slowest one of all — the case where you most need the check is the case that costs the most.
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 need to recover a type does not go away because you refuse to
cast. If a design stores
base*s, something, somewhere, will eventually ask "is this acircle?". dynamic_castcosts 151 ns. In a loop over a model with 100,000 items that is 15 milliseconds per pass — an eternity, in a hot path, in a frame budget.- So the cast gets replaced with
static_cast, which costs 0.32 ns and is undefined behaviour if the type is wrong. The compiler cannot check it, the sanitizer only catches it if the layout happens to fault, and the result is a silent type confusion that reads another type's member as if it were yours. - Or the cast gets replaced by a hand-rolled tag: an
enummember and aswitch. That is cheap and correct — and it is exactly "type erasure with a tag" (Ch18.6), except that the hand-rolled version sits next to a vtable that already knows the type, so you now maintain two copies of the same information and they can disagree.
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:
- A virtual base has no compile-time offset. Its address depends on
which complete object it belongs to, so the offset has to be read from the
__vmi_class_type_infotable (or the vtable's virtual-base offset slots) at run time. Any cast that converts between the virtual base and a derived type is a runtime computation — the very thing a constant-timedown_castwas supposed to avoid. - Cross-DLL, type identity is not guaranteed. Under the Itanium ABI,
type_infoobjects are merged by name at link time, so the same type from two shared objects may or may not be the same object; under MSVC they are compared by pointer, so atype_infofrom another module compares unequal unless that module was built with the same runtime and flags. Adown_castthat compares type identities therefore fails for objects that cross a shared-library boundary — which is exactly wherestd::coutlives: it is a global in the runtime library, of a type (::std::ostream) with a virtual base. - You cannot recompile it.
::std::coutis::std::ostream, an instance of a class you did not write, in a library you do not build, and the type is fixed by the standard. No amount of reflection in your own code changes the layout of a class whose virtual-base offsets are already compiled intolibstdc++.so.
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
- Never write
dynamic_cast. Not because it is unsafe — because it is so expensive that you will not keep it, and the thing you replace it with is the unsafe one. - Never write
static_castdown a hierarchy. If the type is not provable at the call site, the design is wrong. - If you need to know a property of the object, put that property in the
interface as a plain function (
kind(),area()) and let dispatch answer it — that is a table lookup, not a type query. - Build with
-fno-rtti. Atypeidcomparison is cheap, but the typeinfo objects, the mangled names and the runtime dependency are not. - Do not use RTTI for error classification: use
::std::errordomains (Ch19: Legacy Exceptions and Herbceptions). Do not use it for serialisation, either: write the tag yourself or use reflection when it is standard. - Never assume a future
down_castor reflection will fix an iostream-based design. Virtual inheritance and cross-DLL objects mean it cannot, and you cannot recompile the standard library. Replace the library instead.