| Document #: | P4374R0 [Latest] [Status] |
| Date: | 2026-09-07 |
| Project: | Programming Language C++ |
| Audience: |
LEWG |
| Reply-to: |
Theo Payne <painthingy@gmail.com> |
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.
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.
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: dogHere, 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.
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 movingAn 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 movingWhen 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: catNote 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.
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: catBecause 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.
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
24Above, 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);
},
},
}>;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
24An 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
24Here, 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.76The 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: 0For 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.
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: 18The 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: 18When 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: 18The 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 derivedIn 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 childBoth 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.
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:
42When 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:
42The 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:
42In 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:
42Some 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:
1764constexpr 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:
42Absent 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:
1764Member-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:
42336Two 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:
42Here
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 deallocatebase_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 deallocateHere 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()));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.
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.
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 movinganimal, 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 raxfunc 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 raxfunc 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.
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.
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: 3000A 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.
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
deallocatea 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.
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
42This 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.
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 dogHere, 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 dogThe 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.
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.
delete ptr-style
destroy-and-deallocateC++’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.
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: catThe 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.
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: dogA 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_1The 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.