Skip to content

Which reflection capabilities beyond C++ 26 are needed to implement this purely in C++ #29

@philipcraig

Description

@philipcraig

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 to string_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_I and protocol_const_concept_I for 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:

  1. Parse an interface header (e.g., interface_A.h) using libclang via py_cppmodel
  2. Extract class metadata: name, namespace, and for each member function: name, return type, parameter types and names, const-qualification
  3. Generate MD5-based GUIDs for each method signature (to disambiguate overloads in the vtable)
  4. 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-qualification

Status: 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 use t.method()
  • Control block: const methods get const qualifier on virtual/override
  • protocol_view<const I>: only includes const methods
  • View dispatch: const methods cast to const T*, non-const to T*; const views use const 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
  • P3019std::indirect and std::polymorphic: the value-semantic polymorphism design that protocol builds upon
  • PEP 544 — Python's Protocol for structural subtyping: the conceptual inspiration
  • D4148R0 — The draft C++ proposal for protocol itself (included in the repo as DRAFT.md, dated 2026-03-25, co-authored by Jonathan Coe, Hana Dusikova, Antony Peacock, and Philip Craig)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions