Beautiful Type Erasure with C++26 Reflection in rjk::duck

Beautiful Type Erasure with C++26 Reflection in rjk::duck

Overview

rjk::duck uses C++26 reflection to implement a compact, high‑performance type‑erasure library that replaces hundreds of lines of boilerplate with a single header. The library lets you declare an interface once (as a trait) and then store any type that satisfies that interface, with zero‑overhead virtual‑dispatch semantics.


Declaring a Trait and Using the Duck

The core idea is to annotate a struct with [[=rjk::trait]] and list the required member functions:

#include <rjk/duck.hpp>

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

rjk::duck<Container> c{std::vector<int>{1,2,3}}; // stores a vector
c.size();   // → 3
c = std::string{"hello"};                     // swap at runtime
c.size();   // → 5
c = std::map<int,int>{{1,2},{3,4}};            // another swap
c.empty();  // → false
c.clear();

The duck automatically forwards calls to the underlying object, handling type changes at runtime without any manual vtable code.


How Reflection Generates the Trait Tags

C++26 introduces annotations ([[=rjk::trait]]) and the ^^ operator, which yields a compile‑time meta‑info object. The library inspects the members of a trait and converts each function into a tag of the form has_fn<"name", signature>.

consteval auto members_to_tags(std::meta::info trait)
    -> std::vector<std::meta::info> {
    const auto ctx = std::meta::access_context::unprivileged();
    return members_of(trait, ctx)
        | 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 m) {
            const fixed_string name{identifier_of(m)};
            const auto sig = type_of(m);
            return substitute(^^has_fn, {reflect_constant(name), sig});
        })
        | std::ranges::to<std::vector>();
}

The transformation is straightforward: filter public, user‑declared functions, extract their identifier and type, and build a has_fn tag.


Generating a Compile‑Time V‑Table

Using a consteval block, duck builds a static v‑table struct that holds a function pointer for each tag. The new template for syntax iterates over the trait pack, and define_aggregate creates the aggregate type.

template<is_trait... Traits>
struct vtable_generator {
    struct vtable; // forward declaration
    consteval {
        std::vector<std::meta::info> members{ /* typeid, copy, move, destroy … */ };
        constexpr static std::array<std::meta::info, sizeof...(Traits)> traits{^^Traits...};
        template for (constexpr auto trait : traits) {
            for (const auto tag : members_to_tags(trait)) {
                const auto args = template_arguments_of(tag);
                const auto name = extract<fixed_string>(args[0]);
                const auto func_type = remove_fn_qualifiers(args[1]);
                const auto signature = add_pointer(prepend_arg(func_type, ^^void*));
                const auto member = data_member_spec(signature, {.name = name});
                members.push_back(member);
            }
        }
        define_aggregate(^^vtable, members);
    }
};

The resulting v‑table for Container contains three pointers:

struct vtable {
    auto (*size) (const void*) -> std::size_t;
    auto (*empty)(const void*) -> bool;
    auto (*clear)(void*)       -> void;
};

Converting a Member to an Erased Call

Each v‑table slot stores a pointer to a thin wrapper that erases the concrete type. The wrapper follows the classic type‑erasure pattern:

template<class T, class Invoker, class Ret, class... Args>
struct vtable_fn_maker {
    static constexpr Ret erased_call(void* self, Args... args) {
        return std::invoke(Invoker{}, *static_cast<T*>(self), std::forward<Args>(args)...);
    }
};

Invoker is generated at compile time as an overload_set that performs overload resolution for the target type.


Building Callable Members via Inheritance

Reflection cannot inject member functions directly, so duck creates a wrapper struct for each tag. Each wrapper holds a vtable_function object that forwards calls to the static v‑table.

template<std::meta::info VtblMember, typename Func>
class vtable_function;

template<std::meta::info VTblMember, typename Ret, typename... Args>
class vtable_function<VTblMember, auto(Args...) -> Ret> {
public:
    constexpr vtable_function(duck<MyTrait>& owner) : m_owner(&owner) {}
    constexpr Ret operator()(Args... a) const {
        return m_owner->get_vtable()->[: VTblMember :](
            m_owner->get_underlying(), std::forward<Args>(a)...);
    }
private:
    duck<MyTrait>* m_owner;
};

A vtable_wrapper aggregates all these wrappers via multiple inheritance, giving the duck object the clean myDuck.foo() syntax.


Eliminating the Back‑Pointer with Pointer‑Interconvertibility

Storing a back‑pointer in every vtable_function would make duck grow linearly with the number of traits. The library instead relies on pointer‑interconvertibility: a vtable_function is a zero‑size subobject placed as the first member of a vtable_function_wrapper. By reinterpret‑casting this to the wrapper and then static‑casting to the concrete duck type, the call operator can recover the owning object without extra storage.

constexpr auto duck_base<Derived, Tags...>::vtable_function<VMember, Tag, auto(Args...) -> Ret>
    ::operator()(Args... a) -> Ret {
    auto* wrapper = reinterpret_cast<vtable_function_wrapper<Tag>*>(this);
    auto* owner   = static_cast<Derived*>(wrapper);
    return owner->get_vtable()->[: VMember :](owner->get_underlying(), std::forward<Args>(a)...);
}

Because each wrapper is marked [[no_unique_address]], the total size of duck is independent of the number of trait functions.


Compile‑Time Possibilities

All functions are constexpr, and the underlying mechanism (void* ↔ concrete type) is compatible with the upcoming P2738 proposal. While GCC‑trunk currently permits compile‑time void* casts, full constexpr support will arrive once the standard adopts the proposal.


Performance Tweaks: Inlining Selected Functions

duck can inline specific functions to avoid the v‑table indirection. A special perf_options trait lists the functions to inline, and a vtable_caller wrapper dispatches either to the inlined member or to the static v‑table based on compile‑time detection.

struct [[=rjk::perf_options]] MyPerfOptions {
    struct inlined_functions {
        auto importantFunc(int, int) -> int;
    };
};

When instantiated as rjk::duck<MyTrait, MyPerfOptions>, the importantFunc call becomes a direct function‑pointer call, at the cost of a few extra bytes per duck instance.


Community Feedback

  • Positive reaction – Users note that the library feels like “an entirely different language” and showcases the power of modern C++ reflection.
  • Compilation time concerns – Some ask about compile‑time overhead; the author acknowledges that the feature is still experimental and that concrete numbers are pending.
  • Debuggability – A few commenters find static reflection hard to debug, a known trade‑off of heavy compile‑time metaprogramming.
  • Safety of HTTP includes – One comment worries about including headers via HTTP URLs; the library itself ships as a single local header, and the HTTP example is limited to Compiler Explorer.

Takeaway

rjk::duck demonstrates that C++26 reflection can replace hand‑written type‑erasure machinery with concise, compile‑time generated code that offers zero‑overhead virtual dispatch, customizable inlining, and a clean call syntax. The implementation lives in a single header, works with GCC’s -std=c++26 -freflection, and serves as a practical showcase of what reflection can achieve today.

Sources