DEV Community

Ashraf
Ashraf

Posted on

C++26 Reflection Makes Type Erasure Readable Again

C++26 Reflection Makes Type Erasure Readable Again

If you search for C++26 reflection, type erasure in C++, or compile-time vtable generation, you usually find one of two extremes: a toy enum-to-string example or a wall of templates that nobody wants to maintain. The interesting middle ground is a new technique demonstrated by Ryan Keane's rjk::duck: describe an interface once, then use reflection to generate the type-erasure machinery around it.

The result is not magic and it is not production-ready everywhere. It is a useful look at what C++26 reflection can do when applied to a problem that normally demands a lot of repetitive boilerplate.

What C++26 reflection changes for type erasure

Traditional type erasure stores an object behind an opaque pointer and dispatches operations through a manually defined vtable. The implementation usually needs:

  • a concept or interface description;
  • one erased function pointer per operation;
  • adapters that cast void* back to the concrete type;
  • overload handling;
  • ownership and lifetime logic;
  • careful const and reference qualification.

That design is efficient, but the interface and the dispatch code can drift apart. Add an operation and you may need to update several unrelated templates.

The rjk::duck approach starts with an interface-like trait:

struct [[=rjk::trait]] Container {
    auto size() const -> std::size_t;
    auto empty() const -> bool;
    auto clear() -> void;
};
Enter fullscreen mode Exit fullscreen mode

The interface is then used as the erased contract. A duck<Container> can hold different concrete types as long as they satisfy the operations:

rjk::duck<Container> c{std::vector<int>{1, 2, 3}};

c.size();                         // 3
c = std::string{"hello"};
c.size();                         // 5

c = std::map<int, int>{{1, 2}, {3, 4}};
c.empty();                       // false
c.clear();
c.empty();                       // true
Enter fullscreen mode Exit fullscreen mode

The important idea is not the wrapper's name. It is that reflection turns the trait's members into data the library can inspect and transform at compile time.

Turning reflected members into tags

C++26 reflection exposes declarations as std::meta::info values. The library can inspect the members of the trait, keep public user-declared functions, read their identifiers and signatures, and convert them into internal tags.

Conceptually, a member such as:

auto size() const -> std::size_t;
Enter fullscreen mode Exit fullscreen mode

becomes something like:

has_fn<"size", auto() const -> std::size_t>
Enter fullscreen mode Exit fullscreen mode

The transformation has a straightforward shape:

consteval auto members_to_tags(std::meta::info trait)
    -> std::vector<std::meta::info>
{
    const auto context =
        std::meta::access_context::unprivileged();

    return members_of(trait, context)
        | std::views::filter(std::meta::is_user_declared)
        | std::views::filter(std::meta::is_function)
        | std::views::filter(std::meta::has_identifier)
        | std::views::transform([](std::meta::info member) {
            const fixed_string name{identifier_of(member)};
            const auto signature = type_of(member);

            return substitute(
                ^^has_fn,
                {reflect_constant(name), signature});
        })
        | std::ranges::to<std::vector>();
}
Enter fullscreen mode Exit fullscreen mode

This is the first practical payoff of C++26 reflection: the declaration is the source of truth. The library does not need a second, manually maintained list of function names.

^^ produces a reflection. members_of, identifier_of, and type_of query it. substitute builds a new reflected specialization. The output remains compile-time metadata until the library uses it to generate concrete code.

Compile-time vtable generation

A type-erased object still needs a vtable. Reflection does not remove that runtime mechanism; it removes much of the hand-written code needed to construct it.

For every reflected operation, the library creates a function-pointer slot. A simplified slot might look like this:

using size_slot = std::size_t (*)(void*);
using clear_slot = void (*)(void*);
Enter fullscreen mode Exit fullscreen mode

The actual implementation also has to account for qualifiers, arguments, return types, ownership operations, and overloads. The central pattern is still familiar: generate a vtable type, then fill it with functions for the concrete type.

A generated erased call can be reduced to this:

template <typename T, typename Invoker,
          typename Ret, typename... Args>
struct vtable_fn_maker {
    static Ret erased_call(void* self, Args... args) {
        auto* typed = static_cast<T*>(self);
        return std::invoke(
            Invoker{}, *typed,
            std::forward<Args>(args)...);
    }
};
Enter fullscreen mode Exit fullscreen mode

The cast is the type-erasure boundary. The vtable stores a pointer to erased_call; the caller knows only the erased signature, while the function body recovers T and invokes the selected operation.

With reflection, the library can generate the vtable's aggregate members in a consteval context and assign to those generated members using the splice operator:

result.[:slot_reflected_member:] = generated_function;
Enter fullscreen mode Exit fullscreen mode

That is a major shift from older metaprogramming styles. Instead of encoding every possible vtable layout in nested templates, the program can inspect declarations and define an aggregate from the resulting metadata.

Let the language solve overload resolution

Overloads are where a hand-rolled type-erasure implementation becomes fragile. A trait may require foo(int), while a concrete type offers several foo overloads with different cv/ref qualifiers. Reimplementing C++ overload resolution in templates is a bad bargain.

The better strategy is to generate a normal overload set and let the compiler do its job:

template <std::meta::info Member,
          typename Self, typename... Args>
struct candidate_wrapper {
    constexpr decltype(auto)
    operator()(Self self, Args... args) const {
        return std::invoke(
            &[:Member:],
            std::forward<Self>(self),
            std::forward<Args>(args)...);
    }
};
Enter fullscreen mode Exit fullscreen mode

The library creates one candidate_wrapper for each matching member, then combines them using the familiar inheritance pattern:

template <typename... Callables>
struct overload_set : Callables... {
    using Callables::operator()...;
};
Enter fullscreen mode Exit fullscreen mode

Now a call is presented to ordinary C++ overload resolution. That preserves conversions and qualification rules instead of approximating them with a custom ranking algorithm.

For example, a concrete type might provide:

struct MyStruct {
    auto foo(double) -> int;
    auto foo(int) const -> int;
};
Enter fullscreen mode Exit fullscreen mode

A call through a trait that requires foo(int) can select the const overload when the erased object is const. The library's job is to generate the candidates and the call shape—not to become a second C++ compiler.

The limitations matter

This is bleeding-edge code. Keane's article reports that the current example requires a GCC build with -std=c++26 -freflection. That alone makes it unsuitable as a drop-in dependency for most production systems today.

There are other practical constraints:

  • Reflection support and syntax are still evolving.
  • Compile-time code generation can increase build complexity.
  • Diagnostics from generated metadata are not always pleasant.
  • Overloads, operators, inheritance, const traits, and adapters need more machinery than the simplified examples show.
  • Type erasure still has runtime costs and lifetime hazards; reflection does not make them disappear.

The right conclusion is not “replace every virtual interface.” It is “the compiler may finally be able to generate the repetitive parts of a type-erasure library without hiding the interface behind a second DSL.”

What to take from this design

The most useful lesson from this C++26 reflection experiment is architectural:

  1. Describe the contract once.
  2. Reflect over the contract's declarations.
  3. Transform declarations into internal tags.
  4. Generate the vtable shape at compile time.
  5. Generate callable candidates and use normal overload resolution.
  6. Keep the runtime boundary small and conventional.

That division is sensible. Reflection handles structure; the language handles overloads; the runtime handles erased dispatch.

C++ has historically made type erasure possible but expensive to express. C++26 reflection points toward a different tradeoff: keep the runtime representation familiar while making the compile-time plumbing derive itself from the interface.

It is not ready to erase every abstraction by default. But it is already enough to show that “zero-boilerplate type erasure” can mean something more precise than a marketing claim.

Sources

Top comments (0)