Pangram verdict · v3.3
We believe that this document is fully human-written
AI likelihood · overall
HumanArticle text · 1,450 words · 6 segments analyzed
If you’ve ever tried using type erasure for something more complicated than std::any or std::function, you’ve either written 100+ lines of easy-to-mess-up code or reached for a boilerplate-heavy library like Boost.TypeErasure or Folly.Poly. rjk::duck uses the magic that is C++26 reflection to remove these pain points while preserving all of the customization and performance.Consider the following basic example:1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 #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}}; c.size(); // 3
c = std::string{"hello"}; // swap underlying type at runtime c.size(); // 5
c = std::map<int, int>{{1, 2}, {3, 4}}; c.empty(); // false c.clear(); c.empty(); // true Simply declare the interface once and let your already-written definitions handle the rest.The library itself is a single header include. It offers owning and non-owning semantics, operators, interface composition, adapters for existing interfaces, extension methods for third-party types, and much more.We’re talking about the bleeding edge here, so right now support is only available for gcc with -std=c++26 -freflection. duck uses reflection in some unique ways that go beyond the simple enum-to-string or JSON serialization examples you may have seen, so we’ll spend the rest of the article demystifying the tricks that make this library possible.In particular, we’ll walk through tag generation, vtable codegen, overload resolution, and the interconvertibility trick that keeps duck small.If you want a less in-depth walkthrough that traces the entire mechanism, I’ve added The Full Picture as a recap section.
A Brief Intro to C++26 ReflectionYou may have noticed the strange bit of syntax from the above example, [[=rjk::trait]]. This is a C++26 annotation, which can be applied to a struct similarly to an attribute. The actual definition for trait is simply:1 constexpr inline struct{} trait{}; We can check if a type has the trait annotation like this:1 2 3 4 5 return std::ranges::any_of(annotations_of(^^MyType), [](std::meta::info annotation) { return type_of(annotation) == type_of(^^trait); } ); The ^^ operator produces a reflection of something. In this case, we are reflecting both a type (MyType) and a variable (trait). Reflections all have the std::meta::info type and can be queried with a variety of meta-functions, like annotations_of and type_of.One of the first steps in duck’s process is interpreting the members of a trait and converting them to a tag, which is a format that duck uses internally. So for this trait:1 2 3 4 struct [[=rjk::trait]] MyTrait { auto foo() -> void; auto bar() const -> int; }; Our goal is to generate a has_fn<"foo", auto() -> void> and has_fn<"bar", auto() const -> int> tag. We can implement this pretty easily by inspecting the members of MyTrait and transforming them, like so:1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 consteval auto members_to_tags(std::meta::info trait) -> std::vector<std::meta::info> { const auto ctx = std::meta::access_context::unprivileged(); // Only check public members return members_of(trait, ctx) // Take all members of the trait | std::views::filter(std::meta::is_user_declared) // Exclude constructors, etc.
| std::views::filter(std::meta::is_function) // Exclude data members | std::views::filter(std::meta::has_identifier) // Exclude operator functions, etc. | std::views::transform([](std::meta::info member) { // identifier_of returns the name as a string_view. fixed_string is a // custom structural type that can be used as a template argument. const fixed_string name{identifier_of(member)}; const auto signature = type_of(member); // Returns a function type
// Create has_fn<name, signature> const auto tag = substitute(^^has_fn, {reflect_constant(name), signature}); return tag; }) | std::ranges::to<std::vector>(); } The actual implementation has to handle a variety of other aspects to this, such as iterating base classes for traits, handling const traits, operators, and more. But the core transformation is as simple as it looks.Generating a vtableThe mechanism for code generation we got in C++26 is limited, but still very powerful. Let’s walk through each component of how the vtable is generated, starting with creating the struct:1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 template <is_trait... Traits> struct vtable_generator { struct vtable;
// ...
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]);
// Remove cvref qualifiers from the function 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 consteval block is a new feature and is the only context in which we can currently generate code, using define_aggregate. template for is the new syntax for an expansion statement, which lets us iterate over parameter packs without having to rely on fold expressions.Other than that, the code is pretty straightforward. We simply collect all of the traits, then collect all of their respective tags, and generate function pointers for each member function in the traits.One simplification worth acknowledging is that this approach can’t handle overloads, since you can’t have two members with the same name. The actual code will assign names like slot_0, slot_1, etc. and re-derive them later. We’re also just using plain void* here, but in reality we need to also make this const void* based on the function’s qualifier.Creating a static vtable for a type is likewise not too complicated:1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 // still inside vtable_generator
template <typename T> consteval static auto make_vtable() -> vtable { constexpr static auto ctx = std::meta::access_context::unprivileged(); constexpr static auto slots = define_static_array( nonstatic_data_members_of(^^vtable, ctx) | std::views::drop(/* copy, move, etc. */)
);
vtable result{};
template for (constexpr auto index : std::views::indices(slots.size())) { constexpr auto T_member = *std::ranges::find_if( members_of(^^T, ctx), [](std::meta::info member) { return is_function(member) && has_identifier(member) && identifier_of(member) == identifier_of(slots[index]) && type_of(member) == type_of(slots[index]); } ); result.[: slots[index] :] = convert_to_vtable_func<T>(T_member); }
return result; }
template <typename T> constexpr static auto vtable_for = make_vtable<T>(); Real overload resolution for finding T_member is significantly more involved (see below). We’re matching by exact signature here for clarity.The splice operator ([: expr :]) is another new feature, and it’s what brings your code from the land of reflection back into reality. Here, we use it to actually assign to each of the function pointers that were added to the generated vtable. std::views::indices is a nice library utility we got as well, which is simply equivalent to std::views::iota(0, upper_bound).The one loose thread remaining is convert_to_vtable_func. Somehow, we need to turn a T_member into an actual auto(*)(void*, Args...) -> Ret that we can store and call through. That conversion is where duck’s type erasure actually happens, so it’s worth taking apart properly.From Slot to CallAt its core, this is the same trick any type erasure library uses.1 2 3 4 5 6 7 template <typename T, typename Invoker, typename Ret, typename... Args> struct vtable_fn_maker { constexpr static auto erased_call(void* self, Args... args) -> Ret { auto* typed = static_cast<T*>(self); return std::invoke(Invoker{}, *typed, std::forward<Args>(args)...); } }; convert_to_vtable_func (roughly) will end up generating a pointer to this erased_call function, which ultimately gets stored in the vtable.The Invoker here is suspicious, and doesn’t match the T_member we saw from the previous section.
Previously, we just looked for an exact function match, but actual overload resolution can’t be that simple. As it turns out, duck doesn’t try to reimplement it by hand.Making the CallThe reader accustomed to C++17 might be familiar with this common utility:1 2 3 4 template <typename... Callables> struct overload_set : Callables... { using Callables::operator()...; }; Rather than trying to manually reproduce C++ overload resolution, we instead generate a callable overload_set and simply let the language do the work. The actual mechanism relies on two parts. First, candidate_wrapper:1 2 3 4 5 6 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)...); } }; This is a simple wrapper class that takes in some member function from Self, splices it (&[:Member:]) to obtain a member function pointer, and then invokes it with the given type and arguments. The key detail is that this turns any myObj.foo(args...) call into a myWrapper(myObj, args...) call, which can then get substituted into overload_set like so:1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 consteval auto make_set(std::meta::info type, std::string_view identifier) -> std::meta::info { const auto members = members_of(type, ctx); // ctx is still a std::meta::access_context