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.