Non-Intrusive Interfaces for C++

Document #: P4374R0 [Latest] [Status]
Date: 2026-09-07
Project: Programming Language C++
Audience: LEWG
Reply-to: Theo Payne
<>

1 Introduction

C++26 Reflection makes it possible for library code to inspect and use compile-time information about concrete types. This enables a different approach to dynamic polymorphism in which the relationship between an interface and a concrete type can be established without requiring the concrete type to participate in an inheritance hierarchy.

This paper explores a library facility for defining interfaces independently of the concrete types that may satisfy them. An existing object can be viewed through such an interface as a lightweight, non-owning runtime reference wrapper, while its storage and lifetime remain the responsibility of the surrounding program.

The design aims to mirror, as much as practical, the semantic model and usability of ordinary C++ virtual polymorphism, including interface composition, overriding, conversions between related interface views, and runtime identification of the underlying dynamic object. Unlike traditional inheritance-based polymorphism, however, the concrete types need not inherit from, or otherwise explicitly reference, the interface.

The purpose of this paper is to explore this design space and solicit feedback on its applicability, semantics, and appropriate path toward potential standardization. A complete working implementation was developed to validate the proposed design and investigate its runtime and compile-time characteristics. The implementation is currently not publicly available. The results presented in this paper are based on that implementation.

2 Examples

The examples in this section constitute the primary presentation of the proposed design. Collectively, they make the design space concrete, establish a coherent programming model, and illustrate the intended relationships among the proposed facilities. They are deliberately presented at the level of observable use and behavior rather than as a complete specification of their semantics, providing a basis for the feedback sought by this paper.

Every example in this paper places library-introduced names under the namespace stdx::. This is a placeholder, not a naming proposal: it stands in for wherever this facility would ultimately live were it adopted, most plausibly std:: itself. Its purpose here is purely to let a reader distinguish, at a glance, which names this paper is introducing from which names already exist in the standard library or core language. No claim about the eventual namespace, or about any other naming choice, should be inferred from stdx:: beyond that distinction.

2.1 Defining and using an interface

An interface can be defined independently of the concrete types that will later be used with it. The interface describes only the operations required by the consumer — in the example below, a single operation, print_name. No concrete type is required to inherit from a base class or otherwise declare that it implements animal; the definition of the interface is entirely separate from the definition of any type that may satisfy it.

An interface reference — the value returned by animal.ref(x) — is a lightweight, non-owning view, analogous to std::reference_wrapper: interface.ref(x) plays the same role for an object that std::ref(x) plays for a plain reference. It does not take ownership of the referred-to object, and the object’s lifetime remains the caller’s responsibility. In the tested prototype, an interface reference is represented in two machine words; this is an implementation observation, not a semantic requirement of the proposal.

constexpr auto animal = stdx::make_interface<{
    {
        "print_name",
        {^^void() const},
        [](auto&& p_self) {
            std::println("current: {}", identifier_of(remove_cvref(^^decltype(p_self))));
        },
    },
}>;
struct cat {};
struct dog {};
cat c{};
dog d{};
auto view = animal.ref(c);
view->print_name();
view = animal.ref(d);
view->print_name();

static_assert(sizeof(view) == 2 * sizeof(nullptr));

Output:

current: cat
current: dog

Here, cat and dog share no common base class and contain no declaration referring to animal. The adaptation is entirely non-intrusive: the interface is imposed by the consumer at the point where a runtime-polymorphic view is required, not by the author of cat or dog.

2.2 Composing interfaces

Interfaces can be composed from other interfaces. An object viewed through a composed interface supports the union of the operations of its constituent interfaces. This composition is defined once, at the interface level, and does not require the concrete types’ hierarchy to reproduce the same structure.

constexpr auto named = stdx::make_interface<{
    {
        "print_name",
        {^^void() const},
        [](auto&& p_self) {
            std::println("current: {}", identifier_of(remove_cvref(^^decltype(p_self))));
        },
    },
}>;

constexpr auto movable = stdx::make_interface<{
    {
        "move",
        {^^void() const},
        [](auto&& p_self) {
            std::println("{} is moving", identifier_of(remove_cvref(^^decltype(p_self))));
        },
    },
}>;

constexpr auto animal = stdx::make_interface<{named, movable}>;
struct cat {};
struct dog {};
cat c{};
dog d{};
auto view = animal.ref(c);
view->print_name();
view->move();
view = animal.ref(d);
view->print_name();
view->move();

Output:

current: cat
cat is moving
current: dog
dog is moving

2.3 Converting between compatible interface references

An interface reference can be converted to another interface reference whenever the source interface’s composition set includes the target interface. This is a nominal relationship between interfaces as declared, not a general structural check that the source’s resulting operations happen to be a superset of the target’s: two interfaces that expose the same operations by coincidence, without one having been composed from the other, are not convertible.

Such a conversion preserves the identity of the underlying object while changing the interface through which it is accessed; it does not require rediscovering or reconstructing the concrete object type.

constexpr auto named = stdx::make_interface<{
    {
        "print_name",
        {^^void() const},
        [](auto&& p_self) {
            std::println("current: {}", identifier_of(remove_cvref(^^decltype(p_self))));
        },
    },
}>;

constexpr auto movable = stdx::make_interface<{
    {
        "move",
        {^^void() const},
        [](auto&& p_self) {
            std::println("{} is moving", identifier_of(remove_cvref(^^decltype(p_self))));
        },
    },
}>;

constexpr auto animal = stdx::make_interface<{named, movable}>;
struct cat {};
cat c{};
auto animal_view = animal.ref(c);

auto named_view = named.ref(animal_view);
named_view->print_name();

auto movable_view = movable.ref(animal_view);
movable_view->move();

static_assert(std::convertible_to<decltype(animal_view), decltype(named_view)>);
static_assert(std::convertible_to<decltype(animal_view), decltype(movable_view)>);

Output:

current: cat
cat is moving

2.4 Overriding operations

When a composed interface redeclares an operation that one of its constituents already provides, the later declaration wins. This lets an interface refine or override the behavior of an operation it inherits from a constituent interface, without changing the constituent interface itself.

constexpr auto named = stdx::make_interface<{
    {
        "print_name",
        {^^void() const},
        [](auto&& p_self) {
            std::println("current: {}", identifier_of(remove_cvref(^^decltype(p_self))));
        },
    },
}>;

constexpr auto movable = stdx::make_interface<{
    {
        "move",
        {^^void() const},
        [](auto&& p_self) {
            std::println("{} is moving", identifier_of(remove_cvref(^^decltype(p_self))));
        },
    },
}>;

constexpr auto animal = stdx::make_interface<{
    named,
    movable,
    {
        "print_name",
        {^^void() const},
        [](auto&& p_self) {
            std::println("current animal: {}", identifier_of(remove_cvref(^^decltype(p_self))));
        },
    },
}>;
struct cat {};
cat c{};
auto animal_view = animal.ref(c);

animal_view->print_name();
animal_view->move();

auto named_view = named.ref(animal_view);
named_view->print_name();

named_view = named.ref(c);
named_view->print_name();

Output:

current animal: cat
cat is moving
current animal: cat
current: cat

Note the two different results for print_name: converting animal_view down to a named_view still calls animal’s overriding implementation, because the conversion preserves the underlying object’s dynamic operation table — it does not “forget” that the object was originally viewed through animal. Constructing a fresh named_view directly from c, by contrast, uses named’s own implementation.

2.5 Constructing interfaces from tokens

Each entry passed to make_interface — whether a single operation description or a previously defined interface — is, in full, an stdx::interface_token. The brace-enclosed shorthand used in the earlier examples is equivalent to writing out interface_token explicitly:

constexpr auto animal = stdx::make_interface<{
    named,
    movable,
    {
        "print_name",
        {^^void() const},
        [](auto&& p_self) {
            std::println("current animal: {}", identifier_of(remove_cvref(^^decltype(p_self))));
        },
    },
}>;

is equivalent to:

constexpr auto animal = stdx::make_interface<{
    stdx::interface_token{named},
    stdx::interface_token{movable},
    stdx::interface_token{
        "print_name",
        {^^void() const},
        [](auto&& p_self) {
            std::println("current animal: {}", identifier_of(remove_cvref(^^decltype(p_self))));
        },
    },
}>;

Because a token is an ordinary value, it can be produced programmatically — for example, by a consteval function that chooses between two implementations of print_name based on a compile-time flag:

consteval auto make_print_name_token(const bool p_is_animal) -> stdx::interface_token
{
    if (p_is_animal)
        return {
            "print_name",
            {^^void() const},
            [](auto&& p_self) {
                std::println("current animal: {}", identifier_of(remove_cvref(^^decltype(p_self))));
            },
        };
    else
        return {
            "print_name",
            {^^void() const},
            [](auto&& p_self) {
                std::println("current: {}", identifier_of(remove_cvref(^^decltype(p_self))));
            },
        };
}

constexpr auto named = stdx::make_interface<{
    make_print_name_token(false),
}>;

constexpr auto movable = stdx::make_interface<{
    {
        "move",
        {^^void() const},
        [](auto&& p_self) {
            std::println("{} is moving", identifier_of(remove_cvref(^^decltype(p_self))));
        },
    },
}>;

constexpr auto animal = stdx::make_interface<{
    named,
    movable,
    make_print_name_token(true),
}>;

struct cat {};
cat c{};
auto animal_view = animal.ref(c);

animal_view->print_name();
animal_view->move();

auto named_view = named.ref(animal_view);
named_view->print_name();

named_view = named.ref(c);
named_view->print_name();

Output:

current animal: cat
cat is moving
current animal: cat
current: cat

Because tokens are ordinary values, a whole list of them can also be built at compile time and spliced into make_interface as a range, using std::from_range as a disambiguating tag:

constexpr auto animal = stdx::make_interface<{
    std::from_range,
    std::vector<stdx::interface_token>{
        named,
        movable,
        make_print_name_token(true),
    },
}>;

This makes it possible to assemble an interface’s operation set programmatically — for instance, generating one token per entry of some other compile-time description — rather than writing out every token by hand.

2.6 Overloaded operations

A single named operation may be associated with more than one candidate signature. make_interface selects among the candidates according to the actual referenced object’s value category and cv-qualification, in the same way ordinary overload resolution would choose between a const and non-const member function.

constexpr auto accessible = stdx::make_interface<{
    {
        "value",
        {^^int() const, ^^int*()},
        [](std::convertible_to<int> auto&& p_self) {
            if constexpr (is_const(remove_reference(^^decltype(p_self))))
                return p_self;
            else
                return std::addressof(p_self);
        },
    },
}>;
int i = 42;
auto const_view = accessible.ref(std::as_const(i));

std::println("{}", const_view->value());

auto view = accessible.ref(i);

*view->value() = 24;

std::println("{}", const_view->value());
std::println("{}", i);

Output:

42
24
24

Above, value has exactly two candidate signatures, written out by hand. The signature list can instead be given as any range satisfying std::meta::reflection_range — for example, a std::vector of reflections

constexpr auto accessible = stdx::make_interface<{
    {
        "value",
        std::vector{^^int() const, ^^int*()},
        [](std::convertible_to<int> auto&& p_self) {
            if constexpr (is_const(remove_reference(^^decltype(p_self))))
                return p_self;
            else
                return std::addressof(p_self);
        },
    },
}>;

2.7 Qualification via the Interface Accessor

Dereferencing an interface reference (*view) yields an interface accessor: a reference-like proxy whose own value category (and cv-qualification) reflects how it was dereferenced, rather than being fixed at the point the interface reference was created. When an operation is invoked through this accessor, the accessor’s value category participates in selecting among the operation’s overload set — exactly as the implicit object argument’s value category participates in overload resolution between ref-qualified (&/&&) member functions.

Consequently, view->value() — which accesses view as an lvalue — and std::move(*view).value() — which dereferences view and then casts the result to an rvalue — can resolve to different candidates of the same named operation, int*() & and int() && respectively, even though both are invoked against the same underlying object.

constexpr auto accessible = stdx::make_interface<{
    {
        "value",
        std::vector{^^int() &&, ^^int*() & },
        [](std::convertible_to<int> auto&& p_self) {
            if constexpr (is_rvalue_reference_type(^^decltype(p_self)))
                return p_self;
            else
                return std::addressof(p_self);
        },
    },
}>;
int i = 42;
auto view = accessible.ref(i);

std::println("{}", std::move(*view).value());

*view->value() = 24;

std::println("{}", std::move(*view).value());
std::println("{}", i);

Output:

42
24
24

2.8 Self Deduction

An interface operation may be defined in terms of the interface accessor rather than in terms of the type of the observed object. stdx::this_interface_accessor is a concept that identifies such an accessor parameter.

A function whose parameter constrains stdx::this_interface_accessor receives the interface accessor as its representation of *this. In particular, the parameter is not deduced as the underlying observed type. Instead, it denotes an accessor. Consequently, its value category and cv-qualification are those of the accessor at the call site, and the function may use the interface’s other operations through that accessor.

For example, an interface may expose a primitive operation, value_impl, and define value in terms of that operation:

constexpr auto accessible = stdx::make_interface<{
    {
        "value_impl",
        std::vector{^^int() &&, ^^int*() & },
        [](std::convertible_to<int> auto&& p_self) {
            if constexpr (is_rvalue_reference_type(^^decltype(p_self)))
                return p_self;
            else
                return std::addressof(p_self);
        },
    },
    {
        "value",
        std::vector{^^int() &&, ^^int*() & },
        [](stdx::this_interface_accessor auto&& p_this) {
            return std::forward<decltype(p_this)>(p_this).value_impl();
        },
    },
}>;
int i = 42;
auto view = accessible.ref(i);

std::println("{}", std::move(*view).value());

*view->value() = 24;

std::println("{}", std::move(*view).value());
std::println("{}", i);

Output:

42
24
24

Here, value does not need to know the type of the object being observed. Its implementation receives the interface accessor and invokes value_impl through that accessor, preserving the accessor’s value category.

The same mechanism permits an operation to be implemented once in a base interface and then reused by derived interfaces. Since the implementation receives an interface accessor, it may perform interface dispatch on other operations of the same interface.

For example, a common area operation can be defined in terms of either width and height or radius:

consteval auto make_area_token(auto p_function) -> stdx::interface_token
{
    return {"area", {^^double() const}, p_function};
}

constexpr auto area_function = [](stdx::this_interface_accessor auto const& p_this) {
    static_assert(requires { p_this.area(); });
    if constexpr (requires { p_this.width() * p_this.height(); })
        return p_this.width() * p_this.height();
    else {
        static_assert(requires { p_this.radius() * p_this.radius(); });
        return p_this.radius() * p_this.radius() * 3.14;
    }
};

constexpr auto shape = stdx::make_interface<{make_area_token(0)}>;

constexpr auto circle = stdx::make_interface<{
    shape,
    make_area_token(area_function),
    {
        "radius",
        {^^double() const},
        [](auto&& p_self) { return std::get<0>(p_self); },
    },
}>;

constexpr auto square = stdx::make_interface<{
    shape,
    make_area_token(area_function),
    {
        "width",
        {^^double() const},
        [](auto&& p_self) { return std::get<0>(p_self); },
    },
    {
        "height",
        {^^double() const},
        [](auto&& p_self) { return std::get<0>(p_self); },
    },
}>;
auto data = std::tuple{4.2};
auto view = shape.ref(circle.ref(data));

std::println("{}", view->area());

std::get<0>(data) = 2.4;
view = shape.ref(square.ref(data));

std::println("{}", view->area());

Output:

55.3896
5.76

The same area_function is therefore usable by interfaces with different sets of operations. The implementation is selected by the interface on which it is invoked, and its calls to width, height, and radius are dispatched through the accessor.

get_underlying may be used to obtain the underlying observed object, while lookup_interface_of identifies the interface associated with the accessor. Thus, an operation can both inspect the represented object and perform further interface-level operation:

constexpr auto interface_name_token = stdx::interface_token{
    "interface_name",
    {^^std::string_view() const},
    [](stdx::this_interface_accessor auto const& p_this) {
        return lookup_interface_of(p_this).name();
    },
};

constexpr auto shape = stdx::make_interface<{
    interface_name_token,
    {
        "area",
        {^^double() const},
        0,
    },
}>;

constexpr auto circle = stdx::make_interface<{
    shape,
    interface_name_token,
    {
        "area",
        {^^double() const},
        [](stdx::this_interface_accessor auto const& p_this) {
            const auto& [radius] = get_underlying(p_this);
            return radius * radius * 3.14;
        },
    },
}>;

constexpr auto square = stdx::make_interface<{
    shape,
    interface_name_token,
    {
        "area",
        {^^double() const},
        [](stdx::this_interface_accessor auto const& p_this) {
            const auto& [side] = get_underlying(p_this);
            return side * side;
        },
    },
}>;

constexpr auto empty = stdx::make_interface<{
    shape,
    {
        "area",
        {^^double() const},
        [](auto&&) { return 0.0; },
    },
}>;
auto data = std::tuple{4.2};
auto view = shape.ref(circle.ref(data));

std::println("{}'s area: {}", view->interface_name(), view->area());

std::get<0>(data) = 2.4;
view = shape.ref(square.ref(data));

std::println("{}'s area: {}", view->interface_name(), view->area());

view = shape.ref(empty.ref(data));

std::println("{}'s area: {}", view->interface_name(), view->area());

Output:

circle's area: 55.3896
square's area: 5.76
shape's area: 0

For interface_name, lookup_interface_of(p_this) obtains the interface represented by p_this at the point of dispatch. Accordingly, lookup_interface_of(p_this).name() is equivalent to invoking circle.name(), square.name(), or shape.name(), depending on the interface through which the accessor is being used. Thus, for the same observed object, the expression yields “circle”, “square”, or “shape” respectively.

2.9 Deduce Interface Reference Types by Tag Type

An interface operation may need to accept another reference to an interface of the same type. stdx::virtual_self provides a context-dependent placeholder for this purpose. A pointer to stdx::virtual_self denotes an interface reference, while a reference to stdx::virtual_self denotes an interface accessor. The distinction corresponds to the distinction between an interface reference obtained from interface::ref and the accessor obtained by dereferencing such a reference.

constexpr auto attack_function_types = std::array{^^void(stdx::virtual_self* p_other) const};

constexpr auto player = stdx::make_interface<{
    {
        "attack",
        attack_function_types,
        [](auto&& p_self, auto&& p_other) { p_other->hurt(p_self.power); },
    },
    {
        "hurt",
        {^^void(int p_intensity)},
        [](auto&& p_self, int p_intensity) { p_self.health -= p_intensity; },
    },
}>;

struct bob {
    int power, health;
};
bob b{.power = 24, .health = 42};

auto view = player.ref(b);

std::println("current health: {}", b.health);

view->attack(view);

std::println("current health: {}", b.health);

Output:

current health: 42
current health: 18

The use of stdx::virtual_self* makes attack accept a reference to the same interface through which attack is being invoked. The declaration of attack_function_types does not mention player. Consequently, the signature description can be reused for another interface with the corresponding attack operation.

The analogous accessor form is obtained by using stdx::virtual_self&:

constexpr auto attack_function_types = std::array{^^void(stdx::virtual_self& p_other) const};

constexpr auto player = stdx::make_interface<{
    {
        "attack",
        attack_function_types,
        [](auto&& p_self, auto&& p_other) { p_other.hurt(p_self.power); },
    },
    {
        "hurt",
        {^^void(int p_intensity)},
        [](auto&& p_self, int p_intensity) { p_self.health -= p_intensity; },
    },
}>;

struct bob {
    int power, health;
};
bob b{.power = 24, .health = 42};

auto view = player.ref(b);

std::println("current health: {}", b.health);

view->attack(*view);

std::println("current health: {}", b.health);

Output:

current health: 42
current health: 18

When the referenced interface is known statically, stdx::virtual_self_of<I> provides a convenient way to name the corresponding interface reference type without spelling out the concrete type produced by I.ref(...).

For example, the following defines an interface dummy containing hurt, and uses stdx::virtual_self_of<dummy>* to specify that attack accepts a reference to dummy:

constexpr auto dummy = stdx::make_interface<{
    {
        "hurt",
        {^^void(int p_intensity)},
        [](auto&& p_self, int p_intensity) { p_self.health -= p_intensity; },
    },
}>;

constexpr auto player = stdx::make_interface<{
    {
        "attack",
        {^^void(stdx::virtual_self_of<dummy>* p_other) const},
        [](auto&& p_self, auto&& p_other) { p_other->hurt(p_self.power); },
    },
}>;

struct bob {
    int power, health;
};
bob b{.power = 24, .health = 42};

std::println("current health: {}", b.health);

player.ref(b)->attack(dummy.ref(b));

std::println("current health: {}", b.health);

Output:

current health: 42
current health: 18

The same mechanism applies to return types. An interface operation may return an interface reference, and an overriding operation may return a more-derived interface reference. Thus, interface operations can support covariance of interface-reference return types.

Consider an operation speak whose base declaration returns a reference to the current interface:

constexpr auto speak_token = stdx::interface_token{
    "speak",
    {^^const stdx::virtual_self*() const},
    [](stdx::this_interface_accessor auto&& p_this) -> auto&& {
        std::println(
            "{} is speaking in {}.",
            identifier_of(stdx::interfaces::underlying_type(^^p_this)),
            p_this.language()
        );
        return p_this;
    },
};

constexpr auto speaker = stdx::make_interface<{
    {
        "language",
        {^^std::string_view() const},
        [](auto&&) { return "nothing"; },
    },
    speak_token,
}>;

constexpr auto programer = stdx::make_interface<{speaker}>;
constexpr auto cpper = stdx::make_interface<{
    programer,
    {
        "language",
        {^^std::string_view() const},
        [](auto&&) { return "cpp"; },
    },
    speak_token,
}>;

A reference obtained through speak can therefore be used to continue dispatching through the returned interface:

const bob b{};

auto view = speaker.ref(cpper.ref(b));

view = view->speak();

view->speak()->speak();

Output:

bob is speaking in cpp.
bob is speaking in cpp.
bob is speaking in cpp.

Covariant return handling is not restricted to the stdx::virtual_self placeholder itself. An overriding operation may instead declare a concrete interface-reference type, such as the type produced by programer.ref(...), and the interface machinery recognizes the relationship between the base and overriding return types.

constexpr auto speak_function = [](stdx::this_interface_accessor auto&& p_this) -> auto&& {
    std::println(
        "{} is speaking in {}.",
        identifier_of(stdx::interfaces::underlying_type(^^p_this)),
        p_this.language()
    );
    return p_this;
};

constexpr auto speak_token = stdx::interface_token{
    "speak",
    {^^const stdx::virtual_self*() const},
    speak_function,
};

constexpr auto speaker = stdx::make_interface<{
    {
        "language",
        {^^std::string_view() const},
        [](auto&&) { return "nothing"; },
    },
    speak_token,
}>;

constexpr auto programer = stdx::make_interface<{
    speaker,
    {
        "language",
        {^^std::string_view() const},
        [](auto&&) { return "something"; },
    },
    speak_token,
}>;

struct bob {};

constexpr auto cpper = stdx::make_interface<{
    programer,
    {
        "language",
        {^^std::string_view() const},
        [](auto&&) { return "cpp"; },
    },
    {
        "speak",
        {^^decltype(programer.ref(std::declval<const bob&>()))() const},
        speak_function,
    },
}>;

Here, programer::speak uses the return type specified by the speak_token, whereas cpper::speak explicitly declares its return type as the interface reference produced by programer.ref(...). The latter is a covariant return with respect to the base interface’s stdx::virtual_self* return type.

const bob b{};

auto programer_view = programer.ref(b);
programer_view->speak()->speak();

programer_view = programer.ref(cpper.ref(b));
programer_view->speak()->speak();

auto speaker_view = speaker.ref(cpper.ref(b));
speaker_view->speak()->speak();

Output:

bob is speaking in something.
bob is speaking in something.
bob is speaking in cpp.
bob is speaking in cpp.
bob is speaking in cpp.
bob is speaking in cpp.

An interface accessor is a reference-like value, and it participates in implicit conversion the way an ordinary C++ reference to a derived type participates in conversion to a reference to a base type: when an operation’s declared parameter is a reference type of stdx::virtual_self, an accessor produced from a compatible interface — one from which the parameter’s declaring interface was composed— is implicitly accepted at that parameter position, upcast to the declaring interface’s own accessor type. Overload resolution among several stdx::virtual_self-parameterized candidates then proceeds exactly as it would for ordinary reference-qualified overloads, selecting the candidate whose value category matches the argument’s.

constexpr auto interface_name_token = stdx::interface_token{
    "interface_name",
    {^^std::string_view() const},
    [](stdx::this_interface_accessor auto const& p_this) {
        return lookup_interface_of(p_this).name();
    },
};

constexpr auto base = stdx::make_interface<{
    interface_name_token,
    {
        "copy_or_move",
        {
            ^^void(stdx::virtual_self&&),
            ^^void(const stdx::virtual_self&),
        },
        [](auto&& p_self, auto&& p_other) {
            if constexpr (is_rvalue_reference_type(^^decltype(p_other)))
                std::println("move {}", p_other.interface_name());
            else
                std::println("copy {}", p_other.interface_name());
        },
    },
}>;

constexpr auto derived = stdx::make_interface<{base, interface_name_token}>;
int i = 42;

auto base_view = base.ref(i);
auto derived_view = derived.ref(i);

base_view->copy_or_move(*derived_view);
base_view->copy_or_move(std::move(*derived_view));
base_view->copy_or_move(std::move(std::as_const(*derived_view)));

Output:

copy derived
move derived
copy derived

In the reference implementation, an accessor of a base-like interface is constructible from an accessor of a composed (derived-like) interface, but the derived accessor’s type does not actually inherit from the base accessor’s type — accessors are related by convertibility, not by a C++ base-class relationship. This matters because ordinary reference binding relies on inheritance to rank, among several overloads differing only in the cv/ref-qualification of a same-typed parameter, exactly one as the best match for a given argument; without an actual inheritance relationship backing that ranking, a naive implementation of accessor parameter conversion could leave two or more candidates equally good matches for a given argument’s qualification, forcing an ambiguous-overload error where none is intended.

The reference implementation avoids this by checking all eight cv/ref qualification forms a stdx::virtual_self parameter may carry — &, &&, const&, const&&, volatile&, volatile&&, const volatile&, and const volatile&& — against the full set of stdx::virtual_self-typed candidates declared for the operation. For each of these eight forms, the implementation determines which of the declared candidates an argument of that exact qualification would, under ordinary C++ reference-binding rules, be able to bind to, and which single one of those candidates ordinary overload resolution would select as the best match. Where more than one candidate is bindable for a given qualification, every candidate other than the one that should win is made to reject an accessor of that qualification outright, by constructing each candidate’s parameter through a distinct wrapper type that is deliberately not constructible from qualifications belonging to a stronger-matching sibling candidate. Because this rejection is computed per qualification and per full candidate set — rather than assumed to reduce to a single rvalue-vs-const-lvalue pair — it also handles operations declared with more than two stdx::virtual_self candidates, or with volatile-qualified candidates, correctly: at most one wrapper type remains constructible for any given argument qualification, so ordinary overload resolution over the (now non-overlapping) wrapper-typed candidates is never ambiguous.

The effect visible to the user — an accessor upcasts to a compatible interface’s accessor and participates in reference-qualified overload selection as if by ordinary derived-to-base reference binding, for any combination of const/volatile/value-category qualifiers a candidate set may use — is preserved; the wrapper types and the eight-way qualification scan are purely an implementation technique for obtaining that effect without an actual inheritance relationship between accessor types, and are not themselves a semantic requirement of this proposal.

A single operation might declare candidates targeting different, but composition-related, interfaces — and such candidates are intended to participate in overload resolution the same way ordinary C++ reference parameters do when they target different, but inheritance-related, types: given an argument convertible to more than one candidate’s target, the candidate whose target is more derived in the composition graph is preferred, ahead of any tie-break based on the argument’s cv/ref-qualification.

constexpr auto interface_name_token = stdx::interface_token{
    "interface_name",
    {^^std::string_view() const},
    [](stdx::this_interface_accessor auto const& p_this) {
        return lookup_interface_of(p_this).name();
    },
};

constexpr auto grandparent = stdx::make_interface<{interface_name_token}>;
constexpr auto parent = stdx::make_interface<{interface_name_token, grandparent}>;
constexpr auto child = stdx::make_interface<{interface_name_token, parent}>;

constexpr auto visitor = stdx::make_interface<{
    {
        "copy_or_move",
        {
            ^^void(stdx::virtual_self_of<grandparent>&&),
            ^^void(const stdx::virtual_self_of<parent>&),
        },
        [](auto&& p_self, auto&& p_other) {
            if constexpr (is_rvalue_reference_type(^^decltype(p_other)))
                std::println("move {}", p_other.interface_name());
            else
                std::println("copy {}", p_other.interface_name());
        },
    },
}>;
int i = 42;

auto child_view = child.ref(i);
auto visitor_view = visitor.ref(i);

visitor_view->copy_or_move(*child_view);
visitor_view->copy_or_move(std::move(*child_view));
visitor_view->copy_or_move(std::move(*grandparent.ref(child_view)));

Output:

copy child
copy child
move child

Both an lvalue and an rvalue child accessor prefer the const parent& candidate over the grandparent&& candidate, since parent is the more derived target — the rvalue argument in the second call remains viable for grandparent&&, but derivedness is preferred ahead of value category, so copy (not move) is still selected. Only once the accessor is explicitly narrowed to grandparent itself, via grandparent.ref(child_view), does it stop being convertible to parent’s target at all, leaving grandparent&& as the sole viable candidate and producing move.

As with the pure cv/ref-qualification case, the reference implementation obtains this derived-preferred resolution behavior using the same wrapper-type technique described above. This is, again, an implementation means to an observable end, not itself a semantic requirement of this proposal.

2.10 Operator definitions

C++ operators may be implemented either as non-member functions found by argument-dependent lookup (ADL) or as member functions, and the two forms place the “self” operand differently: a non-member operator<<(std::ostream&, const T&) takes the object as an explicit parameter, while a member T::operator<<(std::ostream&) const receives it as the implicit object argument. When an interface operation’s name is an operator token (<<, *, [], and so on), the operation’s declared signature — specifically, its parameter list shape and the presence of one of two tag types, stdx::virtual_self or stdx::adl_virtual_self, in that parameter list — determines which of the two forms is being described, and where in that form the underlying object is substituted. The interface operation function’s parameter order always mirrors the declared signature’s parameter order, with the self position (marked by whichever tag was used, or left implicit) filled by the underlying object, subject to the same cvref-qualification rules as an ordinary operation.

When a binary operator’s candidate signature has two parameters and one of them is a (possibly cv) ref-qualified stdx::virtual_self, the operation is invoked as if by ADL, with the underlying object representation substituted at the tagged parameter’s position and the other parameter supplied by the call site.

constexpr auto operators = stdx::make_interface<{
    {
        "<<",
        {^^void(std::ostream&, const stdx::virtual_self&)},
        [](auto& out, auto&& p_self) { out << p_self.id; },
    },
}>;

struct character {
    int id;
};
character c{.id = 42};

auto view = operators.ref(c);

std::cout << *view;

Output:

42

When the same binary operator symbol is instead given a single-parameter signature, the operation is invoked as a member function: the underlying object occupies the implicit object argument, and the sole declared parameter is supplied by the call site.

constexpr auto operators = stdx::make_interface<{
    {
        "<<",
        {^^void(std::ostream&) const},
        [](auto&& p_self, auto& out) {
            static_assert(is_const(remove_reference(^^decltype(p_self))));
            out << p_self.id;
        },
    },
}>;

struct character {
    int id;
};
character c{.id = 42};

auto view = operators.ref(c);

*view << std::cout;

Output:

42

The cvref-qualification mechanism introduced for ordinary operations applies uniformly to operator operations, in both dispatch modes. In the member-dispatch form, the qualifier is written as the member function’s own ref-qualifier:

constexpr auto operators = stdx::make_interface<{
    {
        "<<",
        {^^void(std::ostream&) && },
        [](auto&& p_self, auto& out) {
            static_assert(is_rvalue_reference_type(^^decltype(p_self)));
            out << p_self.id;
        },
    },
}>;

struct character {
    int id;
};
character c{.id = 42};

auto view = operators.ref(c);

std::move(*view) << std::cout;

Output:

42

In the non-member (ADL) form, the same qualifier is instead attached directly to the stdx::virtual_self tag, since there is no member function to carry a trailing ref-qualifier:

constexpr auto operators = stdx::make_interface<{
    {
        "<<",
        {^^void(std::ostream&, stdx::virtual_self&&)},
        [](auto& out, auto&& p_self) {
            static_assert(is_rvalue_reference_type(^^decltype(p_self)));
            out << p_self.id;
        },
    },
}>;

struct character {
    int id;
};
character c{.id = 42};

auto view = operators.ref(c);

std::cout << std::move(*view);

Output:

42

Some operator symbols — * among them — denote different operators depending on arity (unary dereference versus binary multiplication), so parameter count alone cannot be used to choose between member and non-member dispatch the way it can for a symbol like <<, which is only ever binary. For such symbols, stdx::adl_virtual_self names the parameter position that both supplies the self operand and explicitly requests non-member (ADL) dispatch, independent of how many parameters accompany it. A binary use and a unary use can therefore both request ADL dispatch, distinguished only by how many non-tagged parameters appear alongside the tag:

constexpr auto operators = stdx::make_interface<{
    {
        "id",
        {^^int() const},
        [](auto&& p_self) { return p_self.id; },
    },
    {
        "*",
        {^^int(const stdx::virtual_self&, stdx::adl_virtual_self&&)},
        [](auto&& p_other_accessor, auto&& p_self) {
            static_assert(is_rvalue_reference_type(^^decltype(p_self)));
            return p_other_accessor.id() * p_self.id;
        },
    },
}>;

struct character {
    int id;
};
character c{.id = 42};

auto view = operators.ref(c);

std::println("{}", *view * std::move(*view));

Output:

1764
constexpr auto operators = stdx::make_interface<{
    {
        "*",
        {^^int(stdx::adl_virtual_self&&)},
        [](auto&& p_self) {
            static_assert(is_rvalue_reference_type(^^decltype(p_self)));
            return p_self.id;
        },
    },
}>;

struct character {
    int id;
};
character c{.id = 42};

auto view = operators.ref(c);

std::println("{}", *std::move(*view));

Output:

42

Absent the disambiguating tag, an arity-ambiguous symbol defaults to member dispatch. If a symbol that could be either unary or binary is declared without stdx::adl_virtual_self, the operation falls back to member-function dispatch, exactly as in the unambiguous one-parameter case above — here as a binary, rvalue-qualified member operator*:

constexpr auto operators = stdx::make_interface<{
    {
        "id",
        {^^int() const},
        [](auto&& p_self) { return p_self.id; },
    },
    {
        "*",
        {^^int(const stdx::virtual_self&) && },
        [](auto&& p_self, auto&& p_other_accessor) {
            static_assert(is_rvalue_reference_type(^^decltype(p_self)));
            return p_other_accessor.id() * p_self.id;
        },
    },
}>;

struct character {
    int id;
};
character c{.id = 42};

auto view = operators.ref(c);

std::println("{}", std::move(*view) * (*view));

Output:

1764

Member-only operators have no non-member form to select. Operators that C++ itself restricts to member-function form — operator[] is the example here — have no ADL counterpart to disambiguate against, so only the member-dispatch shape is meaningful for them. stdx::virtual_self may still appear among their declared parameters, but only to mark that a given argument position accepts an accessor of this same interface, not to select a dispatch mode:

struct character;

constexpr auto operators = stdx::make_interface<{
    {
        "id",
        {^^int() const},
        [](auto&& p_self) { return p_self.id; },
    },
    {
        "[]",
        {^^int(const stdx::virtual_self&, character) && },
        [](auto&& p_self, auto&& p_other_accessor, auto p_x) {
            static_assert(is_rvalue_reference_type(^^decltype(p_self)));
            return p_self.id * p_other_accessor.id() * p_x.id;
        },
    },
}>;

struct character {
    int id;
};
character c{.id = 42};

auto view = operators.ref(c);

std::println("{}", std::move(*view)[*view, {.id = 24}]);

Output:

42336

2.11 Lifetime operations

Two operation names, "~T" and "delete", are reserved by the facility to describe an object’s destruction and deallocation respectively. Unlike an ordinary named operation, which is invoked through the interface accessor produced by operator*/operator->, an interface that declares either of these reserved names causes the interface reference itself to gain a corresponding member function — destroy() for "~T", deallocate() for "delete" — callable directly on the reference, without first dereferencing it. This distinction is deliberate: destruction and deallocation end the underlying object’s lifetime, and in general it is no longer meaningful to dereference a reference to an object once that object has been destroyed, so these operations are exposed at the reference’s own level rather than through the accessor used for every other operation. With "emplace" (an ordinary, user-defined operation) placed alongside them, an interface reference alone is sufficient to drive an object through its entire lifetime — construction into unformatted storage, use, destruction, and deallocation — without the caller ever recovering the concrete type.

constexpr auto lifetime_base = stdx::make_interface<{
    {
        "emplace",
        {^^void(int)},
        [](auto&& p_self, int p_x) { std::construct_at(std::addressof(p_self), p_x); },
    },
    {
        "~T",
        {^^void()},
        [](auto&& p_self) { std::destroy_at(std::addressof(p_self)); },
    },
    {
        "delete",
        {^^void()},
        [](auto&& p_self) {
            using allocator_type = std::allocator<std::remove_cvref_t<decltype(p_self)>>;
            allocator_type{}.deallocate(std::addressof(p_self), 1);
        },
    },
}>;

struct character {
    int id;
};
character* ptr = std::allocator<character>{}.allocate(1);

auto view = lifetime_base.ref(*ptr);

view->emplace(42);

std::println("{}", ptr->id);

view.destroy();
view.deallocate();

static_assert(!noexcept(view.destroy()));
static_assert(!noexcept(view.deallocate()));

Output:

42

Here view.destroy() and view.deallocate() are declared with signature void(), i.e. potentially-throwing, and noexcept(view.destroy()) reports false accordingly, exactly as it would for an ordinary member function with the same exception specification.

Overriding preserves the same accessor-independent identity behavior as ordinary operations, including for noexcept-ness observed through the static interface. "~T" and "delete" can be overridden by a composed interface in the same way as any other operation, including with a strengthened, noexcept signature. When a reference is converted from the overriding (derived) interface back to the overridden (base) interface, the override’s implementation remains in effect — as with any other overridden operation reached through a conversion — but the exception specification observed by a noexcept(...) query is the one declared by the interface through which the reference is statically typed, not the one belonging to whichever implementation actually runs. This mirrors the established behavior of virtual functions in noexcept queries through a base-class pointer, and lets lifetime_base continue to promise only a potentially-throwing destroy/deallocate to code holding a lifetime_base reference, even when the concrete override never throws.

constexpr auto lifetime_base = stdx::make_interface<{
    {
        "emplace",
        {^^void(int)},
        [](auto&& p_self, int p_x) { std::construct_at(std::addressof(p_self), p_x); },
    },
    {
        "~T",
        {^^void()},
        [](auto&& p_self) { std::destroy_at(std::addressof(p_self)); },
    },
    {
        "delete",
        {^^void()},
        [](auto&& p_self) {
            using allocator_type = std::allocator<std::remove_cvref_t<decltype(p_self)>>;
            allocator_type{}.deallocate(std::addressof(p_self), 1);
        },
    },
}>;

constexpr auto lifetime_derived = stdx::make_interface<{
    lifetime_base,
    {
        "~T",
        {^^void() noexcept},
        [](auto&& p_self) {
            std::println("override destroy");
            std::destroy_at(std::addressof(p_self));
        },
    },
    {
        "delete",
        {^^void() noexcept},
        [](auto&& p_self) {
            std::println("override deallocate");
            using allocator_type = std::allocator<std::remove_cvref_t<decltype(p_self)>>;
            allocator_type{}.deallocate(std::addressof(p_self), 1);
        },
    },
}>;

struct character {
    int id;
};
character* ptr = std::allocator<character>{}.allocate(1);

auto derived_view = lifetime_derived.ref(*ptr);
auto base_view = lifetime_base.ref(derived_view);

base_view->emplace(42);
base_view.destroy();
base_view.deallocate();

static_assert(noexcept(derived_view.destroy()));
static_assert(noexcept(derived_view.deallocate()));

static_assert(!noexcept(base_view.destroy()));
static_assert(!noexcept(base_view.deallocate()));

Output:

override destroy
override deallocate

base_view.destroy() runs lifetime_derived’s overriding, noexcept implementation — as confirmed by the printed message — while still reporting noexcept(base_view.destroy()) as false, because base_view is typed through lifetime_base’s (non-noexcept) declaration.

Because "~T" and "delete" describe a small, fixed set of well-understood operations, the interface’s operation token for either may omit its function-type entry entirely, letting the interface supply a default. When such an elided declaration does not override an existing declaration, or overrides one that is itself noexcept, the supplied default is void() noexcept:

constexpr auto lifetime_base = stdx::make_interface<{
    {
        "emplace",
        {^^void(int)},
        [](auto&& p_self, int p_x) { std::construct_at(std::addressof(p_self), p_x); },
    },
    {
        "~T",
        [](auto&& p_self) { std::destroy_at(std::addressof(p_self)); },
    },
    {
        "delete",
        [](auto&& p_self) {
            using allocator_type = std::allocator<std::remove_cvref_t<decltype(p_self)>>;
            allocator_type{}.deallocate(std::addressof(p_self), 1);
        },
    },
}>;

struct character {
    int id;
};
character* ptr = std::allocator<character>{}.allocate(1);

auto base_view = lifetime_base.ref(*ptr);

base_view->emplace(42);
base_view.destroy();
base_view.deallocate();

static_assert(noexcept(base_view.destroy()));
static_assert(noexcept(base_view.deallocate()));

If, instead, the elided declaration overrides a declaration whose exception specification is noexcept(false), the supplied default tracks that overridden declaration and is itself void() noexcept(false):

constexpr auto lifetime_base = stdx::make_interface<{
    {
        "emplace",
        {^^void(int)},
        [](auto&& p_self, int p_x) { std::construct_at(std::addressof(p_self), p_x); },
    },
    {
        "~T",
        {^^void()},
        [](auto&& p_self) { std::destroy_at(std::addressof(p_self)); },
    },
    {
        "delete",
        {^^void()},
        [](auto&& p_self) {
            using allocator_type = std::allocator<std::remove_cvref_t<decltype(p_self)>>;
            allocator_type{}.deallocate(std::addressof(p_self), 1);
        },
    },
}>;

constexpr auto lifetime_derived = stdx::make_interface<{
    lifetime_base,
    {
        "~T",
        [](auto&& p_self) {
            std::println("override destroy");
            std::destroy_at(std::addressof(p_self));
        },
    },
    {
        "delete",
        [](auto&& p_self) {
            std::println("override deallocate");
            using allocator_type = std::allocator<std::remove_cvref_t<decltype(p_self)>>;
            allocator_type{}.deallocate(std::addressof(p_self), 1);
        },
    },
}>;

struct character {
    int id;
};
character* ptr = std::allocator<character>{}.allocate(1);

auto derived_view = lifetime_derived.ref(*ptr);
auto base_view = lifetime_base.ref(derived_view);

base_view->emplace(42);
base_view.destroy();
base_view.deallocate();

static_assert(!noexcept(derived_view.destroy()));
static_assert(!noexcept(derived_view.deallocate()));

static_assert(!noexcept(base_view.destroy()));
static_assert(!noexcept(base_view.deallocate()));

Output:

override destroy
override deallocate

Here lifetime_base’s "~T" and "delete" are declared noexcept(false) (via an explicit signature, in the version shown two subsections above), so lifetime_derived’s elided override inherits noexcept(false) rather than defaulting to noexcept, matching the ordinary C++ rule that an overriding function’s exception specification must be at least as strict as (or, when elided here, simply equal to) the one it overrides.

"~T" may additionally elide its implementation. Because std::destroy_at is the only reasonable default implementation for destruction, "~T" alone (not "delete", which has no comparably universal default) may omit its implementing callable and supply the boolean value true in its place, requesting that the interface generate a call to std::destroy_at on the underlying object automatically. The function-type elision rule for the resulting default signature is the same one described above: noexcept unless overriding a noexcept(false) declaration.

constexpr auto lifetime_base = stdx::make_interface<{
    {
        "emplace",
        {^^void(int)},
        [](auto&& p_self, int p_x) { std::construct_at(std::addressof(p_self), p_x); },
    },
    {
        "~T",
        {^^void()},
        true,
    },
    {
        "delete",
        [](auto&& p_self) {
            using allocator_type = std::allocator<std::remove_cvref_t<decltype(p_self)>>;
            allocator_type{}.deallocate(std::addressof(p_self), 1);
        },
    },
}>;

struct character {
    int id;
};
character* ptr = std::allocator<character>{}.allocate(1);

auto base_view = lifetime_base.ref(*ptr);

base_view->emplace(42);
base_view.destroy();
base_view.deallocate();

static_assert(!noexcept(base_view.destroy()));
static_assert(noexcept(base_view.deallocate()));

Explicitly declaring "~T"’s signature as void() (potentially throwing) while still requesting the default std::destroy_at-based implementation via true demonstrates that the two elisions — of the function type and of the implementing callable — are independent: a declaration may supply either, both, or neither. Supplying neither (as in the very first example of this section) declares a fully explicit, hand-written, potentially throwing destructor operation; supplying both, as below, yields the fully elided, noexcept-by-default form:

constexpr auto lifetime_base = stdx::make_interface<{
    {
        "emplace",
        {^^void(int)},
        [](auto&& p_self, int p_x) { std::construct_at(std::addressof(p_self), p_x); },
    },
    {
        "~T",
        true,
    },
    {
        "delete",
        [](auto&& p_self) {
            using allocator_type = std::allocator<std::remove_cvref_t<decltype(p_self)>>;
            allocator_type{}.deallocate(std::addressof(p_self), 1);
        },
    },
}>;

struct character {
    int id;
};
character* ptr = std::allocator<character>{}.allocate(1);

auto base_view = lifetime_base.ref(*ptr);

base_view->emplace(42);
base_view.destroy();
base_view.deallocate();

static_assert(noexcept(base_view.destroy()));
static_assert(noexcept(base_view.deallocate()));

2.12 Diamond composition and operation ambiguity

Because an interface is a stateless description of operations rather than a base subobject, composing the same interface into a target through more than one path — a diamond shape in the composition graph — does not by itself create the storage-duplication problem that diamond inheritance causes for virtual functions. There is no subobject to unify. The only question a diamond composition raises is a purely nominal one: whether the composed interface has a single, unambiguous declaration of a given operation name reachable from it.

If neither intermediate interface redeclares an operation it composes from a common ancestor, both paths through the diamond lead to the very same declaration. There is nothing to reconcile, so the operation remains callable and the composed interface upcasts cleanly to the common ancestor.

constexpr auto object = stdx::make_interface<{
    {
        "id",
        {^^int() const},
        [](auto&& p_self) { return p_self.id; },
    },
}>;

constexpr auto drawable = stdx::make_interface<{object}>;
constexpr auto movable = stdx::make_interface<{object}>;
constexpr auto sprite = stdx::make_interface<{drawable, movable}>;
struct character {
    int id;
};

template <auto& Interface>
constexpr bool is_ambiguous_v = !requires { Interface.ref(std::declval<character&>())->id(); };

template <auto& From, auto& To>
constexpr bool can_upcast_v = requires { To.ref(From.ref(std::declval<character&>())); };

static_assert(!is_ambiguous_v<sprite>);
static_assert(can_upcast_v<sprite, object>);

id is not redeclared by either drawable or movable; sprite sees a single declaration of id, inherited unchanged from object along both paths. Calling id through a sprite reference is therefore unambiguous, and so is converting that reference to an object reference: there is only one candidate declaration of id to carry across the conversion.

If at least one of the intermediate interfaces redeclares the operation, the composed interface now has multiple distinct declarations of the same name reachable from it. Both the operation call and the upcast become ambiguous.

constexpr auto id_token = stdx::interface_token{
    "id",
    {^^int() const},
    [](auto&& p_self) { return p_self.id; },
};

constexpr auto object = stdx::make_interface<{id_token}>;

constexpr auto drawable = stdx::make_interface<{object}>;
constexpr auto movable = stdx::make_interface<{object, id_token}>;
constexpr auto sprite = stdx::make_interface<{drawable, movable}>;
struct character {
    int id;
};

template <auto& Interface>
constexpr bool is_ambiguous_v = !requires { Interface.ref(std::declval<character&>())->id(); };

template <auto& From, auto& To>
constexpr bool can_upcast_v = requires { To.ref(From.ref(std::declval<character&>())); };

static_assert(is_ambiguous_v<sprite>);
static_assert(!can_upcast_v<sprite, object>);

Here movable redeclares id, while drawable does not. sprite therefore composes two declarations of id — object’s, reached through drawable, and movable’s own. Calling id through a sprite reference is ambiguous for the same underlying reason a call would be ambiguous: two equally-reachable candidates exist and no rule orders them. The upcast to object is ambiguous for a corresponding reason: it must reconcile the id reachable through the drawable path with the different id reachable through the movable path, and nothing in the composition resolves that conflict.

If the interface that closes the diamond — here, sprite— redeclares the ambiguous operation, that redeclaration becomes the unique final overrider for every path beneath it. The ambiguity introduced by the diamond is resolved locally.

constexpr auto id_token = stdx::interface_token{
    "id",
    {^^int() const},
    [](auto&& p_self) { return p_self.id; },
};

constexpr auto object = stdx::make_interface<{id_token}>;

constexpr auto drawable = stdx::make_interface<{object}>;
constexpr auto movable = stdx::make_interface<{object, id_token}>;
constexpr auto sprite = stdx::make_interface<{drawable, movable, id_token}>;
struct character {
    int id;
};

template <auto& Interface>
constexpr bool is_ambiguous_v = !requires { Interface.ref(std::declval<character&>())->id(); };

template <auto& From, auto& To>
constexpr bool can_upcast_v = requires { To.ref(From.ref(std::declval<character&>())); };

static_assert(!is_ambiguous_v<sprite>);
static_assert(can_upcast_v<sprite, object>);

Because sprite redeclares id after both drawable and movable in its composition list, that redeclaration overrides both diamond paths uniformly. sprite now exposes a single declaration of id, so both the call and the upcast to object are unambiguous again.

2.13 RTTI in diamond compositions

An interface reference’s dispatch table (its “vtable”) is exposed to the user through a constexpr function, vtable_addressof, which yields the address of the operation table backing a dereferenced interface reference, at either compile time or run time. This is offered as a cheaper alternative to carrying an explicit run-time type tag inside the vtable and comparing that tag: a tag comparison costs an extra indirect load through the vtable pointer, whereas comparing vtable_addressof results is a comparison of values already in hand. For this substitution to be sound, vtable-address equality must mean exactly what a user would expect an identity check to mean: two interface references compare equal in vtable address if and only if they carry the same effective operation table — regardless of which sequence of conversions produced each reference.

Diamond compositions are the case in which this guarantee is least obvious to trust: one might naively expect that a diamond shape in the composition graph could cause the mechanism to break down — for instance, that two conversion paths through the diamond might land on two distinct vtable instances even when they resolve to the same declarations, since nothing about a naive per-composition-step implementation obviously rules that out. The examples below show that the identity rule holds correctly through diamonds regardless, provided the implementation canonicalizes tables by resolved content rather than by conversion path.

constexpr auto object = stdx::make_interface<{
    {
        "id",
        {^^int() const},
        [](auto&& p_self) { return p_self.id; },
    },
}>;

constexpr auto drawable = stdx::make_interface<{object}>;
constexpr auto movable = stdx::make_interface<{object}>;
constexpr auto sprite = stdx::make_interface<{drawable, movable}>;

struct character {
    int id;
};

consteval
{
    character c{};

    auto object_view = object.ref(c);
    auto sprite_view = sprite.ref(c);
    auto upcast_view = object.ref(sprite_view);

    if (vtable_addressof(*object_view) != vtable_addressof(*upcast_view)) throw;
}

Because the upcast crosses no override, upcast_view’s vtable is the same table object_view’s vtable is — not merely an equivalent one — and the check is available at compile time as an consteval block assertion.

With movable redeclaring id and drawable leaving it untouched (the composition from the ambiguous case above), a direct upcast from sprite to object is ill-formed, since the two paths disagree on which declaration of id to carry across. Routing the conversion explicitly through one intermediate interface or the other, however, remains well-formed, and the resulting vtables reveal exactly which path was taken:

constexpr auto id_token = stdx::interface_token{
    "id",
    {^^int() const},
    [](auto&& p_self) { return p_self.id; },
};

constexpr auto object = stdx::make_interface<{id_token}>;

constexpr auto drawable = stdx::make_interface<{object}>;
constexpr auto movable = stdx::make_interface<{object, id_token}>;
constexpr auto sprite = stdx::make_interface<{drawable, movable}>;

struct character {
    int id;
};

consteval
{
    character c{};

    auto object_view = object.ref(c);
    auto sprite_view = sprite.ref(c);

    if (vtable_addressof(*object_view) != vtable_addressof(*object.ref(drawable.ref(sprite_view))))
        throw;

    if (vtable_addressof(*object_view) == vtable_addressof(*object.ref(movable.ref(sprite_view))))
        throw;
}

Converting via drawable — a path that overrides nothing — again preserves vtable identity with a direct object reference. Converting via movable — a path whose own redeclaration of id is in effect — necessarily produces a different vtable, since that table now dispatches movable’s declaration rather than object’s original one. The vtable address therefore reports, without any separate type tag, which declaration of id a given reference is actually backed by.

When the interface that closes the diamond — sprite itself — redeclares the operation, that redeclaration becomes the unique final overrider reachable through either intermediate interface. The upcast to object becomes unambiguous again, but the resulting vtable is neither the original object vtable nor kept as two separate per-path tables: it is a single, canonical table reflecting sprite’s override, reached identically regardless of which intermediate path the conversion is routed through.

constexpr auto id_token = stdx::interface_token{
    "id",
    {^^int() const},
    [](auto&& p_self) { return p_self.id; },
};

constexpr auto object = stdx::make_interface<{id_token}>;

constexpr auto drawable = stdx::make_interface<{object}>;
constexpr auto movable = stdx::make_interface<{object, id_token}>;
constexpr auto sprite = stdx::make_interface<{drawable, movable, id_token}>;

struct character {
    int id;
};

consteval
{
    character c{};

    auto object_view = object.ref(c);
    auto sprite_view = sprite.ref(c);
    auto upcast_view = object.ref(sprite_view);

    if (vtable_addressof(*object_view) == vtable_addressof(*upcast_view)) throw;

    if (vtable_addressof(*upcast_view) != vtable_addressof(*object.ref(movable.ref(sprite_view))))
        throw;

    if (vtable_addressof(*upcast_view) != vtable_addressof(*object.ref(drawable.ref(sprite_view))))
        throw;
}

upcast_view’s vtable differs from object_view’s — sprite’s override means the two references are no longer backed by the same implementation — but it is identical whether reached directly, through drawable, or through movable. The diamond does not produce two independent overriding tables that happen to behave alike; it produces exactly one.

2.14 Porting from virtual functions

With the facilities described so far, users can build a small, general-purpose tool for porting existing virtual-function hierarchies to interfaces, rather than needing to hand-write an equivalent interface for each hierarchy. The sketch below introduces one such tool, iface<T>: given a polymorphic base class T, iface<T> produces an interface reproducing T’s virtual interface, so that existing derived classes can be used through the resulting interface without modification.

template <std::meta::info Mem_>
constexpr auto interface_function_v = [](auto&& p_self, auto&&... p_args) -> decltype(auto) {
    return std::forward<decltype(p_self)>(p_self).[:Mem_:](
        std::forward<decltype(p_args)>(p_args)...
    );
};

template <typename T_>
constexpr auto iface = stdx::make_interface<{
    std::from_range,
    [] {
        auto tokens = std::vector<stdx::interface_token>{};

        tokens.append_range(
            bases_of(^^T_, std::meta::access_context::unchecked())
            | std::views::transform([](const std::meta::info p_base) {
                  return substitute(^^iface, {type_of(p_base)});
              })
        );

        tokens.append_range(
            members_of(^^T_, std::meta::access_context::unchecked())
            | std::views::filter(std::meta::is_pure_virtual)
            | std::views::transform([](const std::meta::info p_mem) {
                  return stdx::interface_token{
                      identifier_of(p_mem),
                      {type_of(p_mem)},
                      substitute(^^interface_function_v, {std::meta::reflect_constant(p_mem)}),
                  };
              })
        );

        return tokens;
    }(),
}>;

struct Named {
    virtual void print_name() = 0;
};

struct Movable {
    virtual void move() = 0;
};

struct Animal : Named, Movable {};

struct Cat : Animal {
    void print_name() override { std::printf("current animal: %s\n", "cat"); }
    void move() override { std::printf("%s is moving\n", "cat"); }
};

struct Dog : Animal {
    void print_name() override { std::printf("current animal: %s\n", "dog"); }
    void move() override { std::printf("%s is moving\n", "dog"); }
};

constexpr auto& animal = iface<Animal>;

[[gnu::noinline, gnu::noclone]]
void virtual_dispatch(Animal* p_animal)
{
    p_animal->print_name();
    p_animal->move();
}

[[gnu::noinline, gnu::noclone]]
void interface_dispatch(auto p_view)
{
    p_view->print_name();
    p_view->move();
}
Cat c;
Dog d;

auto view = animal.ref(c);
Animal* ptr = &c;

virtual_dispatch(ptr);
interface_dispatch(view);

view = animal.ref(d);
ptr = &d;

virtual_dispatch(ptr);
interface_dispatch(view);

Output:

current animal: cat
cat is moving
current animal: cat
cat is moving
current animal: dog
dog is moving
current animal: dog
dog is moving

animal, produced entirely mechanically from Animal by iface<T>, dispatches Cat and Dog identically to the original virtual-function hierarchy — confirming the porting tool is behaviorally sound for this hierarchy, without either Cat or Dog being touched.

With speculative devirtualization disabled, the ported interface performs comparably to the original virtual functions. Compiling with GCC 16.1 -o3 -fno-devirtualize-speculatively isolates the “ordinary,” non-speculative cost of each dispatch style:

<main [0x401040]>:
push    r13
push    r12
mov r12d, 0x402300
sub rsp, 0x28
movq    xmm0, qword ptr [rip + 0x1302]
movhps  xmm0, qword ptr [rip + 0x1303] # xmm0 = xmm0[0,1],mem[0,1]

mov rdi, rsp
movaps  xmmword ptr [rsp], xmm0
movq    xmm0, qword ptr [rip + 0x12fc]
movhps  xmm0, qword ptr [rip + 0x12fd] # xmm0 = xmm0[0,1],mem[0,1]

movaps  xmmword ptr [rsp + 0x10], xmm0
call    <virtual_dispatch(Animal*) [0x4011b0]>
mov rsi, rsp
mov rdi, r12
mov r12d, 0x4022a0
call    <func 0x401380>
lea rdi, [rsp + 0x10]
mov r13, rdi
call    <virtual_dispatch(Animal*) [0x4011b0]>
mov rdi, r12
mov rsi, r13
call    <func 0x401380>
add rsp, 0x28
xor eax, eax
pop r12
pop r13
ret

<virtual_dispatch(Animal*) [0x4011b0]>:
push    rbx
mov rax, qword ptr [rdi]
mov rbx, rdi
call    qword ptr [rax]
mov rax, qword ptr [rbx + 0x8]
lea rdi, [rbx + 0x8]
pop rbx
mov rax, qword ptr [rax]
jmp rax

<func 0x401380>:
push    rbp
mov rbp, rsi
push    rbx
mov rbx, rdi
mov rdi, rsi
sub rsp, 0x8
call    qword ptr [rbx]
mov rax, qword ptr [rbx + 0x8]
add rsp, 0x8
mov rdi, rbp
pop rbx
pop rbp
jmp rax

func 0x401380 is interface_dispatch. interface_dispatch reads a entry directly out of the two-word interface reference passed to it — the classic fat-pointer layout. This is an implementation observation about the tested code, not a semantic requirement of the proposal, and it is worth stating plainly: the fat-pointer representation itself is not a source of slowdown here — under non-speculative compilation, the ported interface and the original virtual functions produce code of comparable shape and cost.

With speculative devirtualization enabled, the fat-pointer representation does not receive the same speculative treatment the compiler gives ordinary virtual calls. Recompiling the same source without -fno-devirtualize-speculatively, the compiler inserts inline type checks into virtual_dispatch while interface_dispatch compiles unchanged from the non-speculative version:

<main [0x401040]>:
push    r13
push    r12
mov r12d, 0x402300
sub rsp, 0x28
movq    xmm0, qword ptr [rip + 0x1302]
movhps  xmm0, qword ptr [rip + 0x1303] # xmm0 = xmm0[0,1],mem[0,1]

mov rdi, rsp
movaps  xmmword ptr [rsp], xmm0
movq    xmm0, qword ptr [rip + 0x12fc]
movhps  xmm0, qword ptr [rip + 0x12fd] # xmm0 = xmm0[0,1],mem[0,1]

movaps  xmmword ptr [rsp + 0x10], xmm0
call    <virtual_dispatch(Animal*) [0x4011b0]>
mov rsi, rsp
mov rdi, r12
mov r12d, 0x4022a0
call    <func 0x401500>
lea rdi, [rsp + 0x10]
mov r13, rdi
call    <virtual_dispatch(Animal*) [0x4011b0]>
mov rdi, r12
mov rsi, r13
call    <func 0x401500>
add rsp, 0x28
xor eax, eax
pop r12
pop r13
ret

<virtual_dispatch(Animal*) [0x4011b0]>:
push    rbx
mov rax, qword ptr [rdi]
mov rbx, rdi
mov rax, qword ptr [rax]
cmp rax, 0x4012c0
je  <virtual_dispatch(Animal*) [0x401220]>
cmp rax, 0x4012e0
jne <virtual_dispatch(Animal*) [0x401210]>
mov esi, 0x40201c
mov edi, 0x402008
xor eax, eax
call    <printf@plt [0x401030]>
mov rax, qword ptr [rbx + 0x8]
lea rdi, [rbx + 0x8]
mov rax, qword ptr [rax]
cmp rax, 0x401320
je  <virtual_dispatch(Animal*) [0x401238]>
cmp rax, 0x401360
jne <virtual_dispatch(Animal*) [0x401218]>
mov esi, 0x40201c
mov edi, 0x402020
xor eax, eax
pop rbx
jmp <printf@plt [0x401030]>
call    rax
jmp <virtual_dispatch(Animal*) [0x4011db]>
pop rbx
jmp rax
mov esi, 0x402004
mov edi, 0x402008
xor eax, eax
call    <printf@plt [0x401030]>
jmp <virtual_dispatch(Animal*) [0x4011db]>
mov esi, 0x402004
mov edi, 0x402020
xor eax, eax
pop rbx
jmp <printf@plt [0x401030]>

<func 0x401500>:
push    rbp
mov rbp, rsi
push    rbx
mov rbx, rdi
mov rdi, rsi
sub rsp, 0x8
call    qword ptr [rbx]
mov rax, qword ptr [rbx + 0x8]
add rsp, 0x8
mov rdi, rbp
pop rbx
pop rbp
jmp rax

func 0x401500, the interface_dispatch function under this compilation mode, is the same shape as its non-speculative counterpart above: the compiler’s speculative-devirtualization pass fires for the virtual-function call but does not fire for the fat-pointer interface call in this build. This is a real, observed asymmetry in this toy example, noted rather than dismissed.

The asymmetry disappears once ordinary inlining is allowed. The comparison above depends on virtual_dispatch and interface_dispatch being forced out-of-line via [[gnu::noinline, gnu::noclone]]. Removing that constraint and letting the compiler inline freely, both call sites collapse identically down to direct, non-polymorphic printf calls with no indirection or branching left at all:

<main [0x401040]>:
sub rsp, 0x8
call    <test() [0x4011e0]>
xor eax, eax
add rsp, 0x8
ret

<test() [0x4011e0]>:
sub rsp, 0x8
mov esi, 0x40201c
mov edi, 0x402008
xor eax, eax
call    <printf@plt [0x401030]>
mov esi, 0x40201c
mov edi, 0x402020
xor eax, eax
call    <printf@plt [0x401030]>
mov esi, 0x40201c
mov edi, 0x402008
xor eax, eax
call    <printf@plt [0x401030]>
mov esi, 0x40201c
mov edi, 0x402020
xor eax, eax
call    <printf@plt [0x401030]>
mov esi, 0x402004
mov edi, 0x402008
xor eax, eax
call    <printf@plt [0x401030]>
mov esi, 0x402004
mov edi, 0x402020
xor eax, eax
call    <printf@plt [0x401030]>
mov esi, 0x402004
mov edi, 0x402008
xor eax, eax
call    <printf@plt [0x401030]>
mov esi, 0x402004
xor eax, eax
add rsp, 0x8
mov edi, 0x402020
jmp <printf@plt [0x401030]>

With inlining permitted, the compiler resolves both Cat and Dog calls at compile time in both dispatch styles equally well, and the asymmetry observed in the noinline comparison no longer shows up in the final code, since neither call survives as an indirect call at all.

The observed gap — an out-of-line, fat-pointer interface call not receiving the same speculative-devirtualization treatment as an equivalent out-of-line virtual call — is real in this toy example and is not dismissed. But the author does not consider a single small, artificially-noinlined benchmark sufficient grounds to conclude that interface dispatch carries a significant cost relative to virtual dispatch in general: the non-speculative comparison shows the fat-pointer representation itself imposes no meaningful overhead, and the fully-inlined comparison shows the speculative-devirtualization gap vanishing once normal optimization is allowed to proceed. Whether that gap reappears at realistic call-site diversity, outside this small and deliberately noinlined case, is not yet known and needs further implementation experience before it can be treated as settled either way.

3 Open design questions

A number of examples in this section go beyond illustrating the core design and instead sketch a possible answer to a question the author does not yet consider settled. Each such example is presented with working code and observed output, but is explicitly flagged as an open design question. These examples are not proposed as part of the design, and reviewer feedback is sought on whether the capability they explore belongs in this proposal at all, and if so, in what shape.

3.1 Exposing an interface through a C ABI

A separate, and so far unaddressed, use case is exposing the same interface to callers that cannot consume C++ interface machinery at all — clients written in C, or in any other language that can only consume a C ABI. This subsection sketches one possible shape such support could take, using a small game-library example, but it is presented as an open question rather than as a design this paper is proposing: the specific mechanism shown here is one way to obtain the target effect, not a semantic requirement of the facility, and reviewer input on better alternatives is explicitly sought.

A C header should be able to declare a plain, C-compatible handle type standing in for an interface reference, plus a table of C function pointers standing in for that interface’s operations — without the C side needing any awareness of vtables, accessors, composition, or any other part of the C++-facing model. A separate C++ translation unit then defines the actual interface, and registers the chosen handle type against it, after which the C-visible function table and lifetime-management entry points (create_player, destroy_player, deallocate_player, and so on) are produced automatically from that registration rather than hand-written per operation.

// .h
#ifdef __cplusplus
extern "C" {
#endif

typedef struct player_handle_t {
    const void* vtable_address;
    void* object_address;
} player_handle_t;

typedef struct player_const_handle_t {
    const void* vtable_address;
    const void* const_object_address;
} player_const_handle_t;

typedef struct damage_handle_t {
    const void* vtable_address;
    void* object_address;
} damage_handle_t;

typedef struct read_methods_t {
    int32_t (*id)(player_const_handle_t);
    int32_t (*health)(player_const_handle_t);
} read_methods_t;

typedef struct write_methods_t {
    int32_t* (*health)(player_handle_t);
    player_handle_t (*take_damage)(player_handle_t, damage_handle_t);
    void (*emplace)(player_handle_t, int32_t, int32_t);
} write_methods_t;

const read_methods_t* read_methods_address(void);
const write_methods_t* write_methods_address(void);

player_handle_t create_player(int32_t, int32_t);
void destroy_player(player_handle_t);
void deallocate_player(player_handle_t);

damage_handle_t create_damage(int32_t);
void delete_damage(damage_handle_t);

player_const_handle_t as_const(player_handle_t p_handle)
{
    return player_const_handle_t{
        p_handle.vtable_address,
        p_handle.object_address,
    };
}

#ifdef __cplusplus
}
#endif
// .cpp
constexpr auto deallocate_token = stdx::interface_token{
    "delete",
    [](auto&& p_self) {
        std::allocator<std::remove_cvref_t<decltype(p_self)>>{}.deallocate(
            std::addressof(p_self),
            1
        );
    },
};

constexpr auto damage = stdx::make_interface<{
    {"~T", true},
    deallocate_token,
    {
        "amount",
        {^^int32_t() const},
        [](auto&& p_self) { return p_self.amount; },
    },
}>;

constexpr auto player = stdx::make_interface<{
    {
        "id",
        {^^int32_t() const},
        [](auto&& p_self) { return p_self.id; },
    },
    {
        "health",
        {^^int32_t() const, ^^int32_t*()},
        [](auto&& p_self) {
            if constexpr (is_const(remove_reference(^^decltype(p_self))))
                return p_self.health;
            else
                return std::addressof(p_self.health);
        },
    },
    {
        "take_damage",
        {^^stdx::virtual_self*(const stdx::virtual_self_of<damage>*)},
        [](stdx::this_interface_accessor auto&& p_this, const auto p_damage) -> auto&& {
            *p_this.health() -= p_damage->amount();
            return p_this;
        },
    },
    {
        "emplace",
        {^^void(int32_t, int32_t)},
        [](auto&& p_self, int32_t p_id, int32_t p_health) {
            std::construct_at(std::addressof(p_self), p_id, p_health);
        },
    },
    {"~T", true},
    deallocate_token,
}>;

struct player_t {
    int32_t id, health;
};

struct attack_damage_t {
    int32_t amount;
};

template <typename HandleT_, typename T_>
constexpr auto create(auto&&... p_args) noexcept
{
    auto* l_ptr = std::allocator<T_>{}.allocate(1);
    std::construct_at(l_ptr, std::forward<decltype(p_args)>(p_args)...);
    return stdx::interfaces::cabi_create_handle<HandleT_>(*l_ptr);
}

consteval
{
    player.cabi_register_handle(^^player_handle_t);
    player.cabi_register_handle(^^player_const_handle_t);
    damage.cabi_register_handle(^^damage_handle_t);
}

extern "C" {
const read_methods_t* read_methods_address(void)
{
    static_assert(noexcept(stdx::interfaces::cabi_methods_address<read_methods_t>()));
    return stdx::interfaces::cabi_methods_address<read_methods_t>();
}

const write_methods_t* write_methods_address(void)
{
    static_assert(noexcept(stdx::interfaces::cabi_methods_address<write_methods_t>()));
    return stdx::interfaces::cabi_methods_address<write_methods_t>();
}

player_handle_t create_player(int32_t p_id, int32_t p_health)
{
    return create<player_handle_t, player_t>(p_id, p_health);
}

void destroy_player(player_handle_t p_handle)
{
    static_assert(noexcept(stdx::interfaces::cabi_destroy_handle(p_handle)));
    stdx::interfaces::cabi_destroy_handle(p_handle);
}

void deallocate_player(player_handle_t p_handle)
{
    static_assert(noexcept(stdx::interfaces::cabi_deallocate_handle(p_handle)));
    stdx::interfaces::cabi_deallocate_handle(p_handle);
}

damage_handle_t create_damage(int32_t p_amount)
{
    return create<damage_handle_t, attack_damage_t>(p_amount);
}

void delete_damage(damage_handle_t p_handle)
{
    static_assert(noexcept(stdx::interfaces::cabi_destroy_handle(p_handle)));
    static_assert(noexcept(stdx::interfaces::cabi_deallocate_handle(p_handle)));
    stdx::interfaces::cabi_destroy_handle(p_handle);
    stdx::interfaces::cabi_deallocate_handle(p_handle);
}
}
player_handle_t p = create_player(42, 1000);
damage_handle_t d = create_damage(100);
const read_methods_t* read_methods = read_methods_address();
const write_methods_t* write_methods = write_methods_address();

printf("id: %d\n", read_methods->id(as_const(p)));
printf("health: %d\n", read_methods->health(as_const(p)));

p = write_methods->take_damage(p, d);

printf("health after damage: %d\n", read_methods->health(as_const(p)));

*write_methods->health(p) += 1000;

printf("health after assignment: %d\n", read_methods->health(as_const(p)));

destroy_player(p);
write_methods->emplace(p, 24, 3000);
printf("new id: %d\n", read_methods->id(as_const(p)));
printf("new health: %d\n", read_methods->health(as_const(p)));

destroy_player(p);
deallocate_player(p);
delete_damage(d);

Output:

id: 42
health: 1000
health after damage: 900
health after assignment: 1900
new id: 24
new health: 3000

A handle type registered via cabi_register_handle must be a plain, standard-layout-like aggregate whose first data member is a pointer-sized slot for the vtable address and whose second data member is a pointer to the type-erased object, addressed by member index rather than by name — so the handle’s field names are free for the C API’s author to choose, as player_handle_t and player_const_handle_t illustrate. Two separate handle types are registered for player — one whose second member is void*, one whose second member is const void* — and this difference in the second member’s pointee cv-qualification is what the C side uses to select between the health operation’s two overloads (int32_t() const versus int32_t*()): read_methods_t::health takes the const-pointer handle and dispatches to the const-qualified candidate; write_methods_t::health takes the non-const-pointer handle and dispatches to the mutable candidate. In effect, the cv-qualification that would ordinarily be carried by the qualification of a C++ reference is instead carried by which of two registered handle types a given C call site chooses to construct — as_const(p) producing a player_const_handle_t from a player_handle_t is the C-side stand-in for a C++ const-qualifying conversion.

Once registered, cabi_methods_address<TableT>() produces the function table’s contents automatically, mapping each field of the C table type (matched by name — id, health, take_damage, emplace — against the registered interface’s operations) to a small extern "C"-compatible trampoline that unpacks the handle, dispatches through the vtable it names, and repacks any handle-shaped result. take_damage’s stdx::virtual_self* return type demonstrates that this repacking composes with the earlier stdx::virtual_self mechanism: the operation returns a pointer to itself, of the same interface, and the trampoline layer repackages that returned accessor back into a player_handle_t for the C caller.

The sketch above resolves cv-qualification at the C boundary by minting two distinct handle types per interface (one per relevant qualification), and treats “which handle type was this value constructed as” as the carrier of cv context, since a C struct has no qualification of its own to carry that context the way a C++ reference would. This is a workable target effect, not a claim that it is the best one. Reviewers are asked specifically whether there is a better contract for an interface to recognize and validate a caller-supplied C handle — one that does not require registering a separate handle type per cv-qualification an interface’s operations may need to distinguish, and that gives the compiler or a sanitizer a stronger basis for catching a mismatched or forged handle than an unchecked reinterpretation of two pointer-sized struct members.

3.2 Interoperating with owning storage facilities

interface.ref(x) deliberately yields only a non-owning view of the underlying object. The standard library and user code already provide a wide range of facilities for owning storage and managing an object’s lifetime — std::unique_ptr, std::any, and countless user-defined owning containers among them — and this proposal does not intend to introduce another one. What it does need is a well-defined, flexible contract by which an existing owning container can be constructed to hold a given underlying object together with a chosen interface, and by which interface operations can subsequently be invoked through that container.

This subsection sketches one possible such contract, using std::unique_ptr as the example owning container, and is presented as an open question rather than as a design this paper is proposing. Reviewers are asked specifically whether there is a better contract than the one shown here.

constexpr auto lifetime_base = stdx::make_interface<{
    {
        "emplace",
        {^^void(int)},
        [](auto&& p_self, int p_x) { std::construct_at(std::addressof(p_self), p_x); },
    },
    {
        "~T",
        [](auto&& p_self) {
            std::println("destroy");
            std::destroy_at(std::addressof(p_self));
        },
    },
    {
        "delete",
        {^^void()},
        [](auto&& p_self) {
            std::println("deallocate");
            using allocator_type = std::allocator<std::remove_cvref_t<decltype(p_self)>>;
            allocator_type{}.deallocate(std::addressof(p_self), 1);
        },
    },
}>;

struct character {
    int id;
};

template <typename T_>
constexpr bool is_uptr_v = false;

template <typename DT_>
constexpr bool is_uptr_v<std::unique_ptr<void, DT_>> = true;
character* ptr = std::allocator<character>{}.allocate(1);

auto uptr = stdx::interfaces::make_void_unique(lifetime_base.ref(*ptr));

static_assert(is_uptr_v<decltype(uptr)>);

auto view = lifetime_base.ref(uptr);

static_assert(std::is_same_v<decltype(view), decltype(lifetime_base.ref(*ptr))>);

view->emplace(42);
std::println("id: {}", ptr->id);

uptr.reset();

Output:

id: 42
destroy
deallocate

a library-supplied conversion function — here given the working, deliberately provisional name stdx::interfaces::make_void_unique — takes an interface reference and produces an owning std::unique_ptr<void, DT_> whose custom deleter DT_ closes over the interface’s "~T" and "delete" operations, so that resetting or destroying the unique_ptr runs exactly the destruction and deallocation behavior the interface itself declares, rather than some fixed, interface-independent default. interface.ref(x) itself is extended to recognize such a container when passed as its argument: rather than treating a unique_ptr<void, DT_> as an ordinary object to be viewed structurally, .ref() detects that the container was produced by the interface’s own conversion, and fetches a non-owning view of the object it holds — as confirmed by view’s type being identical to the type of a reference constructed directly from *ptr. This recognition step is presented as the least amount of cooperation needed for an owning container to inter-operate with the interface model at all: without it, an owning container produced this way would be indistinguishable, from .ref()’s point of view, from any other opaque object, and no view could be recovered from it.

This sketch does not solve a problem: As constructed, the std::unique_ptr’s vtable pointer lives inside its deleter object rather than being reachable through the pointer unique_ptr itself exposes, so uptr->emplace(42) — calling an interface operation directly through the owning container, the way view->emplace(42) works through an interface reference — is not possible. A caller must explicitly convert the container back into an interface reference via a second .ref() call before invoking any operation, which is an awkward extra step compared to every other case in this paper, where dereferencing or arrow-accessing a value obtained from .ref() is sufficient on its own. The provisional make_void_unique name reflects this: it is not proposed as the intended final API. Should a sound design close this gap — for instance, one in which interface.make_unique(x) produces a container that itself supports direct operation calls, rather than requiring a caller to re-.ref() it — that would be the preferred shape of this facility, and reviewer input toward such a design, or toward an entirely different contract for owning-container interoperation, is explicitly sought.

3.3 Member function injection

Every example in this paper depends on an interface reference exposing operations as named members — view->print_name(), view->id(), and so on — with the member’s name matching the string given when the operation was declared. C++26 reflection does not itself provide a way to inject an actual member function into a type from a compile-time string. The current implementation therefore approximates member function injection rather than performing it directly: it uses std::meta::define_aggregate to inject a non-static data member per named operation, whose type is a small callable-like class overloading operator(), so that syntax resembling a member function call (view->print_name()) is in fact a member-access followed by an invocation of that member’s operator(), rather than an ordinary member function call.

Here is the minimal reproduction of this technique, relying on pointer-interconvertible:

template <typename DerivedT_, typename OwnerT_>
struct function_t {
    auto operator()()
    {
        auto ptr = static_cast<DerivedT_*>(reinterpret_cast<OwnerT_*>(this));
        std::println("{}", ptr->id);
    }
};

template <typename DerivedT_, auto Name_>
struct outter_t {
    struct inner_t;

    static constexpr auto members_v = std::array{data_member_spec(
        substitute(^^function_t, {^^DerivedT_, ^^inner_t}),
        {
            .name = std::string_view{Name_},
            .no_unique_address = true,
        }
    )};

    consteval { define_aggregate(^^inner_t, members_v); }

    static_assert(is_standard_layout_type(^^inner_t));
    static_assert(members_v.size() == 1);
};

template <auto... Names_>
struct functions_wrapper_t : outter_t<functions_wrapper_t<Names_...>, Names_>::inner_t... {
    int id;
};
functions_wrapper_t<std::define_static_string("foo"), std::define_static_string("bar")> funcs{
    .id = 42
};
funcs.foo();
funcs.bar();
static_assert(sizeof(funcs) == sizeof(funcs.id));

Output:

42
42

This technique is, on its face, exactly the kind of thing this paper elsewhere insists should be left to implementation discretion — an implementation reaches an observable effect (member-call syntax dispatching to a chosen operation) by whatever internal means it likes. What sets this case apart is that the cost of the chosen means is not fixed: as noted throughout this paper, the two-machine-word size measured for an interface reference is an implementation observation, not a semantic requirement, and that observation is presently a satisfying one — two pointers is a cost model users would find acceptable. But that number depends on the injected operator()-object data member being optimized away by [[no_unique_address]] (or an equivalent empty-member optimization) for a reflection-defined aggregate, and different compilers may implement that optimization differently for such aggregates. A compiler that implements it less favorably could inflate the interface reference’s size well beyond two pointers to a point users would reasonably find unaffordable, even though nothing in the visible behavior of their program would have changed. The author regards injecting a non-static data member as a workaround adopted only because current reflection facilities offer no direct way to inject a member function from a compile-time string, and would prefer that this proposal’s semantics remain deliberately silent on how member-call syntax is achieved, so that an implementation is free to replace this workaround with genuine member function injection, without any change in observable behavior, once a future C++ reflection facility supports it directly.

Two related questions follow from this. First, is there a better implementation scheme available today — one that achieves member-call syntax for a dynamically-named, per-declaration set of operations without depending on how favorably a given compiler happens to optimize a define_aggregate-injected data member? Second, granting that the current, data-member-based scheme may be the best available today: does committing to a semantics built on it foreclose, or at least complicate, a later switch to real member function injection — for instance, by leading users to depend, even inadvertently, on the current scheme’s specific cost characteristics as though they were guaranteed — or can this paper word the proposal so that the underlying mechanism remains genuinely open to being swapped out later? If neither question has a satisfactory answer today, the author is unsure whether this facility is ready to be proposed for standardization now, as opposed to waiting for a future reflection facility that supports member function injection directly; reviewer guidance on this point is explicitly requested.

3.4 Extension

Every example so far assumes the caller adapting a type to an interface controls at least one side of that pairing — the type, the interface, or both. A harder case arises when a user controls neither: a concrete type comes from one party, an interface comes from a second, unrelated party, and the user simply needs the two to work together, without being able to modify either the type’s definition or the interface’s declaration. Some third mechanism is needed to supply, from outside both parties, an implementation of an operation for a type that was never designed with that operation — or that interface — in mind.

This subsection sketches a possible mechanism, stdx::extension, and presents it as an open design question rather than a settled part of the proposal.

constexpr auto animal = stdx::make_interface<{
    {
        "print_name",
        {^^void() const},
        [](auto&& p_self) {
            std::println("current: {}", identifier_of(remove_cvref(^^decltype(p_self))));
        },
    },
}>;

struct cat {};

struct mimic {
    std::string fake_name;
};

template <typename IfaceT_, typename LookupSigT_, auto ImplFnV_>
constexpr auto stdx::extension<mimic, IfaceT_, "print_name", LookupSigT_, ImplFnV_> =
    [](auto&& p_self) {
        ImplFnV_(p_self);
        std::println("play as {}", p_self.fake_name);
    };
cat c{};
mimic m{.fake_name = "dog"};

auto view = animal.ref(c);
view->print_name();
view = animal.ref(m);
view->print_name();

Output:

current: cat
current: mimic
play as dog

Here, mimic is the third-party type and animal is the third-party interface; neither is modified. A specialization of stdx::extension<...> supplies, from entirely outside both, an alternative implementation of print_name specifically for mimic, given access to animal’s own original implementation (ImplFnV_) so the extension can choose to delegate to it — as shown, calling the original implementation and then layering additional behavior on top, rather than replacing it outright.

Two concerns are raised against adopting stdx::extension as proposed. First, extension-style mechanisms of this general shape — attaching an implementation of an interface to a type after the fact, from a third location — have drawn debate in at least one other language that offers a comparable feature (Swift’s extensions), and this paper does not attempt to resolve that broader debate; it notes only that the concern is not novel to this design. Second, and more concretely, the sketch above is noticeably awkward once the interface operation being extended uses self-deduction (stdx::this_interface_accessor) rather than taking its self parameter as a plain forwarding reference:

constexpr auto animal = stdx::make_interface<{
    {
        "print_name",
        {^^void() const},
        [](stdx::this_interface_accessor auto&& p_this) {
            std::println("current: {}", identifier_of(stdx::interfaces::underlying_type(^^p_this)));
        },
    },
}>;

struct cat {};

struct mimic {
    std::string fake_name;
};

template <typename IfaceT_, typename LookupSigT_, auto ImplFnV_>
constexpr auto stdx::extension<mimic, IfaceT_, "print_name", LookupSigT_, ImplFnV_> =
    [](stdx::this_interface_accessor auto&& p_this) {
        ImplFnV_(p_this);
        std::println("play as {}", get_underlying(p_this).fake_name);
    };
cat c{};
mimic m{.fake_name = "dog"};

auto view = animal.ref(c);
view->print_name();
view = animal.ref(m);
view->print_name();

Output:

current: cat
current: mimic
play as dog

The observable result is identical to the non-self-deducing case, but producing it required the extension’s own implementation to be rewritten as another self-deducing operation (taking stdx::this_interface_accessor auto&& p_this rather than an ordinary mimic&), specifically because the extension author needed to know, ahead of time, that print_name’s original implementation happened to be declared with self-deduction, and to match that calling convention in order to forward to ImplFnV_ correctly. Nothing about the interface animal forces this awareness on the extension author from the outside — self-deduction is an implementation-side choice made when print_name was originally declared, not something visible in animal’s public shape — yet the extension mechanism as sketched requires the extension author to discover and match it anyway. This gets substantially worse for a stdx::extension specialization that wants to extend several operations of an interface at once, where some of those operations happen to use self-deduction and others do not: as currently sketched, the specialization would need a different implementation shape per operation, chosen according to a detail of that operation’s original declaration that the specialization has no principled way to discover other than by inspection.

What this design needs, and does not yet have, is a uniform invocation utility: a way for an extension’s implementation to call ImplFnV_ — or any other interface operation’s implementation — without needing to know in advance whether that implementation was declared with self-deduction or with an ordinary self parameter. Until such a utility exists, the author does not consider stdx::extension a workable design, and this subsection is presented as an open question rather than a proposed feature: reviewer input is sought both on whether third-party-to-third-party adaptation belongs in this proposal at all, given the concerns raised against extension-style mechanisms generally, and, if it does belong, on how a self-deduction-agnostic invocation utility of this kind should be shaped.

4 Known design smells in the current implementation

The items in this section share a common shape: each achieves its intended, observable behavior in the current implementation, but does so through a mechanism the author considers awkward, non-idiomatic, or otherwise unsatisfying as a final design. They are documented here — separately from the open questions section — specifically to invite discussion on cleaner alternatives to solved problems, not on whether the underlying capability is wanted at all.

4.1 Expressing delete ptr-style destroy-and-deallocate

C++’s delete ptr expression both destroys an object and deallocates its storage in a single operation, but operator delete itself — the function delete ptr calls after invoking the destructor — is responsible for deallocation alone. This proposal’s own reserved name "delete" mirrors operator delete rather than the delete expression: it names a deallocation-only operation, deliberately kept separate from "~T"’s destruction-only operation, so that the two can be composed, overridden, and elided independently. This leaves open how a caller should express the combined destroy-and-deallocate act that an ordinary delete ptr expression performs in one step, when the underlying object was originally obtained through new.

This subsection sketches one possible mechanism for that combined act, and is presented as an open question: the mechanism shown is considered by the author to still be awkward, and reviewer input toward a better design is explicitly sought.

constexpr auto lifetime_base = stdx::make_interface<{
    {
        "delete new",
        {^^void() noexcept},
        [](auto&& p_self) { delete std::addressof(p_self); },
    },
}>;

struct character {
    int id;
    ~character() { std::println("destroy"); }
};
auto ptr = new character{42};

auto view = lifetime_base.ref(*ptr);

view.delete_new();
static_assert(noexcept(view.delete_new()));

Output:

destroy

"delete new" is, in fact, a third reserved lifetime name, alongside "~T" and "delete": declaring an operation under this name generates a corresponding member function, delete_new, on the interface reference itself, in exactly the same way "~T" generates destroy and "delete" generates deallocate — it is recognized and treated specially by the facility, not left to be inferred from an ordinary operation’s implementation. Here, its implementation is simply the C++ delete expression applied to the address of the underlying object, matching the combined destroy-and-deallocate effect that delete ptr has in ordinary C++.

Why this is considered unsatisfactory? The problem with this sketch is not that "delete new" lacks reserved-name recognition — it has that, the same as "~T" and "delete" — but that, unlike those two, its spelling is not self-explanatory to a reader encountering it for the first time. "~T" and "delete" each read naturally as naming a single, familiar C++ concept — a destructor, operator delete — whereas "delete new" is a two-word phrase invented for this proposal specifically to evoke “the delete counterpart to an object obtained via new,” a relationship that is not obvious from the name alone and that a reader must be told about rather than infer. This raises two open, related questions this paper does not yet answer: first, whether a combined destroy-and-deallocate operation of this kind is worth offering as part of the facility at all, given that "~T" and "delete" can already be called in sequence to the same effect; and second, if it is worth offering, whether "delete new" is simply too awkward a spelling to be accepted by users, and if so, whether there is a better-looking, cleaner naming scheme — a different reserved word, a different generated member-function name, or a different mechanism entirely — for expressing the same combined role that an ordinary delete ptr expression plays.

4.2 Qualified calls

In ordinary C++, a qualified call — writing obj.Base::method() rather than obj.method() — lets a caller invoke a specific base class’s version of a virtual member function directly, bypassing dynamic dispatch, without needing a second object or a cast to the base type. This proposal has no comparable direct notation. The nearest equivalent the current implementation offers is to force resolution to a specific declaration by constructing a second interface reference through a distinguished conversion, static_ref, rather than the ordinary .ref() used everywhere else in this paper:

constexpr auto named = stdx::make_interface<{
    {
        "print_name",
        {^^void() const},
        [](auto&& p_self) {
            std::println("current: {}", identifier_of(remove_cvref(^^decltype(p_self))));
        },
    },
}>;

constexpr auto animal = stdx::make_interface<{
    named,
    {
        "print_name",
        {^^void() const},
        [](auto&& p_self) {
            std::println("current animal: {}", identifier_of(remove_cvref(^^decltype(p_self))));
        },
    },
}>;

struct cat {};
cat c{};

auto named_view = named.ref(animal.ref(c));
named_view->print_name();

named_view = named.static_ref(named_view);
named_view->print_name();

named_view = named.static_ref(animal.ref(c));
named_view->print_name();

Output:

current animal: cat
current: cat
current: cat

The first call reproduces the ordinary, previously-established behavior: converting an animal reference down to named does not discard animal’s override, so print_name still prints "current animal: cat". The second and third calls show named.static_ref producing a reference that instead resolves print_name to named’s own, un-overridden declaration — regardless of whether the value passed to it already carries animal’s override or is constructed fresh from an animal reference. This is, functionally, the effect a qualified call achieves in ordinary C++: pinning a call to one specific declaration of an operation, in a hierarchy where some other declaration would ordinarily be selected instead.

The mechanism works, but it does not read as a qualified call. In ordinary C++, obj.Base::method() names the call and the target base together, in one expression, at the point of the call; here, achieving the same effect requires constructing an intermediate value — via a method whose own name, static_ref, gives no indication that its purpose is to pin an operation to a specific, non-overridden declaration. A reader encountering named.static_ref(...) for the first time has no way to infer, from the name alone, that it is this proposal’s answer to C++’s qualified call syntax. This is recorded here as a known rough edge in the current implementation, not as a design the author is defending: a better surface syntax or naming — ideally one that reads more directly as “call this specific declaration,” the way Base::method() does — is wanted, but has not yet been found.

4.3 RTTI facility

Ordinary C++ offers run-time type identification via dynamic_cast and typeid. This proposal offers a structurally similar facility, get_underlying_if<T> (found by ADL, or reachable explicitly as stdx::interfaces::get_underlying_if), which tests whether the object underlying a dereferenced interface reference is a given type T, returning a pointer to that object on success and a null pointer on failure — the same shape dynamic_cast<T*> has for pointer-to-polymorphic-type queries.

constexpr auto animal = stdx::make_interface<{
    {
        "print_name",
        {^^void() const},
        [](auto&& p_self) {
            std::printf("current: %s\n", identifier_of(remove_cvref(^^decltype(p_self))).data());
        },
    },
}>;

struct cat {};
struct dog {};

[[gnu::noinline]]
void print_name(auto p_view)
{
    template for (constexpr auto type : {^^cat, ^^dog})
    {
        if (typename[:type:]* ptr = get_underlying_if<typename[:type:]>(*p_view)) {
            std::printf("current: %s\n", identifier_of(type).data());
            return;
        }
    }

    std::unreachable();
}
cat c{};
dog d{};

auto view = animal.ref(c);
print_name(view);
view->print_name();

view = animal.ref(d);
print_name(view);
view->print_name();

Output:

current: cat
current: cat
current: dog
current: dog

A reference’s vtable address is not stable across every conversion path that could reach it: an interface reference upcast from a wider, differently-composed interface may present a different vtable than one constructed directly against the same object at the target interface, whenever a conversion crosses an override. get_underlying_if therefore does not compare vtable addresses directly; it consults a separate type tag recorded in the vtable, at the cost of one additional indirect load beyond the vtable pointer already being dereferenced.

However, eliding the extra load when no upcast should be possible. In the common case where a caller can guarantee, from context, that the interface reference in hand was never produced by upcasting from some other interface, the tag-based check is doing strictly more work than needed: a direct vtable-address comparison would answer the same question correctly, at lower cost. get_underlying_if<T, ensure_final> — a second, boolean template argument — lets the caller assert this guarantee explicitly, and the implementation compiles the check down to the cheaper, direct comparison rather than the tag lookup:

Un-elided version:

<main [0x401040]>:
push    r13
push    r12
mov r12d, 0x402030
mov rdi, r12
mov r12d, 0x402020
sub rsp, 0x18
lea rax, [rsp + 0xe]
mov rsi, rax
call    <func 0x401240>
mov esi, 0x402004
mov edi, 0x402008
xor eax, eax
call    <printf@plt [0x401030]>
lea rax, [rsp + 0xf]
mov rdi, r12
mov rsi, rax
call    <func 0x401240>
mov esi, 0x402015
mov edi, 0x402008
xor eax, eax
call    <printf@plt [0x401030]>
add rsp, 0x18
xor eax, eax
pop r12
pop r13
ret

<func 0x401240>:
cmp qword ptr [rdi + 0x8], 0x402030
jne <func 0x40124f>
test    rsi, rsi
jne <func 0x401260>
mov esi, 0x402015
mov edi, 0x402008
xor eax, eax
jmp <printf@plt [0x401030]>
mov esi, 0x402004
mov edi, 0x402008
xor eax, eax
jmp <printf@plt [0x401030]>

Elided version:

constexpr auto animal = stdx::make_interface<{
    {
        "print_name",
        {^^void() const},
        [](auto&& p_self) {
            std::printf("current: %s\n", identifier_of(remove_cvref(^^decltype(p_self))).data());
        },
    },
}>;

struct cat {};
struct dog {};

[[gnu::noinline]]
void print_name(auto p_view)
{
    static constexpr bool ensure_final = true;

    template for (constexpr auto type : {^^cat, ^^dog})
    {
        if (typename[:type:]* ptr = get_underlying_if<typename[:type:], ensure_final>(*p_view)) {
            std::printf("current: %s\n", identifier_of(type).data());
            return;
        }
    }

    std::unreachable();
}
cat c{};
dog d{};

auto view = animal.ref(c);
print_name(view);
view->print_name();

view = animal.ref(d);
print_name(view);
view->print_name();

Output:

current: cat
current: cat
current: dog
current: dog
<main [0x401040]>:
push    r13
push    r12
mov r12d, 0x402030
mov rdi, r12
mov r12d, 0x402020
sub rsp, 0x18
lea rax, [rsp + 0xe]
mov rsi, rax
call    <func 0x401240>
mov esi, 0x402004
mov edi, 0x402008
xor eax, eax
call    <printf@plt [0x401030]>
lea rax, [rsp + 0xf]
mov rdi, r12
mov rsi, rax
call    <func 0x401240>
mov esi, 0x402015
mov edi, 0x402008
xor eax, eax
call    <printf@plt [0x401030]>
add rsp, 0x18
xor eax, eax
pop r12
pop r13
ret

<func 0x401240>:
test    rsi, rsi
je  <func 0x40124e>
cmp rdi, 0x402030
je  <func 0x401260>
mov esi, 0x402015
mov edi, 0x402008
xor eax, eax
jmp <printf@plt [0x401030]>
mov esi, 0x402004
mov edi, 0x402008
xor eax, eax
jmp <printf@plt [0x401030]>

Comparing the generated code for the two versions (GCC 16.1, -O3) makes the difference concrete. Without ensure_final, the per-candidate check first loads the tag from the vtable and compares that loaded value against the candidate’s expected tag:

<func 0x401240>:
cmp qword ptr [rdi + 0x8], 0x402030
jne <func 0x40124f>
...

With ensure_final = true, the same check instead compares the reference’s vtable pointer directly against the candidate’s known vtable address. The load-then-compare becomes comparing against a value already in a register — precisely the one indirect load ensure_final is documented as eliding.

A blanket “this reference was never upcast” guarantee is sometimes stronger than what a caller actually knows or wants to assert; a caller may instead know only that, if the reference was upcast, it was upcast along one specific, named composition path. The current implementation extends get_underlying_if with a template argument taking a list of interfaces describing that path, letting the direct vtable-address comparison be used even in the presence of an upcast, provided the asserted path matches:

constexpr auto may_override_token = stdx::interface_token{
    "may_override",
    {^^void()},
    [](auto&&) {},
};

constexpr auto base = stdx::make_interface<{may_override_token}>;

constexpr auto branch_0 = stdx::make_interface<{base}>;
constexpr auto branch_1 = stdx::make_interface<{base, may_override_token}>;
constexpr auto derived = stdx::make_interface<{branch_0, branch_1}>;

[[gnu::noinline]]
void print_branch(auto p_base_view)
{
    template for (constexpr auto branch : {^^branch_0, ^^branch_1})
    {
        if (int* ptr = get_underlying_if<int, {derived, branch, base}>(*p_base_view)) {
            std::printf("current: %s\n", identifier_of(branch).data());
            return;
        }
    }

    std::unreachable();
}
int i = 42;

auto base_view = base.ref(branch_0.ref(derived.ref(i)));
print_branch(base_view);

base_view = base.ref(branch_1.ref(derived.ref(i)));
print_branch(base_view);

Output:

current: branch_0
current: branch_1

The generated code again drops the indirect load in favor of a direct comparison:

<main [0x401040]>:
push    r13
push    r12
mov r12d, 0x402040
mov rdi, r12
sub rsp, 0x18
lea rax, [rsp + 0xc]
mov dword ptr [rsp + 0xc], 0x2a
mov r13, rax
mov rsi, rax
call    <func 0x4011e0>
mov rsi, r13
mov edi, 0x402030
call    <func 0x4011e0>
add rsp, 0x18
xor eax, eax
pop r12
pop r13
ret

<func 0x4011e0>:
test    rsi, rsi
je  <func 0x4011ee>
cmp rdi, 0x402040
je  <func 0x401200>
mov esi, 0x40201a
mov edi, 0x40200d
xor eax, eax
jmp <printf@plt [0x401030]>
mov esi, 0x402004
mov edi, 0x40200d
xor eax, eax
jmp <printf@plt [0x401030]>

The author considers the underlying optimization opportunity — letting a caller who knows more about a reference’s provenance than the type system alone can express trade a tag lookup for a direct address comparison — reasonable and worth keeping in some form. What is unsettled is the surface API: get_underlying_if<T, ensure_final> and get_underlying_if<T, {path...}>. Neither shape reads as an idiomatic C++ API for “assert this fact about provenance to enable an optimization”. This is recorded as a known rough edge in the current implementation, not as a design the author is defending; a cleaner, more uniform way to let a caller assert varying degrees of upcast-path knowledge is wanted, but has not yet been found.

4.4 Type traits of an interface

Because an interface is a constexpr value rather than a type, asking a type-level question about it doesn’t fit the usual trait<T> shape. The current implementation instead exposes such queries as static member functions on the interface value itself, returning std::meta::info reflections:

constexpr auto animal = stdx::make_interface<{}>;

static_assert(is_same_type(animal.pointer_type(), ^^decltype(animal.ref(std::declval<int&>()))));

static_assert(
    is_same_type(animal.const_pointer_type(), ^^decltype(animal.ref(std::declval<const int&>())))
);

This works, but it’s marked as a possible design smell: it doesn’t read as an idiomatic type trait in the standard-library sense. Not central to the proposal’s core design — flagged here for completeness rather than as something requiring resolution before the rest of the paper can be reviewed.

5 Proposed facilities

Because the purpose of this paper is to explore this design space and solicit feedback, this section deliberately does not attempt a complete specification. It lists only the facilities necessary to name and give a rough shape to what the examples above already demonstrate observably. A great deal of the design space is left unaddressed here and may be taken up in a future revision.

namespace std {
    struct interface_token;

    template<auto Config>
    constexpr auto make_interface = see below;
}

make_interface is a variable template producing a compile-time interface instance. Its template parameter, Config, is constructed from a range of interface_token values, each describing either a single operation or a previously defined interface to compose. An interface instance produced this way exposes a static member function, ref, which takes a reference to an object and produces an interface reference over it — the value returned by every .ref(...) call throughout the examples in this paper.

namespace std::__detail { // exposition-only
    template<...> // exposition-only
    struct interface_instance_t { // exposition-only
        static constexpr auto ref(auto&&) noexcept;
    };

    template<...> // exposition-only
    struct interface_accessor_wrapper_t { // exposition-only
        constexpr auto operator*() const noexcept;
        constexpr auto operator->() const noexcept;
    };

    template<...> struct interface_accessor_t; // exposition-only
}

interface_accessor_wrapper_t is the exposition-only type of an interface reference — what ref() returns. It is parameterized by the cv/ref-qualification under which the referenced object is being viewed, and internally holds a single, unqualified interface_accessor_t. Its operator* yields a reference to that contained accessor, qualified to match the wrapper’s own cv/ref-qualification parameter; this is the mechanism realizing the qualifier propagation, where *view and std::move(*view) can select different operation overloads depending on how the reference was dereferenced. operator->, consistent with ordinary C++ operator-> semantics, yields a pointer to the accessor reflecting only its cv-qualification.

namespace std {
    template<typename T>
    concept this_interface_accessor = see below;
}

An interface operation whose parameter is constrained by this_interface_accessor is dispatched in self-deducing form: rather than receiving a forwarding reference to the underlying object, the parameter is bound directly to a special interface accessor. This is the mechanism behind every operation declared as [](stdx::this_interface_accessor auto&& p_this) { ... } in the preceding examples, and it is what lets such an operation call other operations of the same interface — via the accessor it receives — rather than only inspecting the raw underlying object.

namespace std {
    struct virtual_self;
    struct adl_virtual_self;

    template<auto&>
    using virtual_self_of = see below;
}

virtual_self, adl_virtual_self, and the type produced by virtual_self_of are all intentionally incomplete types. Each serves purely as a tag, used only when writing an operation’s candidate function-type declarations — never as a type that is itself completed, stored, or given an actual object representation. When the vtable is built and the corresponding accessor member function is injected, the implementation substitutes each tag occurrence with an appropriate concrete, complete type; a reader should treat every appearance of these tags in a signature as a placeholder describing where and how an accessor argument is accepted, not as a real parameter type.

6 Implementation experience

The observations below come from a single working prototype, compiled with GCC 16.1. They should be read as observations from one implementation on one compiler, not as claims about the design’s inherent characteristics — several of the problems noted are plausibly implementation- or compiler-specific, and the author flags this uncertainty explicitly where it applies, rather than presenting these results as conclusive.

6.1 Tooling and diagnostics

The current approach presents significant challenges for development tooling. Because interface operations are not, in fact, member functions, LSP-based tooling has no member function to find: it cannot offer completion or go-to-definition for an interface’s operations, and a user browsing an interface reference’s members in an editor sees synthesized, reflection-generated members rather than the operations they were declared to expose. This makes it considerably harder to discover and navigate.

Compilation can also be expensive under heavy use, and compiler diagnostics can grow extremely large. Interface tokens are represented internally through constant template arguments carrying compile-time data, and with GCC’s default diagnostic settings, a single type name appearing in an error message can run to millions of characters. As a result, users unfamiliar with metaprogramming techniques of this kind may find the library difficult to use effectively: diagnostics are often difficult to interpret, and the current implementation does not yet offer a good way to control how much diagnostic information the compiler produces for a given error.

6.2 Code size

A systematic evaluation of code size or code bloat has not yet been performed. The most apparent effect observed so far is the generation of very long symbol names. No other significant source of code-size growth has been observed, but this has not been investigated closely enough to draw a firm conclusion either way.

6.3 Linking performance

Under heavy use, linking can become substantially slower, and can require enough memory to cause out-of-memory failures. This may simply be a consequence of the very long symbol names noted above increasing the linker’s workload, but this has not been confirmed as the actual cause, and it is not yet known whether the overhead is an inherent consequence of the design or an artifact of this particular implementation that could be substantially reduced. Further investigation is needed before drawing a conclusion.

6.4 Features under consideration

This section describes features that have been considered during the development of this proposal but are not currently included, either in the proposal’s stated scope or in the working implementation. They are documented here to facilitate discussion of the proposal’s scope and its possible future direction, and none of them should be read as committed design decisions.

6.4.1 Tag elision

Every parameter declared in an interface operation’s signature currently forms part of the function-pointer type constructed at the ABI boundary, and on some ABIs, a parameter of empty type (std::is_empty_v) can still end up occupying a calling-convention slot despite carrying no run-time state. This feature would elide the first such empty-type parameter specifically, ensuring it never occupies a register or stack slot under any ABI, rather than leaving that outcome to depend on how a given ABI happens to treat empty parameters.

6.4.2 “std::format” as a reserved operation name

This feature would let users declare an interface operation under the reserved name "std::format", making the resulting interface accessor directly usable with std::format. The intended shape of this mechanism mirrors the lifetime operations("~T" and "delete"): a reserved name recognized by the facility, rather than an ordinary user-chosen operation name that happens to produce a similar effect.

6.4.3 Final interfaces

This feature would let a user mark an interface as final when declaring it via stdx::make_interface, preventing any other interface from composing it as a constituent — mirroring the effect of the final specifier on an ordinary C++ class with respect to further derivation.

6.4.4 Hiding composed names and using-declarations

This feature would let a user hide an operation name inherited from a composed interface, by redeclaring an operation under that same name, and then selectively reintroduce a specific hidden declaration via an interface_token referring back to it — mirroring the effect of using Base::func and the general hiding behavior of a derived class member that shadows a base class member of the same name in ordinary C++ inheritance.

6.4.5 A simpler way to construct an interface reference

The current design constructs an interface reference via interface.ref(x), deliberately mirroring std::ref(x). This is considered a conservative choice — users may find interface(x), with interface’s operator() taking on the role currently played by .ref(), a more direct and less verbose way to construct a reference.