| Document #: | P4374R1 Latest Status |
| Date: | 2026-10-21 |
| Project: | Programming Language C++ |
| Audience: |
LEWG |
| Reply-to: |
Theo Payne <painthingy@gmail.com> |
This paper proposes a library facility that makes non-intrusive polymorphism, today built on hand-written vtables, nearly as easy to write as virtual functions.
Virtual functions make runtime polymorphism cheap to write. The programmer declares the operations once; the compiler generates the dispatch table, conversions between related bases, and RTTI. The cost is that the concrete type must participate in the hierarchy.
When it can’t or shouldn’t (a type from another library, a type that
predates the abstraction, a type that shouldn’t carry a vptr), the
alternative is non-intrusive type erasure. In practice that means
hand-writing the dispatch.
std::function,
std::any and
std::move_only_function
each embed their own variant of this machinery. The semantics the
compiler supplies for virtual functions must be re-implemented each
time, or are partially unavailable.
This paper asks whether C++26 reflection lets a library close that gap: the user states an interface once, with no base class and no hand-written table, and gets a virtual-function-like programming model at comparable run-time cost.
The design mirrors the virtual-function semantic model as closely as practical, including interface composition, overriding, conversion between related interface views, and runtime identification of the underlying object. Unlike inheritance, concrete types need not inherit from the interface. Matching the model programmers already know lowers the cost of adoption.
The facility is non-owning. It does not replace owning type-erasure wrappers; it is the building block for writing them without a hand-written dispatch table.
Term
|
Meaning
|
|---|---|
stdx:: |
Placeholder for wherever the facility would live if adopted, most
likely
std::. Marks
names introduced by this paper, as opposed to names already in the
standard library. |
| implementation output | Output produced by an existing working implementation with
GCC 16.1. |
| expected output | Output expected from the proposed design. No implementation exists yet. |
An operation’s implementation might live on the concrete type or on the interface. This paper supports both.
The concrete type implements the operation as a member function. The interface only declares the signature.
struct Constant {
auto draw(std::ostream& out) const -> void {
out << "Constant(" << 1 << ')';
}
};
struct Variable {
int i;
auto draw(std::ostream& out) const -> void {
out << "Variable(" << i << ')';
}
};
P4380R0 DynRef/P4148R2 protocol_view
|
P3086R5 proxy
|
|---|---|
|
|
this paper
|
this paper with P4380R0
|
|---|---|
|
|
Paper
|
Implementation on the interface
|
|---|---|
| P4148R2 protocol/P4380R0 DynRef | rejected |
| P3086R5 proxy | yes |
| this paper | yes |
This paper lets the interface carry the implementation, for two reasons.
A third-party type may not provide the member the interface expects.
Here neither
std::string
nor int has
draw, so the interface supplies
it:
P3086R5 proxy
|
this paper
|
|---|---|
|
|
implementation output:
draw: hello world
draw: 42P4380R0 and
P4148R2 cannot express this: both
require the underlying type to have a
draw member.
With P4380R0 token sequence
macros, the declaration can take a more natural shape:
stdx::make_interface!{
drawable {
void draw() const {
std::println("draw: {}", *this);
}
};
};P4380R0 provides
inject_members for building
metaclasses. An interface that holds its own implementation can serve as
one: applying it to a type injects the operation, implementation
included.
P3086R5 proxy
|
this paper
|
|---|---|
|
|
expected output:
draw: hello worldP3086R5 needs an extra step to
extract the conventions, because a facade bundles non-owning conventions
with an owning policy. drawable in
this paper describes only the non-owning part, so it is usable as a
metaclass directly.
Paper
|
Interface is described by
|
|---|---|
| P4148R2 protocol/P4380R0 DynRef | class |
| P3086R5 proxy | chain of builder calls |
| this paper | range of
stdx::interface_token |
C++26 reflection makes
std::meta::info
an ordinary value, so range-based compile-time metaprogramming is
natural. P4380R0 follows this direction with
std::meta::token_sequence.
This paper uses the same approach with a narrower type:
stdx::interface_token.
The range form is convenient where the interface is derived by
std::views::transform,
std::views::filter
and similar adaptors. Below,
logged_event has
event as a base interface, plus a
logged_ variant of each of its
operations that logs before forwarding.
constexpr auto event = stdx::make_interface<{
{
"execute",
{^^void() const},
[](auto&& p_self) { std::println("execute: {}", p_self); },
},
{
"undo",
{^^void() const},
[](auto&& p_self) { std::println("undo: {}", p_self); },
},
}>;
template <auto Fn_>
constexpr auto logged_fn = [](auto&&... p_args) -> decltype(auto) {
std::puts("log...");
return std::invoke(Fn_, std::forward<decltype(p_args)>(p_args)...);
};
constexpr auto logged_event = stdx::make_interface<{
std::from_range,
[] {
auto tokens = std::vector<stdx::interface_token>{event};
tokens.append_range(event.tokens() | std::views::transform([](const auto& p_token) {
return stdx::interface_token{
std::string{"logged_"} + p_token.function_name(),
p_token.function_types(),
substitute(^^logged_fn, {p_token.function_impl()}),
};
}));
return tokens;
}(),
}>;int i = 42;
auto view = logged_event.ref(i);
view->execute();
view->undo();
view->logged_execute();
view->logged_undo();implementation output:
execute: 42
undo: 42
log...
execute: 42
log...
undo: 42A dedicated token type is preferred over a general
std::meta::token_sequence
for reasons:
token_sequence can hold content that
can never appear in an interface declaration. Handling it is the
library’s job. With a general type, users must handle it too.
interface_token leaves no such
states to handle.function_name(),
function_types(),
function_impl())
show up in completion.override
semantic
Paper
|
Override
|
|---|---|
| P4148R2 protocol/P4380R0 DynRef | rejected |
| P3086R5 proxy | not mentioned |
| this paper | yes |
An interface operation can be left unimplemented, and a derived interface can override it. As with virtual functions, the base fixes the shape and the derived type supplies the behavior. Here the behavior is chosen per interface, so one concrete type can be viewed through many interfaces that share a shape but differ in behavior.
inline constexpr auto skill = stdx::make_interface<{
{
"name",
{^^std::string_view() const},
0,
},
{
"cast",
{^^void()},
0,
},
{
"ready",
{^^bool() const},
0,
},
}>;
struct player {
using vtable_address_t = [:skill.vtable_address_type():];
static_assert(sizeof(vtable_address_t) == sizeof(void*));
int health = 100;
int mana = 100;
std::vector<vtable_address_t> skills = {};
void learn(const auto& p_iface)
{
skills.push_back(vtable_addressof(*skill.ref(p_iface.ref(*this))));
}
void cast(size_t p_idx)
{
const auto l_ref = skill.ref(skills[p_idx], this);
std::println("try skill: {}...", l_ref->name());
if (l_ref->ready())
l_ref->cast();
else
std::println("failed.");
}
};
consteval auto make_skill_tokens(auto p_name, auto p_cast, auto p_ready)
{
return std::vector<stdx::interface_token>{
skill,
{
"name",
{^^std::string_view() const},
p_name,
},
{
"cast",
{^^void()},
p_cast,
},
{
"ready",
{^^bool() const},
p_ready,
},
};
}
constexpr auto fireball = stdx::make_interface<{
std::from_range,
make_skill_tokens(
[](auto&&) { return "fireball"; },
[](auto&& p_self) {
p_self.mana -= 10;
std::println("hurl a fireball.");
},
[](auto&& p_self) { return p_self.mana >= 10; }
),
}>;
constexpr auto blizzard = stdx::make_interface<{
std::from_range,
make_skill_tokens(
[](auto&&) { return "blizzard"; },
[](auto&& p_self) {
p_self.mana -= 100;
std::println("summon a blizzard!");
},
[](auto&& p_self) { return p_self.mana >= 100; }
),
}>;
constexpr auto meditate = stdx::make_interface<{
std::from_range,
make_skill_tokens(
[](auto&&) { return "meditate"; },
[](auto&& p_self) {
p_self.mana += 100;
std::println("current mana: {}", p_self.mana);
},
[](auto&& p_self) { return true; }
),
}>;auto p = player{};
p.learn(fireball);
p.learn(blizzard);
p.learn(meditate);
p.cast(0);
p.cast(1);
p.cast(2);
p.cast(1);implementation output:
try skill: fireball...
hurl a fireball.
try skill: blizzard...
failed.
try skill: meditate...
current mana: 190
try skill: blizzard...
summon a blizzard!In a hand-written vtable, overriding means assigning to a function-pointer member. This is a very common pattern:
struct skill {
std::string_view(*name)(const void*);
void(*cast)(void*);
bool(*ready)(const void*);
};
constexpr auto fireball = skill{
.name = override-function,
.cast = override-function,
.ready = override-function,
};Another case is plugin. The vtable shape is fixed by the interface,
so it can be published in a header. The host sees
skill,
player and
make_skill_tokens. A plugin
overrides skill and returns the
vtable address, and the host stores and calls through it exactly as in
the previous example. ABI stability of the vtable layout is out of scope
here.
// .h
inline constexpr auto skill = stdx::make_interface<
// ...
>;
struct player {
// ...
void learn_from_plugin(vtable_address_t p_addr) { skills.push_back(p_addr); }
};
consteval auto make_skill_tokens(auto p_name, auto p_cast, auto p_ready) {
// ...
}// plugin
constexpr auto karsus_avatar = stdx::make_interface<{
std::from_range,
make_skill_tokens(
[](auto&&) { return "Karsus's Avatar"; },
[](auto&& p_self) {
// this skill grants unbounded power and ends badly: signed overflow
p_self.mana += std::numeric_limits<int>::max();
p_self.health += std::numeric_limits<int>::max();
std::println("current mana: {}\ncurrent health: {}", p_self.mana, p_self.health);
},
[](auto&& p_self) { return true; }
),
}>;
extern "C" auto plugin_get_skill() -> typename[:skill.vtable_address_type():]
{
return skill.vtable_addressof(karsus_avatar.vtable_addressof(^^player));
}// use plugin
auto p = player{};
p.learn_from_plugin(plugin_get_skill());
p.cast(0);implementation output:
try skill: Karsus's Avatar...
current mana: -2147483549
current health: -2147483549This is the usual shape of a plugin interface: the host fixes the table, the plugin fills it by derive and override.
This paper’s facility can supply the dispatch part of existing type-erasure wrappers. The wrapper itself still owns storage and lifetime.
protocolThe facility can implement a reduced
P4148R2 protocol.
Roughly, protocol has the shape
protocol<non-owning-config, owning-config>.
The interface says what can be
called. The owning part says how the object is stored and its lifetime
managed, and does not know the
interface exists. Here
InterfaceV_ is the non-owning part
and ResourceT_ is the owning
part.
This example adapts P4148R2 to
fit that split. ResourceT_ is a
customization point: protocol and
the vtable operations use it to construct, copy, move and destroy the
concrete type, so all storage policy lives in the resource.
A few features are omitted for brevity. for example,
P4148R2 requires
.method(...)
access; this example keeps
->method(...).
InterfaceV_ is received as
auto&
for convenience. A formal design might take a wrapper that stores the
type reflection, with the same observable result.
Firstly, we build the vtable factory from the interface.
protocol_base offers unimplemented
operations, declares __construct
(copy and move overloads),
__destroy, and every function
operation of InterfaceV_ left
unimplemented. protocol_derived
overrides them for a concrete T_ and
a derived interface:
template <auto& InterfaceV_, typename ResourceT_>
constexpr auto protocol_base = stdx::make_interface<{
std::from_range,
[] {
auto l_tokens = std::vector<stdx::interface_token>{
{
"__construct",
{^^void(const ResourceT_&), ^^void(ResourceT_&&)},
0,
},
{
"__destroy",
{^^void()},
0,
},
};
l_tokens.append_range(
InterfaceV_.tokens() //
| std::views::transform([](const auto& p_token) {
return stdx::interface_token{
p_token.function_name(),
p_token.function_types(),
0,
};
})
);
return l_tokens;
}(),
}>;
template <auto& InterfaceV_, stdx::static_string_t NameV_, typename T_>
constexpr auto function_helper = [](auto&& p_res, auto&&... p_args) -> decltype(auto) {
const auto l_ref = InterfaceV_.ref(*p_res.template get<T_>());
return stdx::interfaces::invoke<NameV_>(
std::forward_like<decltype(p_res)>(*l_ref),
std::forward<decltype(p_args)>(p_args)...
);
};
template <auto& BaseInterfaceV_, auto& DerivedInterfaceV_, typename ResourceT_, typename T_>
constexpr auto protocol_derived = stdx::make_interface<{
std::from_range,
[] {
auto l_tokens = std::vector<stdx::interface_token>{
protocol_base<BaseInterfaceV_, ResourceT_>,
{
"__construct",
{^^void(const ResourceT_&), ^^void(ResourceT_&&)},
[](auto&& p_self, auto&& p_res) {
if constexpr (requires {
p_self.template construct<T_>(
std::forward<decltype(p_res)>(p_res)
);
})
p_self.template construct<T_>(std::forward<decltype(p_res)>(p_res));
},
},
{
"__destroy",
{^^void()},
[](ResourceT_& p_self) {
if constexpr (requires { p_self.template destroy<T_>(); })
p_self.template destroy<T_>();
},
},
};
l_tokens.append_range(
DerivedInterfaceV_.tokens() //
| std::views::transform([](const auto& p_token) {
return p_token.is_function() && !p_token.is_pure_virtual()
? stdx::interface_token{
p_token.function_name(),
p_token.function_types(),
substitute(
^^function_helper,
{
std::meta::reflect_object(DerivedInterfaceV_),
std::meta::reflect_constant(
stdx::static_string_t{p_token.function_name()}
),
^^T_,
}
),
}
: p_token;
})
);
return l_tokens;
}(),
}>;Then we build protocol. It owns a
ResourceT_ and one vtable address.
The vtable operations call back into the resource:
template <auto& InterfaceV_, typename ResourceT_>
struct protocol {
private:
static constexpr auto& base_iface = protocol_base<InterfaceV_, ResourceT_>;
using vtable_address_t = [:base_iface.vtable_address_type():];
public:
auto operator->() const { return base_iface.ref(vtable_address_, std::addressof(res_)); }
auto operator->() { return base_iface.ref(vtable_address_, std::addressof(res_)); }
protocol() = default;
protocol(auto&& p_obj)
requires(!is_same_type(remove_cvref(^^decltype(p_obj)), ^^protocol))
{
using bare_t = std::remove_cvref_t<decltype(p_obj)>;
res_.template emplace<bare_t>(std::forward<decltype(p_obj)>(p_obj));
vtable_address_ = base_iface.vtable_addressof(
protocol_derived<InterfaceV_, InterfaceV_, ResourceT_, bare_t>.vtable_addressof(
^^ResourceT_
)
);
}
template <typename DerivedInterfaceT_>
protocol(const DerivedInterfaceT_&, auto&& p_obj)
{
using bare_t = std::remove_cvref_t<decltype(p_obj)>;
res_.template emplace<bare_t>(std::forward<decltype(p_obj)>(p_obj));
vtable_address_ = base_iface.vtable_addressof(
protocol_derived<InterfaceV_, ([:DerivedInterfaceT_::variable():]), ResourceT_, bare_t>.vtable_addressof(
^^ResourceT_
)
);
}
protocol(const protocol&)
requires(requires { requires ResourceT_::erased_copy_construct; })
= default;
protocol(protocol&&)
requires(requires { requires ResourceT_::erased_move_construct; })
= default;
protocol& operator=(const protocol&)
requires(requires { requires ResourceT_::erased_copy_assign; })
= default;
protocol& operator=(protocol&&)
requires(requires { requires ResourceT_::erased_move_assign; })
= default;
protocol(const protocol& p_other)
requires(!requires {
requires ResourceT_::erased_copy_construct;
} && is_copy_constructible_type(^^ResourceT_))
: vtable_address_(p_other.vtable_address_)
{
if (*this) (*this)->__construct(std::forward<decltype(p_other)>(p_other).res_);
}
protocol(protocol&& p_other)
requires(!requires {
requires ResourceT_::erased_move_construct;
} && is_move_constructible_type(^^ResourceT_))
: vtable_address_(p_other.vtable_address_)
{
if (*this) (*this)->__construct(std::forward<decltype(p_other)>(p_other).res_);
if constexpr (requires { p_other.res_.valueless_after_move(); }) {
if (p_other.res_.valueless_after_move()) p_other.vtable_address_ = nullptr;
}
}
protocol& operator=(const protocol& p_other)
requires(!requires {
requires ResourceT_::erased_copy_assign;
} && is_copy_assignable_type(^^ResourceT_))
{
if (this != std::addressof(p_other)) {
if (*this) (*this)->__destroy();
vtable_address_ = p_other.vtable_address_;
if (*this) (*this)->__construct(std::forward<decltype(p_other)>(p_other).res_);
}
return *this;
}
protocol& operator=(protocol&& p_other)
requires(!requires {
requires ResourceT_::erased_move_assign;
} && is_move_assignable_type(^^ResourceT_))
{
if (this != std::addressof(p_other)) {
if (*this) (*this)->__destroy();
vtable_address_ = p_other.vtable_address_;
if (*this) (*this)->__construct(std::forward<decltype(p_other)>(p_other).res_);
if constexpr (requires { p_other.res_.valueless_after_move(); }) {
if (p_other.res_.valueless_after_move()) p_other.vtable_address_ = nullptr;
}
}
return *this;
}
~protocol()
requires(requires { requires ResourceT_::erased_destroy; })
= default;
~protocol()
requires(!requires { requires ResourceT_::erased_destroy; })
{
if (*this) (*this)->__destroy();
}
bool valueless_after_move() const noexcept
requires(!is_default_constructible_type(^^ResourceT_))
{
return vtable_address_ == nullptr;
}
constexpr explicit operator bool() const noexcept
{
if constexpr (requires {
res_.valueless_after_move();
} || is_default_constructible_type(^^ResourceT_))
return vtable_address_ != nullptr;
else
return true;
}
auto& resource() noexcept { return res_; }
auto& resource() const noexcept { return res_; }
private:
ResourceT_ res_;
vtable_address_t vtable_address_ = nullptr;
};Then the user write a memory resource.
protocol expects
emplace<T>,
construct<T>
(copy and move),
get<T>,
and
destroy<T>.
erased_* and
valueless_after_move()
are optional: they let the resource take over a special member or report
an empty state.
small_memory_resource_t stores
T in a small buffer, and otherwise
behind
std::indirect<T>.
The standard library could offer ready-made memory resources:
template <size_t Size_, size_t Alignment_>
struct small_memory_resource_t {
private:
static consteval bool __is_inplace(std::meta::info r)
{
return Size_ >= size_of(r) && Alignment_ >= alignment_of(r);
}
static_assert(__is_inplace(^^std::indirect<int>));
public:
template <typename T_>
auto emplace(auto&&... p_args) -> T_*
{
if constexpr (__is_inplace(^^T_))
return std::construct_at(
reinterpret_cast<T_*>(buffer_.data()),
std::forward<decltype(p_args)>(p_args)...
);
else
return std::addressof(**emplace<std::indirect<T_>>(
std::in_place,
std::forward<decltype(p_args)>(p_args)...
));
}
template <typename T_>
auto construct(const small_memory_resource_t& p_other) -> T_*
{
if constexpr (__is_inplace(^^T_))
return emplace<T_>(*p_other.get<T_>());
else
return std::addressof(**emplace<std::indirect<T_>>(*p_other.get<std::indirect<T_>>()));
}
template <typename T_>
auto construct(small_memory_resource_t&& p_other) -> T_*
{
if constexpr (__is_inplace(^^T_))
return emplace<T_>(std::move(*p_other.get<T_>()));
else
return std::addressof(
**emplace<std::indirect<T_>>(std::in_place, std::move(*p_other.get<T_>()))
);
}
template <typename T_>
auto get() -> T_*
{
if constexpr (__is_inplace(^^T_))
return std::launder(reinterpret_cast<T_*>(buffer_.data()));
else
return std::addressof(**get<std::indirect<T_>>());
}
template <typename T_>
auto get() const -> const T_*
{
if constexpr (__is_inplace(^^T_))
return std::launder(reinterpret_cast<const T_*>(buffer_.data()));
else
return std::addressof(**get<std::indirect<T_>>());
}
template <typename T_>
void destroy()
{
if constexpr (__is_inplace(^^T_))
std::destroy_at(get<T_>());
else
destroy<std::indirect<T_>>();
}
private:
alignas(Alignment_) std::array<std::byte, Size_> buffer_;
};Finally, the user defines animal
and uses protocol:
inline constexpr auto animal = stdx::make_interface<{
{
"make_a_sound",
{^^void(float) const},
[](auto&& p_self, float loudness) {
if constexpr (requires { p_self.make_a_sound(loudness); })
p_self.make_a_sound(loudness);
else
std::printf(
"%s make a sound %f\n",
identifier_of(remove_cvref(^^decltype(p_self))).data(),
loudness
);
},
},
}>;
struct dog {
void make_a_sound(float loudness) const { std::printf("%s %f\n", sound, loudness); }
dog() { std::puts("dog init"); }
dog(const dog&) { std::puts("dog copy"); }
dog(dog&&) { std::puts("dog move"); }
~dog() { std::puts("dog dtor"); }
private:
char sound[64] = "bark";
};using my_resource_t = small_memory_resource_t<2 * sizeof(void*), alignof(std::max_align_t)>;
auto p = protocol<animal, my_resource_t>{
dog{},
};
p->make_a_sound(4.2f);
{
auto copy = p;
auto _ = std::move(copy);
}
std::puts("scope exit");implementation output:
dog init
dog move
dog dtor
bark 4.200000
dog copy
dog move
dog dtor
dog dtor
scope exit
dog dtorproxyWith protocol, we can also
implement a reduced proxy.
proxy prefers to erase the
dereference operation, not the object, so the only new piece is an
adapter that makes the interface dispatch through
operator*.
The rest is protocol.
Firstly, we adapt the interface.
deref_iface rewrites every operation
of InterfaceV_ so that it first
dereferences the stored pointer-like and then invokes the original
operation on the pointee. proxy then
becomes an alias:
template <auto& InterfaceV_, stdx::static_string_t NameV_>
constexpr auto deref_helper = [](auto&& p_self, auto&&... p_args) -> decltype(auto) {
// for proxy example
// p_self is shared_ptr, optional, unique_ptr, T*
return stdx::interfaces::invoke<NameV_>(
std::forward_like<decltype(p_self)>(*InterfaceV_.ref(*p_self)),
std::forward<decltype(p_args)>(p_args)...
);
};
template <auto& InterfaceV_>
inline constexpr auto deref_iface = stdx::make_interface<{
std::from_range,
InterfaceV_.tokens() | std::views::transform([](stdx::interface_token p_token) {
return stdx::interface_token{
p_token.function_name(),
p_token.function_types(),
substitute(
^^deref_helper,
{
std::meta::reflect_object(InterfaceV_),
std::meta::reflect_constant(stdx::static_string_t{p_token.function_name()}),
}
),
};
})
}>;
template <auto& InterfaceV_, typename ResourceT_>
using proxy = protocol<deref_iface<InterfaceV_>, ResourceT_>;Then the user writes a resource. It must be move-only, because
std::unique_ptr
has to be accepted, and
small_memory_resource_t requires
copy. The only differences are the deleted copy operations and the
missing copy construct:
template <size_t Size_, size_t Alignment_>
struct move_only_small_memory_resource_t {
private:
static consteval bool __is_inplace(std::meta::info r)
{
return Size_ >= size_of(r) && Alignment_ >= alignment_of(r);
}
static_assert(__is_inplace(^^std::indirect<int>));
public:
move_only_small_memory_resource_t() = default;
move_only_small_memory_resource_t(move_only_small_memory_resource_t&&) = default;
move_only_small_memory_resource_t& operator=(move_only_small_memory_resource_t&&) = default;
move_only_small_memory_resource_t(const move_only_small_memory_resource_t&) = delete;
move_only_small_memory_resource_t& operator=(const move_only_small_memory_resource_t&) = delete;
template <typename T_>
auto emplace(auto&&... p_args) -> T_*
{
if constexpr (__is_inplace(^^T_))
return std::construct_at(
reinterpret_cast<T_*>(buffer_.data()),
std::forward<decltype(p_args)>(p_args)...
);
else
return std::addressof(**emplace<std::indirect<T_>>(
std::in_place,
std::forward<decltype(p_args)>(p_args)...
));
}
template <typename T_>
auto construct(move_only_small_memory_resource_t&& p_other) -> T_*
{
if constexpr (__is_inplace(^^T_))
return emplace<T_>(std::move(*p_other.get<T_>()));
else
return std::addressof(
**emplace<std::indirect<T_>>(std::in_place, std::move(*p_other.get<T_>()))
);
}
template <typename T_>
auto get() -> T_*
{
if constexpr (__is_inplace(^^T_))
return std::launder(reinterpret_cast<T_*>(buffer_.data()));
else
return std::addressof(**get<std::indirect<T_>>());
}
template <typename T_>
auto get() const -> const T_*
{
if constexpr (__is_inplace(^^T_))
return std::launder(reinterpret_cast<const T_*>(buffer_.data()));
else
return std::addressof(**get<std::indirect<T_>>());
}
template <typename T_>
void destroy()
{
if constexpr (__is_inplace(^^T_))
std::destroy_at(get<T_>());
else
destroy<std::indirect<T_>>();
}
private:
alignas(Alignment_) std::array<std::byte, Size_> buffer_;
};Finally, we add a few more animals (plus before
dog) and use
proxy:
struct ape {
~ape() { std::printf("%s", "ape dtor\n"); }
};
struct bee {
~bee() { std::printf("%s", "bee dtor\n"); }
};
struct cat {
~cat() { std::printf("%s", "cat dtor\n"); }
};using my_resource_t =
move_only_small_memory_resource_t<2 * sizeof(void*), alignof(std::max_align_t)>;
using animal_proxy_t = proxy<animal, my_resource_t>;
auto p = animal_proxy_t{std::make_shared<ape>()};
p->make_a_sound(4.2f);
p = animal_proxy_t{std::make_optional<bee>()};
p->make_a_sound(4.2f);
p = animal_proxy_t{std::make_unique<cat>()};
p->make_a_sound(4.2f);
dog d{};
{
auto _ = std::move(p);
p = animal_proxy_t{&d};
p->make_a_sound(4.2f);
}
std::puts("scope exit");implementation output:
ape make a sound 4.200000
ape dtor
bee dtor
bee dtor
bee make a sound 4.200000
bee dtor
cat make a sound 4.200000
dog init
bark 4.200000
cat dtor
scope exit
dog dtorape,
bee and
cat have no
make_a_sound member, so
animal takes its fallback branch:
the implementation on the interface side prints the type name and the
loudness. dog has the member and
uses it.
std::move_only_functionWith protocol, we can also make a
reduced
std::move_only_function.
Firstly, we write a resource. The steps are the same as before.
construct differs, because
move_only_function is valueless
after move, so the resource destroys the source object once it has been
relocated.
valueless_after_move()
reports this to protocol:
template <size_t Size_, size_t Alignment_>
struct move_only_function_resource_t {
private:
static consteval bool __is_inplace(std::meta::info r)
{
return Size_ >= size_of(r) && Alignment_ >= alignment_of(r);
}
static_assert(__is_inplace(^^std::indirect<int>));
public:
move_only_function_resource_t() = default;
move_only_function_resource_t(move_only_function_resource_t&&) = default;
move_only_function_resource_t& operator=(move_only_function_resource_t&&) = default;
move_only_function_resource_t(const move_only_function_resource_t&) = delete;
move_only_function_resource_t& operator=(const move_only_function_resource_t&) = delete;
template <typename T_>
auto emplace(auto&&... p_args) -> T_*
{
if constexpr (__is_inplace(^^T_))
return std::construct_at(
reinterpret_cast<T_*>(buffer_.data()),
std::forward<decltype(p_args)>(p_args)...
);
else
return std::addressof(**emplace<std::indirect<T_>>(
std::in_place,
std::forward<decltype(p_args)>(p_args)...
));
}
template <typename T_>
auto construct(move_only_function_resource_t&& p_other) -> T_*
{
if constexpr (__is_inplace(^^T_)) {
auto ptr = emplace<T_>(std::move(*p_other.get<T_>()));
p_other.destroy<T_>();
return ptr;
} else
return std::addressof(**construct<std::indirect<T_>>(std::move(p_other)));
}
template <typename T_>
auto get() -> T_*
{
if constexpr (__is_inplace(^^T_))
return std::launder(reinterpret_cast<T_*>(buffer_.data()));
else
return std::addressof(**get<std::indirect<T_>>());
}
template <typename T_>
auto get() const -> const T_*
{
if constexpr (__is_inplace(^^T_))
return std::launder(reinterpret_cast<const T_*>(buffer_.data()));
else
return std::addressof(**get<std::indirect<T_>>());
}
template <typename T_>
void destroy()
{
if constexpr (__is_inplace(^^T_))
std::destroy_at(get<T_>());
else
destroy<std::indirect<T_>>();
}
static consteval bool valueless_after_move() { return true; }
private:
alignas(Alignment_) std::array<std::byte, Size_> buffer_;
};Then we make move_only_function.
The interface function dispatches
the call, protocol manages lifetime.
The interface function take the
whole signature pack as one overload set
{^^SigTs_...}
with one implementation. Those facilities keeps
move_only_function short.
operator_t maps each signature’s
ref-qualifier to a
operator(),
only the
& and
&&
specializations are implemented, for brevity.
template <typename, typename>
struct operator_t;
template <typename BaseT_, bool NoexceptV_, typename RetT_, typename... ArgTs_>
struct operator_t<BaseT_, RetT_(ArgTs_...) & noexcept(NoexceptV_)> {
constexpr auto operator()(ArgTs_... p_args) & noexcept(NoexceptV_) -> RetT_
{
return std::invoke(
*static_cast<BaseT_*>(this)->__ref(),
std::forward<decltype(p_args)>(p_args)...
);
}
};
template <typename BaseT_, bool NoexceptV_, typename RetT_, typename... ArgTs_>
struct operator_t<BaseT_, RetT_(ArgTs_...) && noexcept(NoexceptV_)> {
constexpr auto operator()(ArgTs_... p_args) && noexcept(NoexceptV_) -> RetT_
{
return std::invoke(
std::move(*static_cast<BaseT_*>(this)->__ref()),
std::forward<decltype(p_args)>(p_args)...
);
}
};
template <typename... SigTs_>
struct move_only_function : operator_t<move_only_function<SigTs_...>, SigTs_>... {
using operator_t<move_only_function<SigTs_...>, SigTs_>::operator()...;
friend operator_t<move_only_function<SigTs_...>, SigTs_>...;
private:
static constexpr auto function = stdx::make_interface<{
{
"()",
{^^SigTs_...},
[](auto&& p_self, auto&&... p_args) -> decltype(auto) {
return std::invoke(
std::forward<decltype(p_self)>(p_self),
std::forward<decltype(p_args)>(p_args)...
);
},
},
}>;
using resource_t = move_only_function_resource_t<2 * sizeof(void*), alignof(std::max_align_t)>;
using protocol_t = protocol<function, resource_t>;
constexpr auto __ref() { return pro_.operator->(); }
constexpr auto __ref() const { return pro_.operator->(); }
public:
move_only_function() = default;
move_only_function(auto&& p_obj)
requires(!is_same_type(remove_cvref(^^decltype(p_obj)), ^^move_only_function))
: pro_(std::forward<decltype(p_obj)>(p_obj))
{}
explicit operator bool() const noexcept { return static_cast<bool>(pro_); }
private:
protocol_t pro_;
};Then we use it:
struct big_fn {
auto operator()(this auto&& p_self)
{
if constexpr (is_rvalue_reference_type(^^decltype(p_self)))
std::printf("big rv: %s\n", p_self.str);
else
std::printf("big lv: %s\n", p_self.str);
}
~big_fn() { std::puts("big_fn dtor"); }
char str[64] = "hello world";
};auto f = move_only_function<void() &, void() &&>{
[](this auto&& p_self) {
if constexpr (is_rvalue_reference_type(^^decltype(p_self)))
std::puts("small rv");
else
std::puts("small lv");
},
};
f();
std::move(f)();
f = {big_fn{}};
f();
std::move(f)();
{
auto _ = std::move(f);
if (!f) std::puts("f is empty");
}
std::puts("scope exit");implementation output:
small lv
small rv
big_fn dtor
big lv: hello world
big rv: hello world
f is empty
big_fn dtor
scope exitany_viewWe can also make a reduced
any_view. A complete one would take
flags for whether the erased view is copyable and for the minimal
iterator category. For brevity, this example fixes
any_view to a copyable view that is
at least a forward range, and stores views in place only: a view must be
trivially destructible and fit the buffer.
This is the first example to use two features of
protocol: the
erased_*
flags, which let a resource take over a special member, and the
derived-interface constructor.
Firstly, we write the interfaces for the iterator and the sentinel.
The iterator has __next,
__value and
__equal.
__equal takes the other iterator’s
resource, so both iterators must hold the same dynamic type. The
sentinel is a double dispatch:
iterator == sentinel
depends on both dynamic types, but a sentinel alone does not say which
iterator it pairs with. So
any_sentinel_base fixes the vtable
shape, and
any_sentinel<R, IterT_>::iface
overrides it once the iterator type is known. This is where the
derived-interface constructor is needed.
template <typename T_>
using value_ret_t = [:is_reference_type(^^T_) ? add_pointer(remove_reference(^^T_)) : ^^T_:];
template <typename T_, typename ResourceT_>
inline constexpr auto any_iterator = stdx::make_interface<{
{
"__next",
{^^void()},
[](auto&& p_self) { ++p_self; },
},
{
"__value",
{^^value_ret_t<T_>() const},
// gcc bug when return type is reference type, fixed in gcc17
[](auto&& p_self) -> decltype(auto) { return std::addressof(*p_self); },
},
{
"__equal",
{^^bool(const ResourceT_&) const},
[](auto&& p_self, const ResourceT_& p_other) {
return p_self == *p_other.template get<std::remove_cvref_t<decltype(p_self)>>();
},
},
}>;
template <typename ResourceT_>
inline constexpr auto any_sentinel_base = stdx::make_interface<{
{
"__sentinel_equal",
{^^bool(const ResourceT_&) const},
0,
},
}>;
template <typename ResourceT_, typename IterT_>
struct any_sentinel {
static constexpr auto iface = stdx::make_interface<{
any_sentinel_base<ResourceT_>,
{
"__sentinel_equal",
{^^bool(const ResourceT_&) const},
[](auto&& p_self, const ResourceT_& p_other) {
return *p_other.template get<IterT_>() == p_self;
},
},
}>;
};Then we write the resource for iterators. Iterators must be trivially
copyable, so the
erased_*
flags switch off the dynamic construct, assign and destroy paths, and
protocol uses the defaulted special
members.
template <size_t Size_, size_t Alignment_>
struct inplace_resource_t {
private:
static consteval bool __is_inplace(std::meta::info r)
{
return Size_ >= size_of(r) && Alignment_ >= alignment_of(r);
}
public:
template <typename T_>
auto emplace(auto&&... p_args) -> T_*
{
static_assert(__is_inplace(^^T_));
static_assert(is_trivially_copyable_type(^^T_));
return std::construct_at(
reinterpret_cast<T_*>(buffer_.data()),
std::forward<decltype(p_args)>(p_args)...
);
}
static constexpr bool erased_copy_construct = true;
static constexpr bool erased_move_construct = true;
static constexpr bool erased_copy_assign = true;
static constexpr bool erased_move_assign = true;
static constexpr bool erased_destroy = true;
template <typename T_>
auto get() -> T_*
{
return std::launder(reinterpret_cast<T_*>(buffer_.data()));
}
template <typename T_>
auto get() const -> const T_*
{
return std::launder(reinterpret_cast<const T_*>(buffer_.data()));
}
private:
alignas(Alignment_) std::array<std::byte, Size_> buffer_;
};
using any_iter_resource_t = inplace_resource_t<2 * sizeof(void*), alignof(std::max_align_t)>;then we make the iterator and the sentinel.
protocol keeps both short.
any_sentinel_t uses the
derived-interface constructor: the shape comes from
any_sentinel_base, the
implementation from
any_sentinel<R, IterT_>::iface:
struct any_sentinel_t;
template <typename T_>
struct any_iterator_t {
private:
friend any_sentinel_t;
using protocol_t = protocol<any_iterator<T_, any_iter_resource_t>, any_iter_resource_t>;
public:
using difference_type = std::ptrdiff_t;
using iterator_concept = std::forward_iterator_tag;
using value_type = std::remove_cvref_t<T_>;
using reference = T_;
any_iterator_t() = default;
any_iterator_t(auto p_iter)
requires(!is_same_type(^^decltype(p_iter), ^^any_iterator_t))
: pro_(std::move(p_iter))
{}
reference operator*() const
{
if constexpr (is_reference_type(^^reference))
return *(pro_->__value());
else
return pro_->__value();
}
any_iterator_t& operator++()
{
pro_->__next();
return *this;
}
any_iterator_t operator++(int)
{
auto l_tmp = any_iterator_t{*this};
++(*this);
return l_tmp;
}
friend bool operator==(const any_iterator_t& p_lhs, const any_iterator_t& p_rhs)
{
return p_lhs.pro_->__equal(p_rhs.pro_.resource());
}
private:
protocol_t pro_;
};
struct any_sentinel_t {
any_sentinel_t() = default;
template <typename IterT_>
any_sentinel_t(std::in_place_type_t<IterT_>, auto p_sent)
: pro_(any_sentinel<any_iter_resource_t, IterT_>::iface, std::move(p_sent))
{}
private:
using protocol_t = protocol<any_sentinel_base<any_iter_resource_t>, any_iter_resource_t>;
template <typename T_>
friend bool operator==(const any_iterator_t<T_>& p_lhs, const any_sentinel_t& p_rhs)
{
return p_rhs.pro_->__sentinel_equal(p_lhs.pro_.resource());
}
private:
protocol_t pro_;
};The view repeats the same steps. Interface with
__begin and
__end build the erased iterator and
sentinel from the concrete view:
template <typename T_>
inline constexpr auto any_range = stdx::make_interface<{
{
"__begin",
{^^any_iterator_t<T_>()},
[](auto&& p_self) { return any_iterator_t<T_>{p_self.begin()}; },
},
{
"__end",
{^^any_sentinel_t()},
[](auto&& p_self) {
return any_sentinel_t{
std::in_place_type<std::ranges::iterator_t<decltype(p_self)>>,
p_self.end()
};
},
},
}>;Then make the memory resource: a view is trivially destructible but
may have non-trivial copy and move, so only
erased_destroy is set. One
forwarding construct serves both
copy and move.
template <size_t Size_, size_t Alignment_>
struct any_view_resource_t {
private:
static consteval bool __is_inplace(std::meta::info r)
{
return Size_ >= size_of(r) && Alignment_ >= alignment_of(r);
}
public:
template <typename T_>
auto emplace(auto&&... p_args) -> T_*
{
static_assert(__is_inplace(^^T_));
static_assert(is_trivially_destructible_type(^^T_));
return std::construct_at(
reinterpret_cast<T_*>(buffer_.data()),
std::forward<decltype(p_args)>(p_args)...
);
}
template <typename T_>
auto construct(auto&& p_other) -> T_*
{
return emplace<T_>(std::forward_like<decltype(p_other)>(*p_other.template get<T_>()));
}
static constexpr bool erased_destroy = true;
template <typename T_>
auto get() -> T_*
{
return std::launder(reinterpret_cast<T_*>(buffer_.data()));
}
template <typename T_>
auto get() const -> const T_*
{
return std::launder(reinterpret_cast<const T_*>(buffer_.data()));
}
private:
alignas(Alignment_) std::array<std::byte, Size_> buffer_;
};Then make the any_view_t:
protocol and the interface do the
structural works, so any_view_t
stays short.
template <typename T_>
struct any_view_t : std::ranges::view_interface<any_view_t<T_>> {
using resource_t = any_view_resource_t<2 * sizeof(void*), alignof(std::max_align_t)>;
using protocol_t = protocol<any_range<T_>, resource_t>;
any_view_t() = default;
any_view_t(auto&& p_view)
requires(!is_same_type(^^decltype(p_view), ^^any_view_t))
: pro_(std::forward<decltype(p_view)>(p_view))
{}
auto begin() { return pro_->__begin(); }
auto end() { return pro_->__end(); }
private:
protocol_t pro_;
};
constexpr struct as_any_t : std::ranges::range_adaptor_closure<as_any_t> {
static constexpr auto operator()(std::ranges::viewable_range auto&& p_range)
{
return any_view_t<std::ranges::range_reference_t<decltype(p_range)>>{
std::views::all(std::forward<decltype(p_range)>(p_range))
};
}
} as_any{};Finally we use it:
auto vec = std::vector{1, 2, 3, 4, 5};
auto views = std::vector{
vec | as_any,
vec | std::views::drop(2) | as_any,
vec | std::views::filter([](int x) { return x % 2 == 0; }) | as_any,
vec | std::views::take_while([](int x) { return x < 4; }) | as_any,
};
std::println("{}", views);
std::println("{}", views);
*views[0].begin() = 5;
std::println("{}", views);implementation output:
[[1, 2, 3, 4, 5], [3, 4, 5], [2, 4], [1, 2, 3]]
[[1, 2, 3, 4, 5], [3, 4, 5], [2, 4], [1, 2, 3]]
[[5, 2, 3, 4, 5], [3, 4, 5], [2, 4], []]Many type-erasure implementations need a vtable builder. Some expose
it to users (proxy,
protocol), and some keep it internal
(std::move_only_function).
Among these, only this paper’s
non-intrusive interface
satisfies all of the following at once:
interface_token range.override
semantics.These features carry the examples above. Some removed a great deal of awkwardness, and others were close to irreplaceable.
If
non-intrusive interface
overlapped with the vtable-builder part of these facilities, the effect
on users would vary. For
std::move_only_function,
the observable API would not change. For
proxy and
protocol, it would change slightly,
but the model would stay the same, and users would gain a vtable builder
with more features. The features are practical in the scenarios shown
above, so they are worth adding.
The overlap barely touches the owning part. The two sides need only a
light contract. The interface does not need to know that any owning
facility exists, and an owning facility needs only a thin adaptation
layer on top of it. The examples show this:
protocol,
proxy,
std::move_only_function
and any_view are all built on the
same interface. Each adds only what is owning: where the object lives,
and how it is copied, moved and destroyed. Users can write their own
owning wrappers the same way.
The two designs can therefore proceed in parallel. Both are heavy designs, and parallel progress lets each get the design effort it needs, instead of being rushed into trade-offs by a shared schedule.
This section walks through small examples of basic use. Each one exercises a part of the proposed design, so together they sketch its shape.
animal is an interface defined
with make_interface by user.
cat and
dog are concrete types. The two
sides do not know each other.
The user supplies each operation of
animal as three parts: a name
(print_name), a signature reflection
(^^void() const),
and a free function object that implements it.
animal.ref(x)
returns a view of x: lightweight,
trivially copyable and non-owning, analogous to
std::reference_wrapper.
It behaves like a raw pointer, so
view->print_name()
calls the operation like the way a raw pointer calls a member function.
In this implementation it is a classic fat pointer: an object pointer
plus a vtable address.
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));implementation output:
current: cat
current: dogInterfaces 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();implementation output:
current: cat
cat is moving
current: dog
dog is movingAn interface reference wrapper can be converted to another interface reference wrapper whenever the source interface’s composition set includes the target interface. This is a nominal relationship between interfaces as declared: two interfaces that expose the same operations by coincidence, without one having been composed from the other, are not convertible.
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)>);implementation 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 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();implementation 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 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();implementation 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.
An operation can have several candidate signatures. The call selects among them by ordinary overload resolution.
Here value has two signatures: a
const form
that returns the value, and a
non-const
form that returns a pointer. One implementation serves both. It branches
on the constness of p_self:
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);implementation output:
42
24
24const_view is a view of a
const int,
so value()
resolves to
int() const.
view is a view of a
non-const
int, so
value()
resolves to
int*(),
and writing through the pointer changes
i.
const_view then reads
24, which
shows it refers to the same object.
The signature list above is written by hand. It can also be any range
that satisfies
std::meta::reflection_range,
such as 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);
},
},
}>;The signatures do not have to be declared together. Each signature can be its own token with the same name and the tokens form one overload set:
constexpr auto fn = [](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);
};
constexpr auto accessible = stdx::make_interface<{
{
"value",
{^^int*()},
fn,
},
{
"value",
{^^int() const},
fn,
},
}>;This is what makes overloads work with token ranges: signatures can be generated and each wrapped as a token:
constexpr auto accessible = stdx::make_interface<{
std::from_range,
std::vector{^^int*(), ^^int() const} | std::views::transform([](std::meta::info r) {
return stdx::interface_token{
"value",
{r},
fn,
};
}),
}>;Given
auto mut_view = accessible.ref(x),
mut_view->value()
calls the
non-const
lvalue form. How do we call the
const or
rvalue form?
Dereferencing a view yields an interface accessor, a reference-like
object. This works like dereferencing a raw pointer:
*p on an
int*
gives an
int&,
and on a
const int*
gives a
const int&.
The qualification comes from what was dereferenced, not from whether the
pointer itself is
const.
*interface.ref(x)
is a
non-const
lvalue reference to an accessor.*interface.cref(x)
is a const
lvalue reference to an accessor.std::move
and
std::as_const
then change the qualification of that reference, as for any
reference.The accessor’s qualification takes part in overload selection the way
the implicit object argument does for
& and
&&
member functions.
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);implementation output:
42
24
24So
view->value()
and
std::move(*view).value()
select different overloads of the same operation,
int*() &
and
int() &&,
on the same underlying object.
An interface operation may be defined in terms of the interface
accessor rather than in terms of the type of the observed object.
stdx::this_interface_accessor
is a concept that identifies such an accessor parameter.
A function whose parameter constrains
stdx::this_interface_accessor
receives the interface accessor as its representation of
*this.
In particular, the parameter is not deduced as the underlying observed
type. Instead, it denotes an accessor. Consequently, its value category
and cv-qualification are those of the accessor at the call site, and the
function may use the interface’s other operations through that
accessor.
For example, an interface may expose a primitive operation,
value_impl, and define value in
terms of that operation:
constexpr auto accessible = stdx::make_interface<{
{
"value_impl",
std::vector{^^int() &&, ^^int*() & },
[](std::convertible_to<int> auto&& p_self) {
if constexpr (is_rvalue_reference_type(^^decltype(p_self)))
return p_self;
else
return std::addressof(p_self);
},
},
{
"value",
std::vector{^^int() &&, ^^int*() & },
[](stdx::this_interface_accessor auto&& p_this) {
return std::forward<decltype(p_this)>(p_this).value_impl();
},
},
}>;int i = 42;
auto view = accessible.ref(i);
std::println("{}", std::move(*view).value());
*view->value() = 24;
std::println("{}", std::move(*view).value());
std::println("{}", i);implementation 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());implementation 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());implementation 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 wrapper, while a reference to
stdx::virtual_self
denotes an interface accessor. The distinction corresponds to the
distinction between an interface reference wrapper 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);implementation 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);implementation 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
wrapper 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);implementation output:
current health: 42
current health: 18The same mechanism applies to return types. An interface operation may return an interface reference wrapper, and an overriding operation may return a more-derived interface reference wrapper. 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();implementation 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 wrapper
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();implementation 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)));implementation 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)));implementation 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;implementation 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;implementation 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;implementation 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);implementation 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));implementation 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));implementation 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));implementation 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}]);implementation 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 wrapper 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 wrapper 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()));implementation 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()));implementation 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()));implementation 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 wrapper’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 wrapper, 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);implementation 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 wrapper 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.
Some callers cannot consume C++ interface machinery: C clients, or any language that can only consume a C ABI. This example exposes an interface to them, using a small game-library example. The C side sees plain handle types and tables of function pointers, and nothing of the C++ interface.
Firstly, on the C side, the user defines the handle types:
player_handle_t,
player_const_handle_t and
damage_handle_t. A handle is a plain
aggregate. Its first member is a slot that can hold the vtable address.
Its second is a
void pointer
to the type-erased object, and the pointee’s qualification matters.
Beyond these two, the user is free to name the members and add others
the interface does not know about.
Secondly, also on the C side, the user defines the method tables:
read_methods_t and
write_methods_t. The interface fills
them in. It walks the table’s members and, for each function pointer
whose signature contains a recognized handle and whose name matches an
operation, exports that operation into the slot.
Thirdly, the user declares the functions that fetch the method table
addresses (read_methods_address,
write_methods_address) and the
lifetime functions that manage a handle
(create_player,
destroy_player,
deallocate_player…).
as_const is only a convenience
helper for this example.
// .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);
inline static 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
}
#endifFourthly, in the C++ translation unit, the user defines the
interfaces damage and
player, and calls
cabi_register_handle to bind each
handle type to its interface. Registration is what makes a handle type
valid for
cabi_create_handle<HandleT_>
and cabi_destroy_handle,
cabi_deallocate_handle, and lets
cabi_methods_address<TableT>
find the slots to fill:
// .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);
}
}Finally, the C client uses it:
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);implementation output:
id: 42
health: 1000
health after damage: 900
health after assignment: 1900
new id: 24
new health: 3000The design is settled in outline, but one detail may have a better
answer. One handle type is registered per qualification an interface
distinguishes (player_handle_t and
player_const_handle_t). Is there a
contract that avoids this?
Every example so far assumes the user controls at least one side of the pairing: the type, the interface, or both. A harder case is when the user controls neither. The concrete type comes from one party and the interface from another, and the user needs them to work together without modifying either. The interface author also did not reserve a place for an override. Something must supply, from outside both, an implementation of an operation for a type that was never designed with that operation or interface in mind.
stdx::extension
is that mechanism. A specialization of it supplies an alternative
implementation of one operation for one concrete type. It receives the
interface’s original implementation
(ImplFnV_), so it can delegate to
it:
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 <std::meta::info IfaceTypeV_, typename LookupSigT_, auto ImplFnV_>
constexpr auto stdx::extension<mimic, IfaceTypeV_, "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();implementation output:
current: cat
current: mimic
play as dogmimic is the third-party type and
animal is the third-party interface.
Neither is modified. The extension for
mimic calls the original
implementation, then adds behavior on top. It could also replace the
original outright.
Three concerns count against
stdx::extension.
First, attaching an implementation to a type after the fact, from a
third location, has drawn debate in other languages with comparable
features, such as Swift’s
extensions. This paper does not try
to resolve that debate. It notes that the concern is not specific to
this design.
Second, if the extended operation takes
stdx::this_interface_accessor,
the extension can no longer call
ImplFnV_ directly. It must go
through a helper,
interface.invoke(fn, x, args...),
which accepts either a self-deducing or a plain
fn:
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 <std::meta::info IfaceTypeV_, typename LookupSigT_, auto ImplFnV_>
constexpr auto stdx::extension<mimic, IfaceTypeV_, "print_name", LookupSigT_, ImplFnV_> =
[](auto&& p_self) {
[:IfaceTypeV_:] ::invoke(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();implementation output:
current: cat
current: mimic
play as dogThe observable result is the same as before. The cost is an extra helper in the facility, which makes it more complex.
Third, override already covers most of this case: the user derives an
interface from the third-party one and overrides the operation.
stdx::extension
matters only when override cannot be used, for example when the
interface’s author did not leave an operation overridable and the user
cannot change that. It is an escape hatch, not a necessary facility.
Whether that narrow case justifies a second facility, given the concerns
above, is left open. Reviewer input is welcome.
In the examples so far, an interface is declared with a complete token range. An interface can also be declared incomplete, by giving a type reflection in place of the range. This is useful when a forward declaration is needed, such as for interfaces that refer to each other.
Here red,
yellow and
green form a cycle: each one’s
cast returns a view of the next.
color is a plain base interface.
Each color is declared first, against a forward-declared token provider
fwd_tokens_t<N>,
and the provider is defined afterwards:
constexpr auto color = stdx::make_interface<{
{
"cast",
{^^stdx::virtual_self*()},
0,
},
{
"draw",
{^^void() const},
0,
},
}>;
template <size_t>
struct fwd_tokens_t;
constexpr auto red = stdx::make_interface<{std::from_range, ^^fwd_tokens_t<0>}>;
constexpr auto yellow = stdx::make_interface<{std::from_range, ^^fwd_tokens_t<1>}>;
constexpr auto green = stdx::make_interface<{std::from_range, ^^fwd_tokens_t<2>}>;
constexpr auto colors = std::array{^^red, ^^yellow, ^^green};
template <size_t IdxV_>
struct fwd_tokens_t : decltype([] {
return std::vector<stdx::interface_token>{
color,
{
"cast",
{^^stdx::virtual_self_of<colors[(IdxV_ + 1) % colors.size()]>*()},
[](auto&& p_self) -> auto&& { return ++p_self; },
},
{
"draw",
{^^void() const},
[](auto&& p_self) {
std::println("current: {}, value: {}", [:colors[IdxV_]:].name(), p_self);
},
},
};
}) {};int i = 42;
auto view = color.ref(red.ref(i));
static_assert(std::same_as<decltype(red.ref(i)->cast()), decltype(yellow.ref(i))>);
while (i <= 45) {
view->draw();
view = view->cast();
}implementation output:
current: red, value: 42
current: yellow, value: 43
current: green, value: 44
current: red, value: 45Declaring an interface does not build its reference wrapper type,
decltype(interface.ref(x)).
The type is built at the first use of
interface.ref(x).
The token provider T therefore only
has to be complete, and invocable as
T{}(),
before that first use.
The same holds for an operation’s implementation. If the
implementation is given as a type reflection, such as
fwd_draw_t<IdxV_>,
instead of a constant reflection, the interface builds the value with
std::meta::reflect_constant(fwd_draw_t<IdxV_>{})
at first use. The function can then be defined after the interface is
declared and before it is used:
template <size_t IdxV_>
struct fwd_draw_t;
template <size_t IdxV_>
struct fwd_tokens_t : decltype([] {
return std::vector<stdx::interface_token>{
color,
{
"cast",
{^^stdx::virtual_self_of<colors[(IdxV_ + 1) % colors.size()]>*()},
[](auto&& p_self) -> auto&& { return ++p_self; },
},
{
"draw",
{^^void() const},
^^fwd_draw_t<IdxV_>,
},
};
}) {};
template <size_t IdxV_>
struct fwd_draw_t : decltype([](auto&& p_self) {
std::println("current: {}, value: {}", [:colors[IdxV_]:].name(), p_self);
}) {};Because the implementation may be incomplete, signatures may also use incomplete types as return or parameter types:
struct A;
struct fn;
constexpr auto make_lazy = stdx::make_interface<{
{
"make",
{^^A() const},
^^fn,
},
}>;
struct A {
int value;
};
struct fn : decltype([](auto&& p_self) { return A{p_self}; }) {};int i = 42;
std::same_as<A> auto a = make_lazy.ref(i)->make();
std::printf("%d", a.value);implementation output:
42make_interface as a token sequence
macroP4380R0 introduces token sequence
macros, which allow an interface to be declared in a class-like syntax.
Token sequences are not part of the standard, so whether
make_interface should become a macro
is left open. Below is what a macro would fix.
The macro form reads like a class hierarchy:
Before
|
After
|
|---|---|
|
|
Copying an interface also becomes a plain declaration:
Before
|
After
|
|---|---|
|
|
For current design, an interface knows its own variable, and from it, its name:
constexpr auto drawable = stdx::make_interface<...>;
static_assert(^^drawable == drawable.variable());
static_assert("drawable" == drawable.name());Knowing its variable also makes an interface a singleton, which is the natural shape: one interface type has one object, and copying is not allowed. Two interfaces built from the same tokens must still be two interfaces:
constexpr auto tokens = std::array<stdx::interface_token, N>{...};
inline constexpr auto drawable_0 = stdx::make_interface<{std::from_range, tokens}>;
inline constexpr auto drawable_1 = stdx::make_interface<{std::from_range, tokens}>;The direct way is to pass the variable’s reflection:
inline constexpr auto drawable =
stdx::__interface_instance_type<^^drawable, {std::from_range, tokens}>{};but spelling the reflection by hand is too noisy for users, therefore we deduce the variable from the declaration scope. Below is the minimal reproduction of this technique:
struct interface_instance_context_t {
std::meta::access_context ctx_;
std::meta::info tag_;
consteval interface_instance_context_t(std::meta::access_context p_ctx, auto p_tag)
: ctx_(p_ctx), tag_(^^decltype(p_tag))
{}
};
template <std::monostate Dummy, interface_instance_context_t CtxV_>
struct interface_instance_t {
static consteval auto variable() -> std::meta::info
{
const auto l_scope = CtxV_.ctx_.scope();
for (const auto l_mem : members_of(l_scope, std::meta::access_context::unchecked())) {
if (is_variable(l_mem)
&& is_same_type(remove_cv(type_of(l_mem)), ^^interface_instance_t)) {
return l_mem;
}
}
throw;
}
static consteval auto name() -> std::string_view { return identifier_of(variable()); }
};
template <
std::monostate Dummy,
std::meta::access_context CtxV_ = std::meta::access_context::current(),
auto V_ = [] {}
>
constexpr auto make_interface = interface_instance_t<Dummy, {CtxV_, V_}>{};
constexpr auto drawable = make_interface<{}>;
static_assert(^^drawable == drawable.variable());
static_assert("drawable" == drawable.name());It works in C++26, but it has costs. For example, an interface that
needs
.name()
or
.variable()
cannot be a variable template, because the scope walk cannot find it. It
has to be wrapped in a class template.
With macro we can write:
stdx::make_interface!{
drawable {
tokens;
};
};
// generate:
// inline constexpr auto drawable =
// stdx::__interface_instance_type<^^drawable, {std::from_range, tokens}>{};The generated code contains
^^drawable,
so no scope machinery is needed.
stdx::virtual_self_of<I>
is an incomplete type used as a tag. It stands for “an object accessed
through interface I”. A pointer or
reference to it gives raw-pointer and reference semantics. Given an
interface drawable:
Type
|
Produced by
|
|---|---|
stdx::virtual_self_of<drawable>* |
drawable.ref(x) |
const stdx::virtual_self_of<drawable>* |
drawable.cref(x) |
stdx::virtual_self_of<drawable>& |
*drawable.ref(x) |
stdx::virtual_self_of<drawable>&& |
std::move(*drawable.ref(x)) |
const stdx::virtual_self_of<drawable>& |
*drawable.cref(x) |
The tag can also be passed to standard templates that take a type,
similar to Rust’s
Box<dyn Trait>.
Type
|
Produced by
|
|---|---|
std::unique_ptr<stdx::virtual_self_of<drawable>> |
drawable.make_unique(x) |
std::shared_ptr<stdx::virtual_self_of<drawable>> |
drawable.make_shared(x) |
std::indirect<stdx::virtual_self_of<drawable>> |
drawable.make_indirect(x) |
std::optional<stdx::virtual_self_of<drawable>&> |
drawable.make_optional(drawable.ref(x)) |
This is an open question: should the standard templates accept the tag? We have examples to sketch a possible implementation. Its shape seems reasonable, but it is not a full proof.
The example below shows a reduced
std::unique_ptr<stdx::virtual_self_of<drawable>>.
unique_ptr can adopt an existing
interface reference wrapper, so it may not know the concrete type. The
interface must therefore know how to destroy and deallocate it.
default_delete calls the reserved
lifetime operation
~T and the
delete
operation.
Note that: - It writes unique_ptr
and default_delete as local
templates with the standard shape. A real design would specialize the
standard ones. - Many features are omitted for brevity. -
be_virtual_self is a placeholder for
a constraint that matches
virtual_self_of<I>.
- unique_ptr needs the interface
behind the tag. This paper gives users no way to extract it from
stdx::virtual_self_of<I>,
since the tag is meant to be handled by the standard library only. A
future revision may add one.
template <typename>
concept be_virtual_self = true; // dummy
template <typename>
struct default_delete;
template <typename T_, typename = default_delete<T_>>
struct unique_ptr;
template <be_virtual_self T_>
struct default_delete<T_> {
static constexpr void operator()(auto p_ptr)
{
if constexpr (requires { p_ptr.delete_new(); })
p_ptr.delete_new();
else {
p_ptr.destroy();
p_ptr.deallocate();
}
}
};
template <be_virtual_self T_, typename DT_>
struct unique_ptr<T_, DT_> {
private:
// exposition only: the library extracts the interface from the tag
// users have no such access
using interface_t = [:template_arguments_of(^^T_).front():];
public:
using pointer = [:interface_t::pointer_type():];
using element_type = T_;
using deleter_type = DT_;
unique_ptr() = default;
explicit unique_ptr(auto* p_ptr) noexcept : ptr_(interface_t::ref(*p_ptr)) {}
unique_ptr(const unique_ptr&) = delete;
unique_ptr& operator=(const unique_ptr&) = delete;
unique_ptr(unique_ptr&& p_other) noexcept
: ptr_(p_other.release()), deleter_(std::move(p_other.deleter_))
{}
unique_ptr& operator=(unique_ptr&& p_other) noexcept
{
if (this != std::addressof(p_other)) {
reset(p_other.release());
deleter_ = std::move(p_other.deleter_);
}
return *this;
}
auto get() const noexcept -> pointer { return ptr_; }
auto release() noexcept -> pointer
{
auto p = ptr_;
ptr_ = nullptr;
return p;
}
void reset(pointer p_new = nullptr) noexcept
{
auto old = ptr_;
ptr_ = p_new;
if (old) deleter_(old);
}
auto operator->() const noexcept -> pointer { return ptr_; }
auto& operator*() const noexcept { return *ptr_; }
~unique_ptr()
{
if (ptr_) deleter_(ptr_);
}
private:
pointer ptr_;
[[no_unique_address]] deleter_type deleter_;
};For the user’s interface,
~T and
delete are
the lifetime operations. emplace and
draw are plain user operations that
complete a lifetime example. emplace
constructs into raw memory, and draw
is
&&-qualified
to show that qualifiers pass through:
constexpr auto drawable = 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::puts("destroy");
std::destroy_at(std::addressof(p_self));
},
},
{
"delete",
{^^void()},
[](auto&& p_self) {
std::puts("deallocate");
using allocator_type = std::allocator<std::remove_cvref_t<decltype(p_self)>>;
allocator_type{}.deallocate(std::addressof(p_self), 1);
},
},
{
"draw",
{^^void() && },
[](auto&& p_self) {
static_assert(is_rvalue_reference_type(^^decltype(p_self)));
std::printf("character %d draw\n", p_self.id);
},
},
}>;
struct character {
int id;
character(int i) : id(i) { std::printf("character %d init\n", id); }
~character() { std::puts("character dtor"); }
};Finally, we use it:
using uptr_t = unique_ptr<stdx::virtual_self_of<drawable>>;
character* ptr = std::allocator<character>{}.allocate(1);
auto uptr = uptr_t{ptr};
uptr->emplace(42);
std::printf("id: %d\n", ptr->id);
std::move(*uptr).draw();
{
auto _ = std::move(uptr);
}
std::puts("scope exit");implementation output:
character 42 init
id: 42
character 42 draw
destroy
character dtor
deallocate
scope exitWe can also implement a reduced
std::indirect<stdx::virtual_self_of<drawable>>.
Many features are omitted for brevity.
std::indirect
has value semantics and knows the concrete type at construction, so the
user’s interface does not carry lifetime operations. Instead,
indirect extends the user’s
interface with its own: we build
base from the user’s interface. It
takes the user’s interface as a base, and adds
__construct (copy and move
overloads, allocating and constructing a new object) and
__delete (destroying and
deallocating). Both take the allocator, which is how an allocator-aware
indirect works.
template <typename>
concept be_virtual_self = true; // dummy
template <be_virtual_self VT_, typename AllocT_ = std::allocator<void>>
struct indirect {
private:
static constexpr auto interface_type_v = template_arguments_of(^^VT_).front();
using interface_t = [:interface_type_v:];
static constexpr auto base = stdx::make_interface<{
interface_type_v,
{
"__construct",
{
^^stdx::virtual_self*(const AllocT_&) const&,
^^stdx::virtual_self*(const AllocT_&) &&,
},
[](auto&& p_self, const AllocT_& p_alloc) -> auto&& {
using alloc_t = std::allocator_traits<AllocT_>::
template rebind_alloc<std::remove_cvref_t<decltype(p_self)>>;
auto l_alloc = alloc_t{p_alloc};
auto* p = l_alloc.allocate(1);
std::allocator_traits<alloc_t>::construct(
l_alloc,
p,
std::forward<decltype(p_self)>(p_self)
);
return *p;
},
},
{
"__delete",
{^^void(const AllocT_&) noexcept},
[](auto&& p_self, const AllocT_& p_alloc) {
using alloc_t = std::allocator_traits<AllocT_>::
template rebind_alloc<std::remove_cvref_t<decltype(p_self)>>;
auto l_alloc = alloc_t{p_alloc};
auto* p = std::addressof(p_self);
std::allocator_traits<alloc_t>::destroy(l_alloc, p);
l_alloc.deallocate(p, 1);
},
},
}>;
public:
using value_type = VT_;
using allocator_type = AllocT_;
using pointer = [:base.pointer_type():];
using const_pointer = [:base.const_pointer_type():];
explicit indirect() = default;
explicit indirect(auto&& p_obj)
requires(!is_same_type(remove_cvref(^^decltype(p_obj)), ^^indirect))
{
ptr_ = std::forward_like<decltype(p_obj)>(*base.ref(p_obj)).__construct(alloc_);
}
indirect(const interface_t&, auto&& p_obj) : indirect(std::forward<decltype(p_obj)>(p_obj)) {}
indirect(const indirect& p_other)
: alloc_(
std::allocator_traits<AllocT_>::select_on_container_copy_construction(p_other.alloc_)
)
{
if (!p_other.valueless_after_move()) ptr_ = p_other->__construct(alloc_);
}
indirect(indirect&& p_other) noexcept
: alloc_(std::move(p_other.alloc_)), ptr_(std::exchange(p_other.ptr_, nullptr))
{}
// omit assignment for brevity
~indirect()
{
if (!valueless_after_move()) ptr_->__delete(alloc_);
}
bool valueless_after_move() const noexcept { return ptr_ == nullptr; }
auto operator->() const noexcept -> const_pointer { return ptr_; }
auto operator->() noexcept -> pointer { return ptr_; }
auto&& operator*() const& noexcept { return std::as_const(*ptr_); }
auto&& operator*() & noexcept { return *ptr_; }
auto&& operator*() const&& noexcept { return std::as_const(std::move(*ptr_)); }
auto&& operator*() && noexcept { return std::move(*ptr_); }
private:
[[no_unique_address]] allocator_type alloc_;
pointer ptr_;
};
template <typename IfaceT_>
indirect(const IfaceT_&, auto&&)
-> indirect<stdx::virtual_self_of<^^IfaceT_>, std::allocator<void>>;The user defines a plain interface.
draw has
& and
&&
overloads to show that value category passes through
operator*:
constexpr auto drawable = stdx::make_interface<{
{
"draw",
{^^void() &&, ^^void() & },
[](auto&& p_self) {
if constexpr (is_rvalue_reference_type(^^decltype(p_self)))
std::printf("rv character %d draw\n", p_self.id);
else
std::printf("lv character %d draw\n", p_self.id);
},
},
}>;
struct character {
int id;
character(int i) : id(i) { std::printf("character %d init\n", id); }
character(const character& p_other) : id(p_other.id) { std::puts("character copy"); }
character(character&& p_other) : id(p_other.id) { std::puts("character move"); }
~character() { std::puts("character dtor"); }
};Finally, we use it.
indirect{drawable, character{42}}
goes through the deduction guide, which takes the interface object and
deduces the tag:
using drawable_indirect_t = indirect<stdx::virtual_self_of<drawable>>;
std::same_as<drawable_indirect_t> auto i = indirect{drawable, character{42}};
i->draw();
(*std::move(i)).draw();
{
auto copy = i;
auto _ = std::move(copy);
}
std::puts("scope exit");implementation output:
character 42 init
character move
character dtor
lv character 42 draw
rv character 42 draw
character copy
character dtor
scope exit
character dtorHere an interface is a
constexpr
variable, like
std::views::transform.
Related proposals declare it as a type:
Paper
|
Declaration
|
|---|---|
| P4148R2 protocol/P4380R0 DynRef | type |
| P3086R5 proxy | type |
| this paper | variable |
A variable is a value. That gives it two things a type lacks: member operators, and use as an argument.
A variable can use operators directly, such as
operator->.
A type needs an extra expression to produce a value first.
variable
|
type
|
|---|---|
|
|
Member operators are also the extension point for richer expressions:
For example, interfaces combine in expressions, and a concept can take the result:
template<typename, auto>
concept planet_with = ...;
constexpr auto animal = stdx::make_interface<...>;
constexpr auto plant = stdx::make_interface<...>;
constexpr auto bacteria = stdx::make_interface<...>;
// planet that have animal, and lack plant or lack bacteria (or both)
planet_with<animal & (!plant | !bacteria)> auto my_planet = ...;A variable can be passed as an argument tag. Combined with the operators, the tag can have richer expressions:
template<...>
struct planet {...};
template<typename IfaceT_, ...>
planet(const IfaceT_&, ...) -> planet<...>;
// planet<...> is deduced from the interface expression via CTAD
// the tag selects the constructor
auto my_planet = planet{ animal & (!plant | !bacteria), ... };If P4380R0’s token sequence
macros are adopted, they can be member operators. Their convenience then
matters more. On a variable they are called directly. On a type they
need a named object:
variable
|
type
|
|---|---|
|
|
A variable is usable in annotation, to make
P4380R0’s push-based
customization:
variable
|
type
|
|---|---|
|
|
However, variable brings some awkwardness.
Type traits take a splice:
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&>())))
);
using t = [:animal.const_pointer_type():];
void func(typename[:animal.const_pointer_type():] arg);A header needs
inline constexpr.
That is an ODR hazard.
inline constexpr auto animal = stdx::make_interface<...>;A variable with a deduced type cannot be declared before its definition. A type can.
constexpr auto drawable; // invalidForward declaration is still possible through an incomplete interface. It needs an extra type and a separate definition, so it is more roundabout than a type declaration. A type-based form might give it a better shape. The author has not explored this direction yet.
The implementation is variable-based. The author is unsure which users prefer, so this is left open. The design keeps room to port to a type-based form if needed.
delete ptr-style
destroy-and-deallocateC++’s
delete ptr
expression destroys an object and deallocates its storage in one step.
operator delete,
which the expression calls after the destructor, only deallocates. This
paper’s reserved name
"delete"
mirrors
operator delete:
it is deallocation only, and kept separate from
"~T",
which is destruction only. The two can then be composed, overridden and
elided independently.
That leaves one question open: how should a caller express the
combined act of
delete ptr
when the object was obtained through
new?
This section sketches one mechanism. It is still awkward in the author’s view, and better designs are welcome.
"delete new"
is a third reserved lifetime name. Declaring an operation under it
generates a member function
delete_new on the interface
reference wrapper, the same way
"~T"
generates destroy and
"delete"
generates deallocate. Its
implementation here is a
delete
expression on the address of the underlying object:
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()));implementation output:
destroy"~T"
and
"delete"
each name one familiar C++ concept: a destructor and
operator delete.
"delete new"
is a two-word phrase invented for this paper. It evokes “the delete that
pairs with
new”, but a
reader has to be told that.
The question: Is a combined reserved operation worth offering at all?
"~T"
followed by
"delete"
already gives the same effect. A user can also declare it as a plain
operation, with no reserved name:
constexpr auto lifetime_base = stdx::make_interface<{
{
"user_delete",
{^^void() noexcept},
[](auto&& p_self) { delete std::addressof(p_self); },
},
}>;auto ptr = new character{42};
auto view = lifetime_base.ref(*ptr);
view->user_delete();The reserved name buys only the generated member
delete_new on the reference wrapper.
The plain form is called as
view->user_delete(),
like any other operation.
If it is worth offering, is there a better spelling? Options include a different reserved name, a different generated member name, or a different mechanism entirely.
In ordinary C++, a qualified call,
obj.Base::method(),
invokes a specific base’s version of a virtual member function. It
bypasses dynamic dispatch and needs neither a second object nor a cast.
This paper has no comparable notation.
One possible scheme is
static_ref. It builds an interface
reference wrapper that resolves operations to the declarations of one
interface, ignoring overrides in more derived ones:
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();implementation output:
current animal: cat
current: cat
current: catThe first call is the established behavior: converting an
animal reference wrapper down to
named keeps
animal’s override, so it prints
current animal: cat.
The second and third calls use
named.static_ref.
The result resolves print_name to
named’s own declaration, whether the
argument already carries animal’s
override or is built from an animal
reference wrapper. This has the effect of a qualified call: it pins a
call to one declaration in a hierarchy where another would normally be
selected.
static_ref is needed to produce a
reference wrapper without overrides. If the only goal is a qualified
call, the interface could instead be passed to the call itself, which
names the call and its target together, as
Base::method()
does:
named_view->print_name(named); // alternative
named.static_ref(named_view)->print_name(); // currentNeither form is satisfying. The name
static_ref is unusual and does not
suggest its purpose. Passing the interface as a dispatch tag risks
ambiguous overloads in corner cases. The design is left open, and a
better one is welcome.
Ordinary C++ offers run-time type identification through
dynamic_cast
and typeid.
This paper offers a similar facility,
get_underlying_if<T>,
found by ADL or reachable as
stdx::interfaces::get_underlying_if.
It tests whether the object behind a dereferenced interface reference
wrapper is a T. It returns a pointer
to the object on success and a null pointer on failure, the same shape
as
dynamic_cast<T*>.
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();implementation output:
current: cat
current: cat
current: dog
current: dogA reference wrapper’s vtable address is not stable across
conversions. A reference upcast from a wider, differently composed
interface may present a different vtable than one built directly at the
target interface, whenever the conversion crosses an override.
get_underlying_if therefore cannot
compare vtable addresses. It reads a type tag recorded in the vtable,
which costs one extra indirect load beyond the vtable pointer.
If the caller knows the reference was never upcast, a direct
vtable-address comparison gives the same answer at lower cost. A second
template argument,
get_underlying_if<T, ensure_final>,
lets the caller assert this, and the check compiles to the direct
comparison:
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();implementation 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]>Compiled with GCC 16.1 at
-O3, the
check differs as follows. Without
ensure_final, it loads the tag from
the vtable and compares it with the expected tag:
<func 0x401240>:
cmp qword ptr [rdi + 0x8], 0x402030
jne <func 0x40124f>
...With ensure_final, it compares
the vtable pointer, already in a register, with the candidate’s known
vtable address:
<func 0x401240>:
...
cmp rdi, 0x402030
je <func 0x401260>
...A blanket “never upcast” guarantee is sometimes stronger than the
caller knows. The caller may know only that any upcast followed one
specific composition path.
get_underlying_if therefore also
accepts a list of interfaces describing that path. If the asserted path
matches, the direct comparison is used even in the presence of an
upcast:
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);implementation 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 optimization is worth keeping in some form: a caller who knows
more about a reference’s provenance than the type system expresses can
trade a tag lookup for an address comparison. The surface API is
unsettled. Neither
get_underlying_if<T, ensure_final>
nor
get_underlying_if<T, {path...}>
reads as an idiomatic way to assert a fact about provenance. A cleaner
and more uniform way to express varying degrees of upcast-path knowledge
is wanted, and suggestions are welcome.
These features were considered during development but are not in the proposal’s scope or in the working implementation. They are listed to help discuss future direction.
Feature
|
What it would do
|
|---|---|
| Tag elision | If the first parameter of an operation is of empty type
(std::is_empty_v),
treat it as a tag and elide it, so it never takes a register or stack
slot on any ABI. Today every declared parameter is part of the
function-pointer type at the ABI boundary, and some ABIs still give
empty parameters a slot. |
"std::format"
as a reserved name |
Declaring an operation under
"std::format"
makes the interface accessor directly usable with
std::format.
Same shape as the lifetime names
"~T"
and
"delete":
a reserved name the facility recognizes. |
| Final interfaces | Mark an interface
final in
stdx::make_interface,
so no other interface can compose it, like
final on a
C++ class. |
| Hiding and using-declarations | Redeclaring an operation hides the same-named operation of a
composed interface, like name hiding in C++ inheritance. An
interface_token referring back to
the hidden declaration reintroduces it, like
using Base::func. |
| Simpler reference construction | Write
interface(x)
instead of
interface.ref(x),
with
operator()
taking the role of
.ref().
The current spelling mirrors
std::ref(x)
and is the conservative choice. |
| ABI stability | Promise a stable vtable ABI, so a vtable pointer can be exposed directly and used across compiler versions or compilers. Today the design promises nothing, so users cannot expect, check or control the vtable ABI. The C ABI example is the current way to control ABI. |
These are problems around the design, not in its semantics. Some may be reduced by improving the design or the implementation.
Area
|
Problem
|
|---|---|
| Tooling | Interface operations are not member functions, so LSP tooling cannot offer completion or go-to-definition for them, which makes operations harder to discover and navigate. |
| Compile time | Compilation can be expensive under heavy use. |
| Diagnostics | Interface tokens are constant template arguments carrying compile-time data. With GCC’s default settings, one type name in an error message can run to millions of characters. Diagnostics are hard to interpret for users unfamiliar with this style of metaprogramming, and there is no good way yet to control how much information the compiler prints. |
| Code size | The facility relies on heavy metaprogramming. Facilities of this kind often risk code bloat. No systematic evaluation has been done. It is marked here as a risk to watch. |
| Linking | Under heavy use, linking can become much slower and run out of memory. Very long symbol names may be the cause, but this is unconfirmed. LTO can make link time extremely long. It is not known whether the overhead is inherent to the design or an artifact of this implementation. |
namespace std::__detail {
template<...> struct interface_accessor_t; // carries the interface operations
template<...>
struct interface_accessor_wrapper_t {
mutable interface_accessor_t<...> accessor_;
constexpr auto& operator*() const noexcept { return accessor_; }
constexpr auto operator->() const noexcept { return std::addressof(**this); }
// operator bool, operator==, operator<=>, swap...
// lifetime member functions (deallocate, destroy), generated conditionally
};
template<...>
struct interface_instance_t {
static consteval auto tokens() -> std::vector<interface_token_t>;
static consteval auto vtable_address_type() -> std::meta::info;
static consteval auto vtable_addressof(std::meta::info object-type) -> typename[:vtable_address_type():];
// yields the vtable address for a concrete type
static constexpr auto vtable_addressof(const auto* vtable-address) -> typename[:vtable_address_type():];
// yields the vtable address from a vtable address of another interface
static consteval auto pointer_type() -> std::meta::info;
// returns ^^interface_accessor_wrapper_t<...>
static consteval auto const_pointer_type() -> std::meta::info;
// returns ^^interface_accessor_wrapper_t<...>
static constexpr auto ref(auto&&) noexcept;
// accepts a concrete object, pointer_type or const_pointer_type
// yields pointer_type or const_pointer_type
static constexpr auto ref(const auto* vtable-address, auto* object-address) noexcept;
// accepts a vtable address and a concrete object address
// yields pointer_type or const_pointer_type
static constexpr auto cref(auto&&) noexcept -> typename[:const_pointer_type():];
static consteval auto variable() -> std::meta::info;
static consteval auto name() -> std::string_view;
static consteval void cabi_register_handle(std::meta::info handle-type);
// registers a type as a C ABI handle
static constexpr decltype(auto) invoke(auto&& fn, auto&& self, auto&&... args);
// invokes fn, which may or may not be a self-deducing function
consteval operator std::meta::info() const { return ^^interface_instance_t; }
};
}
namespace std {
struct interface_token {
consteval bool is_super_interface() const;
consteval auto super_interface_type() const -> std::meta::info;
consteval bool is_function() const;
consteval auto function_name() const -> std::string_view;
consteval auto function_types() const -> std::vector<std::meta::info>;
consteval auto function_impl() const -> std::meta::info;
consteval bool is_pure_virtual() const;
consteval bool is_deleted() const;
consteval bool is_defaulted() const;
// non-static data members...
};
template<...>
inline constexpr auto make_interface = __detail::interface_instance_t<...>{};
// accepts an interface_token range, yields an interface_instance_t
template<typename>
concept this_interface_accessor = ...; // to enable a self-deducing function
struct virtual_self; // tag type
struct adl_virtual_self; // tag type
template<...>
using virtual_self_of = ...;
// accepts an interface object or reflection, yields a tag type
}
namespace std::interfaces {
template <typename>
concept accessor = ...;
// true for __detail::interface_accessor_t<...>
template <typename T>
constexpr auto&& get_underlying(accessor auto&&) noexcept;
// extracts a reference to T from an interface accessor
constexpr auto vtable_addressof(accessor auto&&) noexcept;
// extracts the vtable address from an interface accessor
constexpr auto& lookup_interface_of(accessor auto const&) noexcept;
constexpr auto& override_interface_of(accessor auto const&) noexcept;
// get the lookup or override interface of an interface accessor
template <fixed-string Name>
constexpr decltype(auto) invoke(accessor auto&&, auto&&...) noexcept;
// invokes an interface operation by name
// ----------- C ABI helpers --------------
template <typename>
concept cabi_handle = ...;
// true for a type registered as a C ABI handle
template <typename Table>
constexpr auto cabi_methods_address() noexcept -> const Table*;
template <cabi_handle Handle>
constexpr auto cabi_create_handle(auto&&) -> Handle;
template <cabi_handle Handle>
constexpr void cabi_destroy_handle(Handle);
template <cabi_handle Handle>
constexpr void cabi_deallocate_handle(Handle, auto&&...);
}This paper is at an early stage. There has been no poll or feedback yet on whether the direction should be pursued, and many design questions remain open. The paper builds a base shape, and complete wording remains to be written.
The author is preparing the working implementation as a reference implementation, to be available in Compiler Explorer. Early feedback on the current design would help that work.
[P0707R5] Herb Sutter. 2024-10-16. Metaclass functions for generative C++. https://wg21.link/p0707r5
[P3086R5] Mingxin Wang. 2025-10-06. Proxy: A Pointer-Semantics-Based Polymorphism Library. https://wg21.link/p3086r5
[P3411R6] Hui Xie, Louis Dionne, S. Levent Yilmaz, Patrick Roberts. 2026-05-09. any_view. https://wg21.link/p3411r6
[P4148R2] Jonathan B. Coe, Hana Dusikova, Antony Peacock, Philip Craig, Neelofer Banglawala. 2026-06-11. protocol: Structural Subtyping for C++. https://wg21.link/p4148r2
[P4380R0] Barry Revzin, Daveed Vandevoorde, Andrei Alexandrescu, NVIDIA. 2026-09-22. Token Sequence Injection. https://wg21.link/p4380r0
This technique was an open question in
R0. It is a C++26 workaround, not a
ready scheme. P4380R0’s
queue_injection(^^{...})
now does the same job directly, so this paper no longer needs to discuss
the old technique in the main text. It is kept here as an appendix.
Every example in this paper exposes operations as named members:
view->print_name(),
view->id(),
with the member name equal to the string given when the operation was
declared. C++26 reflection cannot inject a member function into a type
from a compile-time string. The current implementation approximates it:
std::meta::define_aggregate
injects one empty,
[[no_unique_address]]
data member per operation, whose type overloads
operator().
view->print_name()
is a member access followed by a call, and
operator()
recovers the owner from
this. This
relies on pointer-interconvertibility.
Minimal reproduction:
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));implementation output:
42
42