rroessler.io

Type Introspection

How I over-engineered dynamic dispatching

For those familiar with C++, you would have come across the topic of run-time type identification or RTTI for short. This involves deducing types at runtime to safely cast base objects to potential derivatives. The key problem that then occurs is, how can we do this correctly?

Casting Natively

Let's propose an abstract class for which an Instrument could be derived from.

// Let's make use of some modern headers.
#include <print>

struct Instrument {
    // No undefined behaviour for us!
    virtual ~Instrument() = default;

    // All instruments should make sound.
    virtual void play() const {
        std::println("Unknown sounds!");
    }
};

// Who doesn't love listening to a piano...
struct Piano : public Instrument {
    void play() const final {
        std::println("Piano sounds!");
    }
};

// Or to the sound of a guitar melody!
struct Guitar : public Instrument {
    void play() const final {
        std::println("Guitar sounds!");
    }
};

As expected, upcasting to the base type will always be valid, but downcasting may fail.

void foo() {
    // Starting with a child instance.
    Piano piano = {};

    // We can safely upcast this to an instrument.
    Instrument* instrument = &piano;

    // However depending on our downcast, it could fail:
    Guitar* guitar = dynamic_cast<Guitar*>(instrument);
    if (guitar == nullptr) std::println("Failed casting!");
}

However, this method leaves a lot to be desired and does not allow for using a switch statement over the internal types.

Tagged Classes

Alternatively, we could tag derived classes with some enumeration value, that way we could .

// Enable usage of the "std::derived_from" concept.
#include <concepts>

// The typed enumeration for tagging.
enum class Tag { GUITAR, PIANO, UNKNOWN };

// Base instruments would require a tag now.
struct Instrument {
    // Supposes we have a suitable mixing for simplifying setup.
    template <Tag T, std::derived_from<Instrument> B = Instrument> struct Mixin : public B {
        Tag tag() const noexcept override { return T; }
    };

    // We could denote base instruments however we like, but unknown feels suitable.
    virtual Tag tag() const noexcept { return Tag::UNKNOWN; }
};

// And the derived instruments would inherit as necessary.
struct Piano : public Instrument<Tag::PIANO> {};
struct Guitar : public Instrument<Tag::GUITAR> {};

Note: We need to use template <Tag T, std::derived_from<class> B = Instrument> to define the mixin abstraction so that types are inferred after the original declaration. Otherwise without it the base Instrument class would not be fully defined when declaring the Mixin class.

Now when checking types, we could match them accordingly:

void foo(Instrument* instrument) {
    switch (instrument->tag()) {
        case Tag::PIANO: std::println("Keys: {0}", dynamic_cast<Piano*>(instrument)->keys()); break;
        case Tag::GUITAR: std::println("Frets: {0}", dynamic_cast<Piano*>(instrument)->frets()); break;
    }
}

However, we now have the new issue of additionally boiler-plate in managing a set of Tag enumeration values.

Over-Engineering RTTI

What about we take the ideas that we have had previously, and incorporate a way to hash the name of a class and bind that hash via a CRTP mixin as before.

Set up some utility values for function signature reflection:

// Let's again use some modern headers
#include <concepts>
#include <string_view>

// Start by defining some common values for our function signatures.
#if defined(__clang__)
#define RTTI_FUNCSIG_MACRO __PRETTY_FUNCTION__
static inline constexpr auto RTTI_FUNCSIG_PREFIX = std::string_view("[T = ");
static inline constexpr auto RTTI_FUNCSIG_SUFFIX = std::string_view("]");
#elif defined(__GNUC__)
#define RTTI_FUNCSIG_MACRO __PRETTY_FUNCTION__
static inline constexpr auto RTTI_FUNCSIG_PREFIX = std::string_view("with T = ");
static inline constexpr auto RTTI_FUNCSIG_SUFFIX = std::string_view("; ");
#elif defined(__FUNCSIG__)
#define RTTI_FUNCSIG_MACRO __FUNCSIG__
static inline constexpr auto RTTI_FUNCSIG_PREFIX = std::string_view("get_type_name<");
static inline constexpr auto RTTI_FUNCSIG_SUFFIX = std::string_view(">(void)");
#else
#define RTTI_FUNCSIG_MACRO __func__
static inline constexpr auto RTTI_FUNCSIG_PREFIX = std::string_view();
static inline constexpr auto RTTI_FUNCSIG_SUFFIX = std::string_view();
#endif

Use the utility values for parsing typenames at compile-time:

// From here we can using some reflection magic for compile-time typenames.
template <class T> inline static consteval std::string_view rtti_get_name() noexcept {
    // Prepare the baseline signature details to be used.
    static constexpr auto s_prefix = RTTI_FUNCSIG_PREFIX;
    static constexpr auto s_suffix = RTTI_FUNCSIG_SUFFIX;

    // Ensure that function-signatures can be validly deduced.
    static_assert(!!s_prefix.size() && !!s_suffix.size(), "Cannot deduce name-of value");

    // And actually slice out the typename.
    static constexpr auto signature = String::View(RTTI_FUNCSIG_MACRO);
    static constexpr auto start = signature.find(s_prefix) + s_prefix.size();
    return signature.substr(start, signature.find(s_suffix) - start);
}

Hash the compile-time typenames to construct distinct tags:

// Alias our values with a sized anonymous tag.
enum class Tag : uint64_t {};

// For hashing we will go with the FNV-1a algorithm.
static constexpr uint64_t rtti_fnv1a_prime = 0x100000001b3;
static constexpr uint64_t rtti_fnv1a_offset = 0xcbf29ce484222325;

// Prepare a hashing function that works on compile-time strings.
static inline consteval Tag rtti_get_hash(const std::string_view& name) {
    uint64_t hash = rtti_fnv1a_offset;
    for (char c : str) hash = (hash ^ static_cast<uint64_t>(c)) * rtti_fnv1a_prime;
    return static_cast<Tag>(hash);
}

// Allow resolving typenames into a distinct compile-time hash.
template <class T> static inline constexpr Tag rtti_get_hash() {
    return rtti_get_hash(rtti_get_name<std::remove_cvref_t<T>>());
}

With this we can rebuild the Instrument class to now use compile-time hashed tags instead:

// Base instruments now define a tag and a validation method.
struct Instrument {
    // Supposes we have a suitable mixing for simplifying setup.
    template <class T, std::derived_from<Instrument> B = Instrument> struct Mixin;

    // We now make the tag be purely virtual to enforce "Mixin" usage.
    virtual Tag tag() const noexcept = 0;

    // Additionally get access to the name of the derived class.
    virtual constexpr std::string_view name() const noexcept = 0;

    // We can also now define a validation handler as well.
    template <class ...As> constexpr bool is() const noexcept { return (m_is(rtti_get_hash<As>()) || ...); }

protected:
    // Define a validation handler for our "Mixin" instances (with this as the base case).
    virtual constexpr bool m_is(Tag) const noexcept { return false; }
};

// For brevity we have pulled the declaration for "Mixin" outside of the "Instrument" class.
template <class T, std::derived_from<Instrument> B = Instrument> struct Instrument::Mixin : public B {
    // We can now bind the compile-time tag for the CRTP typing.
    virtual Tag tag() const noexcept override { return rtti_get_hash<T>(); }

    // Why not add the name since we now have that as well.
    virtual std::string_view name() const noexcept override { return rtti_get_name<T>(); };

protected:
    // We allow checking against parent tags whilst possible.
    virtual bool m_is(Tag other) const noexcept override { return other == tag() || B::m_is(other); }
};

This also allows us to go deeper into more nested abstractions:

// Prepare some initial instruments that can be inherited from.
struct Piano : public Instrument<Piano> {};
struct Guitar : public Instrument<Guitar> {};

// Define some derived classes that is more than just a guitar.
struct Fender : public Instrument<Fender, Guitar> {};
struct Gibson : public Instrument<Gibson, Guitar> {};

// Now we can validate against primary derived classes, or switch based on nested derivations.
void bar(Instrument* instrument) {
    // Ignore handling when we do not have a suitable "Guitar" like instrument.
    if (!instrument->is<Guitar>()) return;

    // handle based on the given "Guitar" derived classes.
    switch (instrument->tag()) {
        case rtti_get_hash<Fender>(): std::println("Jimi Hendrix"); break;
        case rtti_get_hash<Gibson>(): std::println("Jimmy Page"); break;
    }
}

Example in Practice

Although the above is a rather daunting amount of boiler-plate to allow simplifying the ergonomics of RTTI (and CTTI), this is the core design behind the AST nodes and other data structures in the Sabre programming language.

These cases also showcase a utility for statically casting to derived types effectively bypassing the overhead of dynamic_cast whilst also maintaining ergonomics.

Further Reflection

With the incoming C++26 release of the <meta> static reflection standard library, this could further be improved by instead using the std::meta::info directly over a compile-time tag.

// Hopefully this becomes available soon!
#include <meta>

// We could remove all the previous boiler-plate utilities (ensuring references to "Tag" are changed to "std::meta::info"):
template <class T, std::derived_from<Instrument> B = Instrument> struct Instrument::Mixin : public B {
    // We can now bind the compile-time tag for the CRTP typing.
    virtual std::meta::info info() const noexcept override { return ^^T; }

    // Why not add the name since we now have that as well.
    virtual std::string_view name() const noexcept override { return std::meta::identifier_of(info()); };

protected:
    // We allow checking against parent tags whilst possible.
    virtual bool m_is(const std::meta::info& other) const noexcept override { return other == info() || B::m_is(other); }
};
Last updated on October 10, 2026