-
Notifications
You must be signed in to change notification settings - Fork 2
Which reflection capabilities beyond C++ 26 are needed to implement this purely in C++ #29
Description
C++ Reflection Requirements for cc-protocol
An analysis of the reflection and code-injection capabilities that future C++ standards must provide in order to implement the cc-protocol library purely within the language, eliminating the need for external Python/Jinja2 code generation.
What cc-protocol does
cc-protocol implements structural subtyping (duck typing) with value semantics for C++. Given a plain struct that declares an interface through its member function signatures (no virtual functions, no base classes), the library generates:
-
protocol<I, Allocator>: An owning, type-erased wrapper with deep-copy value semantics, const-propagation, allocator awareness, and a valueless-after-move state. Any type that structurally matches the interface (i.e., provides member functions with compatible signatures) can be stored in it. -
protocol_view<I>: A non-owning, lightweight reference (analogous tostring_view) for zero-allocation duck typing at function boundaries. -
protocol_view<const I>: A const-restricted view that only exposes const-qualified methods. -
C++20 concepts:
protocol_concept_Iandprotocol_const_concept_Ifor compile-time structural validation with clear error messages.
The library provides two implementation strategies: one using virtual inheritance for dispatch, and one using manually constructed static vtables with function pointers. Both are generated from the same interface definition.
How the code generator works today
The current pipeline is:
- Parse an interface header (e.g.,
interface_A.h) using libclang viapy_cppmodel - Extract class metadata: name, namespace, and for each member function: name, return type, parameter types and names, const-qualification
- Generate MD5-based GUIDs for each method signature (to disambiguate overloads in the vtable)
- Feed the metadata into a Jinja2 template that emits a complete C++ header file containing all the generated types
Reflection capabilities required
1. Member function introspection
What: Given a class type, enumerate all its declared member functions and extract for each: the function name, return type, each parameter's type, and whether the function is const-qualified.
Why: This is the foundational operation. Every generated construct — concepts, control blocks, forwarding functions, vtables — is driven by iterating over the interface's member functions.
Current code-gen equivalent:
for m in target_class.methods:
m.name # function name
m.return_type.name # return type
m.arguments # parameter list
m.is_const # const-qualificationStatus: Achievable with C++26 P2996. The ^ operator on a class type yields a reflection of its members, and std::meta::members_of() can iterate them. std::meta::name_of(), std::meta::type_of(), std::meta::parameters_of(), and std::meta::is_const() provide the required introspection.
2. Class and namespace identity
What: Given a reflected class, retrieve its unqualified name and its enclosing namespace to construct fully-qualified names like ::xyz::A.
Why: The generated code needs the fully-qualified class name in template specializations (e.g., template <> class protocol<::xyz::A, Allocator>) and in constraints that reference the original interface type.
Current code-gen equivalent:
{% set full_class_name = "::" ~ c.namespace ~ "::" ~ c.name %}Status: Achievable with C++26 P2996 via std::meta::name_of() and std::meta::parent_of().
3. Concept synthesis from introspected methods
What: Given the set of introspected member functions, programmatically generate a C++20 concept that constrains a type T to provide all those functions with compatible signatures.
Why: The library generates two concepts per interface: protocol_concept_I (requires all methods, respecting const on const methods and using mutable T& for non-const methods) and protocol_const_concept_I (requires only the const methods, via const T&).
Generated pattern:
template <typename T>
concept protocol_concept_A = requires(T& t) {
{ std::as_const(t).name() } -> std::convertible_to<std::string_view>;
{ t.count() } -> std::convertible_to<int>;
};Why it's hard: The concept body must be assembled from a compile-time loop over reflected methods. Each requires-clause must splice in the method name, the std::declval<> expressions for parameters, the return type constraint, and conditionally route through std::as_const(t) for const methods. This requires either compile-time code injection or a way to build requires-expressions from reflection data.
Status: Partially achievable with C++26. Compile-time validation is possible via std::meta introspection in consteval functions, but generating a named concept definition that can be used in requires clauses on constructors likely requires code injection.
4. Class template specialization injection
What: Programmatically define a partial specialization of a class template, e.g., protocol<::xyz::A, Allocator>.
Why: The entire owning wrapper, its inner control block hierarchy, and the non-owning view are all partial specializations of protocol and protocol_view. The primary templates deliberately static_assert(false) to ensure only generated specializations compile.
Status: Requires post-C++26 code injection. There is no mechanism in P2996 to inject a template specialization.
5. Virtual member function injection
What: Define a class with virtual member functions whose signatures are derived from reflection data — i.e., for each introspected method, inject a corresponding pure virtual function into an inner control_block base class.
Why: The virtual-dispatch implementation strategy generates an inner control_block class with pure virtual methods mirroring the interface:
class control_block {
public:
virtual control_block* xyz_protocol_clone(const Allocator& alloc) = 0;
virtual control_block* xyz_protocol_move(const Allocator& alloc) = 0;
virtual void xyz_protocol_destroy(const Allocator& alloc) = 0;
// One virtual method per interface method:
virtual std::string_view name() const = 0;
virtual int count() = 0;
};Status: Requires post-C++26. std::meta::define_class in C++26 is limited to data members. Injecting virtual member functions requires generalized code injection.
6. Override function injection in derived class templates
What: For a class template direct_control_block<T> that derives from control_block, inject override functions that forward calls to the stored T object. Each override must splice in the correct parameter types, forward arguments, and handle the return type.
Why: This is how type erasure works — the concrete type T is stored in a union, and the override functions bridge between the virtual interface and T's actual member functions:
template <typename T>
class direct_control_block final : public control_block {
// ...
std::string_view name() const override {
return storage_.t_.name();
}
int count() override {
return storage_.t_.count();
}
};Status: Requires post-C++26. Same dependency on generalized member function injection, plus the ability to splice reflected parameter types into function signatures and forwarding calls.
7. Function pointer type synthesis for manual vtables
What: Generate a struct of function pointers where each pointer's type is derived from an introspected method signature. For overloaded methods, the function pointer field names must be disambiguated.
Why: The manual vtable strategy avoids virtual dispatch overhead by using a struct of function pointers. The current implementation uses MD5 hashes of method signatures to generate unique field names for overloaded methods:
struct vtable {
void* (*xyz_protocol_clone)(void* cb, const Allocator& alloc);
void* (*xyz_protocol_move)(void* cb, const Allocator& alloc);
void (*xyz_protocol_destroy)(void* cb, const Allocator& alloc);
// For interface C with overloaded compute():
int (*compute_a1b2c3d4)(void* cb, int);
double (*compute_e5f6g7h8)(void* cb, double);
std::string (*compute_i9j0k1l2)(void* cb, const std::string&);
};Status: Requires post-C++26. Needs the ability to inject data members (function pointers) with programmatically determined types, plus a mechanism to generate unique identifiers for overloaded method slots.
8. Static function generation for vtable population
What: For each interface method, generate a static function in direct_control_block<T> that casts void* to the concrete type and forwards the call. These static functions are used to populate the constexpr vtable.
Why:
static int compute_a1b2c3d4(void* cb, int a0) {
auto* self = static_cast<direct_control_block*>(cb);
return self->storage_.t_.compute(std::forward<decltype(a0)>(a0));
}
static constexpr vtable vtable_ = {
xyz_protocol_clone,
xyz_protocol_move,
xyz_protocol_destroy,
compute_a1b2c3d4,
compute_e5f6g7h8,
compute_i9j0k1l2,
};Status: Requires post-C++26. Needs member function injection and the ability to construct aggregate initializers from reflected data.
9. Public forwarding member function injection
What: Inject public member functions into the protocol wrapper class that forward calls through the type-erasure mechanism (either virtual dispatch or vtable function pointers).
Why: The generated protocol must expose the same interface as the original struct. Each public method delegates to the control block:
// Virtual dispatch version:
std::string_view name() const { return cb_->name(); }
int count() { return cb_->count(); }
// Manual vtable version:
int compute(int a0) { return vtable_->compute_a1b2c3d4(cb_, a0); }Status: Requires post-C++26 code injection.
10. Parameter list splicing
What: Given a reflected function's parameter list, splice those parameter types and names into a generated function signature, and separately splice just the argument names into a forwarding call expression.
Why: Every generated function — virtual methods, overrides, static vtable functions, and public forwarding methods — needs to reproduce the parameter list from the interface. The current code generator builds these as string concatenation:
params = []
passes = []
for a in m.arguments:
params.append(f"{a.type.name} a{i}")
passes.append(f"std::forward<decltype(a{i})>(a{i})")This requires not just knowing the parameter types but being able to splice them into two different contexts: the function declaration and the forwarding expression.
Status: Requires post-C++26. P2996 supports splicing of individual reflections, but range-based splicing of an entire parameter list into a function signature and separately into a forwarding call requires more advanced injection capabilities. This is one of the capabilities that proposals like P3294 (code injection) aim to address.
11. Const-aware code branching during generation
What: Conditional code generation based on whether a reflected method is const-qualified. This affects multiple decisions: whether to use std::as_const(t) in concepts, whether to add const to override signatures, whether to cast to const T* in views, and whether to include a method in protocol_view<const I> at all.
Why: Const-correctness is central to the design. The generator makes different decisions at every level:
- Concepts: const methods use
std::as_const(t).method(), non-const uset.method() - Control block: const methods get
constqualifier on virtual/override - protocol_view<const I>: only includes const methods
- View dispatch: const methods cast to
const T*, non-const toT*; const views useconst void*
Status: Achievable with C++26 P2996. std::meta::is_const() on a reflected member function, combined with if constexpr or compile-time filtering, can drive conditional generation.
12. Overload disambiguation
What: When an interface has overloaded methods (same name, different parameter types), the manual vtable strategy needs unique identifiers for each overload's function pointer slot.
Why: Interface C demonstrates this with three overloads of compute():
struct C {
int compute(int x);
double compute(double x);
std::string compute(const std::string& x) const;
};The current generator creates MD5 hashes of the full signature to produce unique names (compute_a1b2c3d4, etc.). A reflection-based implementation needs some way to generate distinct identifiers for overloaded method slots in the vtable.
Status: Partially addressable. If code injection allows generating anonymous or indexed function pointer slots (rather than named fields), overload disambiguation becomes a non-issue. But if named fields are required, some form of compile-time identifier synthesis is needed, which is not in P2996.
13. Constexpr aggregate construction from reflected data
What: Construct a constexpr vtable instance by populating each function pointer field with a reference to the corresponding generated static function, where the set of fields and functions is determined by reflection.
Why:
static constexpr vtable vtable_ = {
xyz_protocol_clone,
xyz_protocol_move,
xyz_protocol_destroy,
compute_a1b2c3d4,
compute_e5f6g7h8,
compute_i9j0k1l2,
};This initializer list is driven by the reflected method set — each slot corresponds to a generated static function.
Status: Requires post-C++26 code injection that can build initializer lists from compile-time sequences of reflected members.
14. Non-owning view with static constexpr control blocks
What: Generate protocol_view types that store a void* plus a pointer to a static constexpr control block (or vtable), where the control block is a class-template-level constant parameterized on the concrete type T.
Why: The view types achieve zero overhead by using static constexpr singleton control blocks:
template <typename T>
static constexpr direct_control_block<T> cb_obj{};Or for the manual vtable variant:
template <typename T>
static constexpr vtable vtable_for = { /* lambdas or fn ptrs */ };These must be generated for each method, with cast-and-forward logic.
Status: Requires post-C++26. The generation of these constexpr instances with reflection-driven initializers depends on code injection.
15. Lambda or function synthesis from reflected signatures
What: In the manual vtable version of protocol_view, the vtable is populated with lambdas that cast void* and forward calls:
template <typename T>
static constexpr vtable vtable_for = {
[](void* ptr, int a0) -> int {
return static_cast<T*>(ptr)->compute(std::forward<decltype(a0)>(a0));
},
// ...
};Each lambda's signature and body must be synthesized from the reflected method.
Status: Requires post-C++26. Needs the ability to programmatically generate lambda expressions from reflected function signatures.
Summary: what's achievable when
C++26 (P2996 reflection)
- Introspecting a class's member functions: names, return types, parameters, const-qualification (requirements 1, 2, 11)
- Validating structural conformance at compile time (partial requirement 3)
- Conditional logic based on reflected member properties
Post-C++26 (code injection, expected C++29+)
Everything else — which is the bulk of the library:
- Generating concept definitions from reflected data (requirement 3)
- Injecting template specializations (requirement 4)
- Injecting virtual and override member functions (requirements 5, 6)
- Generating vtable structs and populating them (requirements 7, 8, 13)
- Injecting public forwarding methods (requirement 9)
- Splicing parameter lists into function signatures and forwarding calls (requirement 10)
- Overload disambiguation for vtable slots (requirement 12)
- Generating constexpr control blocks and lambdas (requirements 14, 15)
The critical missing piece is generalized code injection: the ability to programmatically define member functions, class specializations, and initializer expressions from compile-time reflection data. This is being explored in proposals such as P3294 (Code Injection with Token Sequences). Until this lands, external code generation (as cc-protocol demonstrates with Python/Jinja2) remains the only viable approach.
Relevant proposals and references
- P2996 — Reflection for C++26: provides the introspection foundation
- P3294 — Code Injection with Token Sequences: the most likely vehicle for the injection capabilities needed here
- P3019 —
std::indirectandstd::polymorphic: the value-semantic polymorphism design thatprotocolbuilds upon - PEP 544 — Python's Protocol for structural subtyping: the conceptual inspiration
- D4148R0 — The draft C++ proposal for
protocolitself (included in the repo asDRAFT.md, dated 2026-03-25, co-authored by Jonathan Coe, Hana Dusikova, Antony Peacock, and Philip Craig)