Skip to content
VLSI Mentor

C++ Part I · Module 17 · Runtime Polymorphism

dynamic_cast and Runtime Type Identification

Everything in this module has pointed one way. Client code holds a base reference, calls through the interface, and never learns what it is actually holding — that is the design the whole module exists to produce, and Chapter 17.1 measured its payoff as a client object file driving an implementation that did not exist when it was compiled. The language also provides the reverse direction: a way to ask, while the program is running, whether the object in front of you is really a particular derived type, and to get a usable pointer to it if the answer is yes. This chapter is that facility, measured on all the axes that matter. What the checked cast returns when it succeeds and when it fails, which differ depending on whether you cast a pointer or a reference. What the unchecked alternative does instead, which is nothing at all — it hands back a pointer of the type you asked for whether or not the object is one. And what both cost, where the result is more interesting than a single number: a succeeding cast is about six times a virtual call, and a failing one is about six and a half times the succeeding one, for a reason that falls straight out of what a failure has to establish. The chapter also takes the second half of its own brief seriously, because the frozen description of it puts the design observation on the same footing as the mechanism: reaching for this facility is usually a statement about your interface rather than about your object.

Advanced18 min readC++Part IFundamentalsPolymorphism

Part I · Module 17 · Chapter 17.8 · dynamic_cast and Runtime Type Identification

1. The Question It Answers

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
    void inspect(Model& m) {
        // m is some kind of Model. Is it, specifically, a FastModel?
    }

Chapter 17.2's dispatch answers what should this object do without ever naming a type. dynamic_cast answers a different question — what is this object — and it is the only facility in C++ that will tell you, because the object's type is not otherwise available to your code.

It requires a polymorphic type. Chapter 17.1 §6's table pointer is what makes the answer obtainable at all; a class with no virtual functions carries no identity to interrogate.

2. On Pointers: A Null Result

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
    FastModel* a = dynamic_cast<FastModel*>(pf);   // pf points at a FastModel
    FastModel* b = dynamic_cast<FastModel*>(ps);   // ps points at a SlowModel
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
A. dynamic_cast on POINTERS -- success and failure:
     FastModel* from a FastModel : non-null (ok)
     FastModel* from a SlowModel : nullptr (failed, as designed)

A failed pointer cast yields nullptr. That is a result, not an error — the cast is a question, and nullptr is the answer "no".

Which gives the idiomatic form, with the test and the use in one statement:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
    if (auto* f = dynamic_cast<FastModel*>(&m)) {
        f->fastOnly();            // only reachable when the answer was yes
    }

Chapter 14.6 §7's lock() has the same shape for the same reason: ask and obtain in one operation, so there is no window between the check and the use.

3. On References: A Throw

A reference cannot be null, so failure has to be reported another way:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
    FastModel& r = dynamic_cast<FastModel&>(*ps);   // *ps is a SlowModel
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
B. dynamic_cast on REFERENCES -- failure throws:
     threw std::bad_cast (std::bad_cast)

std::bad_cast. So the two forms express different intentions, and choosing between them is a design decision rather than a style one:

FormFailureUse when
dynamic_cast<D*>(p)nullptrthe object might be a D and that is normal
dynamic_cast<D&>(r)throws std::bad_castthe object must be a D and anything else is a bug

(Exceptions are Module 22's subject; the throw is used here as a measurement of what the reference form does.)

4. typeid: Asking About the Exact Type

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
C. typeid:
     typeid(*pf) == typeid(FastModel) ? 1
     typeid(*ps) == typeid(FastModel) ? 0

typeid yields a std::type_info describing the object's dynamic type, and comparing two of them tests for an exact match.

The difference from dynamic_cast matters: dynamic_cast<D*> succeeds for a D or anything derived from a D, while typeid(x) == typeid(D) is true only for exactly D. So typeid is the stricter test and almost always the wrong one — a type derived from FastModel is still a FastModel for every purpose your code should care about, and a typeid comparison would reject it.

Note also typeid(*pf) dereferences. typeid(pf) would describe the pointer type, statically, and tell you nothing.

5. static_cast Does Not Check

The important contrast, because this is the mistake that turns a design smell into undefined behaviour:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
    FastModel* wrong = static_cast<FastModel*>(ps);     // ps is a SlowModel!
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
D. static_cast does NOT check -- it just reinterprets:
     static_cast<FastModel*>(SlowModel*) gave a non-null pointer
     (calling wrong->fastOnly() would be undefined behaviour)

A non-null pointer, no diagnostic, no runtime check. static_cast down a hierarchy is a statement — I assert this is a FastModel — and the compiler takes your word for it. Chapter 4.10 §4 established that a named cast makes a conversion visible; what it does not do is make it verified.

So the division is clean:

static_cast down a hierarchy asserts. dynamic_cast asks. Use static_cast only where the type is already established by other means, and prefer dynamic_cast whenever you are actually uncertain — the cost in §6 is the price of the certainty.

6. What It Costs

20,000,000 of each, barriered, against this same program's virtual call as a baseline:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
E. cost:
     virtual call        :  20.1 ms ( 1.01 ns)
     dynamic_cast (hit)  : 115.8 ms ( 5.79 ns)   5.8x
     dynamic_cast (miss) : 748.8 ms (37.44 ns)  37.2x

Two results, and the second is the interesting one.

A succeeding cast is about 5.8 times a virtual call. Chapter 17.7 §5 measured a virtual call as essentially free relative to a direct call, so 5.79 ns is still small in absolute terms — but it is a real computation rather than a load and a branch.

A failing cast costs about 6.5 times a succeeding one — 37.44 ns against 5.79 ns. And the reason follows directly from what each has to establish:

  • a hit can stop as soon as it finds the target type in the object's hierarchy;
  • a miss has to prove the type is not anywhere in the hierarchy, which means examining all of it.

Proving absence costs more than finding a match. A dynamic_cast used as a type test — where failure is the common case — is paying the expensive branch most of the time.

That is the measurement that should change behaviour: a chain of dynamic_cast attempts, trying each derived type in turn, pays the miss price for every type it rejects before the one that matches.

7. The Chain That Gives the Game Away

The shape that appears when an interface is incomplete:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
    void handle(Model& m) {
        if (auto* f = dynamic_cast<FastModel*>(&m))      { f->fastThing(); }
        else if (auto* s = dynamic_cast<SlowModel*>(&m))  { s->slowThing(); }
        else if (auto* r = dynamic_cast<RefModel*>(&m))   { r->refThing(); }
        else                                              { /* ??? */ }
    }

Three things are wrong with it, in increasing order of importance.

It is slow, by §6 — the first two branches pay the miss price on every RefModel.

It does not compile-time-check for completeness. Add a fourth model and this function silently takes the else branch. Chapter 17.1 §5 measured client code driving an implementation added later without being recompiled; this function is the opposite, and nothing will tell you it needs updating.

And it is a virtual function written by hand. Every branch selects behaviour based on the object's type, which is precisely what Chapter 17.2's dispatch does in three instructions with a guarantee of completeness. The chain is a slower, unchecked reimplementation of the mechanism the hierarchy already provides.

The fix is almost always to add the operation to the interface:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
    struct Model {
        virtual ~Model() = default;
        virtual void doTheThing() = 0;      // each model does its own version
    };

Now handle is m.doTheThing(); — three instructions, and a new model that forgets to implement it is a compile error by Chapter 17.4 §5.

8. When Asking Is Legitimate

The frozen description of this chapter says needing to ask often means the interface is wrong, which leaves room for the cases where it does not. Four of them, and they share a property: the question is about something outside the interface's purpose.

Crossing a boundary you do not control. A framework hands you a base pointer and the operation you need is genuinely specific to one implementation that the framework's interface was never going to include.

A deliberate capability query, where the base's contract says this operation is optional:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
    if (auto* d = dynamic_cast<Debuggable*>(&m)) { d->dumpState(); }

Here Debuggable is a separate interface, and asking whether a model also implements it is the designed use. This is the strongest case, and note what makes it strong: the answer being "no" is a normal, expected outcome rather than a failure.

Recovering a type at a serialisation or scripting boundary, where the type was encoded as data and has to be turned back into a type.

And in tests, where asserting that a factory returned the specific implementation you asked for is exactly the point.

What these have in common is that the type question is the question, not a substitute for a missing operation. The diagnostic to apply: am I asking because I want to know the type, or because the interface will not do what I need? The second answer means §7.

9. The Alternatives

Before reaching for dynamic_cast, two other mechanisms often fit better.

Add a virtual function — §7. This is the answer in the large majority of cases, and the cost is three instructions.

Add a virtual query when the capability really is optional, rather than casting:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
    virtual Debuggable* asDebuggable() { return nullptr; }   // overridden to return this

This is a hand-rolled dynamic_cast with the advantage of being one dispatch and the disadvantage of putting a mention of the derived capability in the base.

And for a genuinely closed set of types, a tagged union — std::variant with std::visit — gives exhaustiveness checking and no hierarchy at all. That is Module 21's subject, and it is the right tool when the set of alternatives is fixed and known, which is exactly the condition Chapter 17.1 §7 said not to use a hierarchy for.

(RTTI can be disabled — -fno-rtti — in which case dynamic_cast and typeid are unavailable. Some embedded and game codebases build this way, which is worth knowing because it means a design that depends on dynamic_cast is not portable to those environments.)

10. What to Actually Do

Use dynamic_cast<D*> and test the result — ask and obtain in one statement.

Use dynamic_cast<D&> only when anything else is a bug, and expect std::bad_cast.

Never static_cast down a hierarchy unless the type is already established — measured, it checks nothing.

Prefer dynamic_cast to typeid — typeid rejects further-derived types.

Replace a chain of casts with a virtual function. Three instructions, and new types are checked.

Expect a failing cast to cost ~6.5x a succeeding one — a type test pays the expensive branch.

Consider std::variant where the type set is genuinely closed.

And ask the §8 diagnostic: do I want the type, or do I want an operation the interface lacks?

11. Mistakes Worth Making Once

static_cast down a hierarchy on a guess. Measured: a non-null pointer to the wrong type, no diagnostic, undefined behaviour on use.

Checking then casting in two steps. Ask and obtain together, like Chapter 14.6 §7's lock().

Expecting a failed reference cast to be null. It throws std::bad_cast.

Using typeid for a type test. It rejects anything further derived.

typeid(ptr) instead of typeid(*ptr). You get the pointer's static type.

A chain of dynamic_cast branches. Slow, unchecked against new types, and a hand-written dispatch.

Assuming a failing cast is as cheap as a succeeding one. Measured: 37.44 ns against 5.79 ns.

dynamic_cast on a non-polymorphic type. There is no identity to interrogate.

Designing around it in a codebase that may build -fno-rtti.

12. Interview Relevance

"What does dynamic_cast do?" Asks at run time whether an object is of a given type in a polymorphic hierarchy, returning nullptr on a pointer or throwing std::bad_cast on a reference.

"How does it differ from static_cast?" static_cast performs no check — measured, it returned a non-null pointer for an unrelated type. One asks, the other asserts.

"dynamic_cast or typeid?" dynamic_cast usually — typeid tests for an exact match and rejects further-derived types.

"What does it cost?" Measured, 5.79 ns on a hit against a 1.01 ns virtual call — and 37.44 ns on a miss, because proving absence requires examining the whole hierarchy. The asymmetry is the answer that stands out.

"What does a chain of dynamic_casts tell you?" That the interface is missing an operation — it is slow, it does not notice new types, and it reimplements dispatch.

"When is it legitimate?" A capability query where "no" is expected, a boundary you do not control, a serialisation edge, and tests.

"What if RTTI is disabled?" Neither dynamic_cast nor typeid is available, so a design depending on them is not portable to those builds.

13. What You Should Be Able to Say Now

  1. dynamic_cast answers what is this object, which dispatch never asks, and it needs a polymorphic type.
  2. Measured: a failed pointer cast gives nullptr — a result, not an error.
  3. Measured: a failed reference cast throws std::bad_cast.
  4. So the pointer form means "might be" and the reference form means "must be".
  5. Ask and obtain in one statement — if (auto* f = dynamic_cast<D*>(p)), the shape of Chapter 14.6 §7's lock().
  6. typeid tests the exact dynamic type and rejects further-derived types, so it is usually the wrong test.
  7. typeid(*p), not typeid(p) — the latter is the static pointer type.
  8. Measured: static_cast down a hierarchy returned a non-null pointer to the wrong type, with no check and no diagnostic.
  9. static_cast asserts; dynamic_cast asks.
  10. Measured: a hit is 5.79 ns against a 1.01 ns virtual call — 5.8x.
  11. Measured: a miss is 37.44 ns — about 6.5x the hit, because proving absence means examining the whole hierarchy.
  12. So a type test pays the expensive branch most of the time.
  13. A chain of casts is slow, silently incomplete when a type is added, and a hand-written dispatch.
  14. The usual fix is a virtual function — three instructions, and new types are compile-checked.
  15. Legitimate uses: a capability query where "no" is normal, an uncontrolled boundary, serialisation, and tests.
  16. std::variant suits a genuinely closed type set; -fno-rtti removes the facility entirely.

14. Exercises

Exercise 1 — the two forms. Write both a pointer cast and a reference cast, and say what each does on failure and when you would choose it.

Exercise 2 — the idiom. Write the single-statement form and say what window it closes.

Exercise 3 — typeid. Say what typeid(*p) == typeid(Fast) reports for a Fast, for a class derived from Fast, and for a Slow, and why that makes it the stricter test.

Exercise 4 — the unchecked cast. Say what static_cast<Fast*>(slowPtr) returns, what diagnostic you get, and what happens on use.

Exercise 5 — the costs. Give all three measured figures and explain the hit/miss asymmetry from first principles.

Exercise 6 — diagnose the chain. Given a three-branch dynamic_cast chain, list its three defects and rewrite it.

Exercise 7 — legitimate or symptom. Judge each: asking whether a model is Debuggable; asking whether a transaction is a WriteTransaction so you can read its mask; recovering a type after deserialisation; checking in a test that a factory returned a FastModel.

Exercise 8 — the alternative. For a closed set of three message types, sketch the variant approach and say what it gives you that a hierarchy does not.

Exercise 9 — the diagnostic question. State §8's question and apply it to a cast in code you have written.

15. Summary

  • dynamic_cast asks what an object is, which requires a polymorphic type and is the reverse of everything else in this module.
  • Measured: a failed pointer cast gives nullptr; a failed reference cast throws std::bad_cast.
  • Measured: static_cast down a hierarchy gave a non-null pointer to the wrong type with no check — it asserts where dynamic_cast asks.
  • typeid tests the exact type and rejects further-derived ones.
  • Measured costs: hit 5.79 ns, virtual call 1.01 ns, miss 37.44 ns — a miss is ~6.5x a hit, because proving absence means examining the whole hierarchy.
  • So a type test pays the expensive branch, which is the cost structure arguing against using it as one.
  • A chain of casts is slow, silently incomplete, and a hand-written dispatch — add a virtual function instead.
  • Legitimate uses are those where the type question is the actual question: capability queries, uncontrolled boundaries, serialisation, tests.
  • std::variant for a closed set; -fno-rtti removes the facility.

Eight chapters have built and priced the mechanism, and two chapters remain, both about judgement rather than machinery. Everything so far has taken the base class as given — Model with a step(), Sink with a write() — without ever asking what makes such a class worth implementing. That question has a surprising amount of structure, and this chapter has just supplied one piece of it: a dynamic_cast chain in client code is evidence that the interface was too narrow, and the same evidence read differently says the interface was designed around what one implementation happened to provide. The next chapter is the design problem directly — how many operations an interface should have, why the number is smaller than it first appears, which operations belong behind a non-virtual public function instead of being virtual themselves, and what it means for a base class to contain an assumption about one of its implementations.

Standards & specifications

Governing standard
ISO/IEC 14882 (the C++ language standard)(opens ISO/IEC (JTC 1/SC 22/WG 21) in a new tab)

Defines the C++ language and its standard library — the type system and templates, the object model and object lifetime, initialisation and overload resolution, the memory model and the containers, algorithms and utilities the library provides. Like C's standard it is a FAMILY of editions (C++98/03, C++11, C++14, C++17, C++20, C++23) and the editions are NOT interchangeable: a feature this curriculum relies on may be unavailable, differently specified or merely proposed in another edition, so each lesson names the edition its claim depends on. It does not define any foreign-function interface to a hardware description language, nor what a compiler actually emits, nor platform behaviour on real silicon — those are the implementation's and the device's contracts. Working drafts published by WG21 are freely available and are what most engineers read day to day; a draft is not the published standard.

This page also covers RTL structure, verification approach and debugging technique. Those are engineering practice built on the standard, not requirements the standard itself imposes.

Where this fits

Part of the C++ Programming curriculum.