Beautiful Type Erasure with C++26 Reflection
Elegant Type Erasure via C++26 Reflection
By Ryan Keane
If you have ever attempted to implement type erasure for anything more complex than the basic std::any or std::function, you likely encountered one of two paths: writing hundreds of lines of fragile, manual boilerplate or relying on heavy-duty libraries like Folly.Poly or Boost.TypeErasure.
rjk::duck leverages the power of C++26 reflection to eliminate these hurdles, providing a seamless experience without sacrificing performance or flexibility.
A Practical Example
Consider a scenario where we want a generic container interface:
#include <rjk/duck.hpp>
#include <vector>
#include <string>
#include <map>
// Define the interface using a trait annotation
struct [[ = rjk::trait ]] Container {
auto size() const - std::size_t;
auto empty() const - bool;
auto clear() - void;
};
int main() {
// The duck can hold any type that satisfies the Container trait
rjk::duck<Container> c { std::vector<int>{1, 2, 3} };
c.size(); // Returns 3
c = std::string{"hello"}; // Runtime type swap
c.size(); // Returns 5
c = std::map<int, int>{{1, 2}, {3, 4}};
c.empty(); // Returns false
}
Instead of writing complex wrappers, you simply define the interface once, and the library handles the underlying implementation.
Key Capabilities of rjk::duck:
- Owning and non-owning semantics
- Support for operators
- Interface composition
- Adapters for legacy interfaces
- Extension methods for external types
Warning: This is bleeding-edge technology. Currently, this is only supported on GCC using the flags
-std=c++26 -freflection.
Demystifying the Magic
The library uses reflection in ways that go far beyond simple JSON serialization. To understand how rjk::duck works, we need to look at four primary mechanisms: tag generation, vtable codegen, overload resolution, and interconvertibility.
1. C++26 Reflection Basics
In the example above, you saw [[=rjk::trait]]. This is a C++26 annotation, which functions similarly to an attribute but is accessible via reflection.
The trait identifier is defined simply as:
constexpr inline struct {} trait {};
To detect if a type possesses this annotation, the library uses the ^^ (reflection) operator:
return std::ranges::any_of(
annotations_of( ^^ MyType ),
[](std::meta::info annotation) {
return type_of(annotation) == type_of( ^^ trait );
}
);
Key Concepts:
^^: The reflection operator. It turns a type or variable into astd::meta::infoobject.std::meta::info: The universal type for all reflections.annotations_of/type_of: Meta-functions used to query the properties of a reflection.
2. The Tag Generation Process
The first step is converting the members of a trait struct into internal tags. For instance, if we have:
struct [[ = rjk::trait ]] MyTrait {
auto foo() - void;
auto bar() const - int;
};
The goal is to produce tags like has_fn "foo", auto() - void and has_fn "bar", auto() const - int.
The Transformation Pipeline:
The implementation looks like this:
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) // No constructors
| std::views::filter(std::meta::is_function) // No data members
| std::views::filter(std::meta::has_identifier) // No operators
| std::views::transform([](std::meta::info member) {
const fixed_string name { identifier_of(member) };
const auto signature = type_of(member);
// Create the tag: substitute( ^^ has_fn, { name, signature })
return substitute( ^^ has_fn, { reflect_constant(name), signature });
})
| std::ranges::to<std::vector>();
}
3. Generating the VTable
C++26 provides a limited but powerful way to generate code. The vtable is constructed using a consteval block and the define_aggregate function.
The Logic Flow:
- Iterate through all provided
Traitsusingtemplate for. - Convert each trait to tags via
members_to_tags. - Transform the function signature to include a
void*(the object pointer). - Define the final vtable structure.
Mathematical Representation of the Signature Shift: If a trait method is defined as , the vtable entry is transformed into:
Implementation Snippet:
template <is_trait... Traits>
consteval {
std::vector<std::meta::info> members { /* basic ops: 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]);
// Prepend void* to the function signature
const auto signature = add_pointer(prepend_arg(func_type, ^^ void*));
const auto member = data_member_spec(signature, { name });
members.push_back(member);
}
}
define_aggregate( ^^ vtable, members );
}
Summary Comparison
| Feature | Traditional Type Erasure | rjk::duck (C++26) |
|---|---|---|
| Boilerplate | (Manual wrappers) | (Trait-based) |
| Maintenance | Automatic (Reflection-based) | |
| Performance | VTable overhead | VTable overhead (Optimized) |
| Flexibility | Rigid | Highly Composable |