Non-Intrusive Interfaces for C++
Theme
Contents

Non-Intrusive Interfaces for C++

Non-Intrusive Interfaces for C++

Document #: P4374R1 Latest Status
Date: 2026-10-21
Project: Programming Language C++
Audience: LEWG
Reply-to: Theo Payne
<>

1 History

1.1 R1

1.2 R0

2 Introduction

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.

3 Conventions

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.

4 Comparison with related work

4.1 Where the implementation lives

An operation’s implementation might live on the concrete type or on the interface. This paper supports both.

4.1.1 Concrete type side

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
struct Interface {
    auto draw(std::ostream&) const -> void;
};

int main() {
    auto c = Constant();
    auto v1 = Variable{10};
    auto v2 = Variable{20};

    using vec = std::vector<stdx::DynRef<Interface>>;
    auto stuff = vec{ c, v1, v2 };

    for (auto& d : stuff) {
        std::cout << "* ";
        d.draw(std::cout);
        std::cout << '\n';
    }
}
PRO_DEF_MEM_DISPATCH(MemDraw, Draw);

struct Interface : stdx::facade_builder
    ::add_convention<MemDraw, void(std::ostream& out)>
    ::build {};

int main() {
    auto c = Constant();
    auto v1 = Variable{10};
    auto v2 = Variable{20};

    auto stuff = std::vector{ 
        stdx::make_proxy_view<Interface>(c), 
        stdx::make_proxy_view<Interface>(v1), 
        stdx::make_proxy_view<Interface>(v2),
    };

    for (auto& d : stuff) {
        std::cout << "* ";
        d->draw(std::cout);
        std::cout << '\n';
    }
}
this paper
this paper with P4380R0
constexpr auto drawable = stdx::make_interface<{
    {
        "draw",
        {^^void(std::ostream&) const},
        [](auto&& self, auto& out) { return self.draw(out); },
    },
}>;

int main() {
    auto c = Constant();
    auto v1 = Variable{10};
    auto v2 = Variable{20};

    auto stuff = std::vector{
        drawable.ref(c),
        drawable.ref(v1),
        drawable.ref(v2),
    };

    for (auto& d : stuff) {
        std::cout << "* ";
        d->draw(std::cout);
        std::cout << '\n';
    }
}
stdx::make_interface!{
    drawable {
        auto draw(std::ostream&) const -> void = default;
    };
};

// macro generate:
// inline constexpr auto drawable = 
    // stdx::__interface_instance_type<^^drawable, {
    //     {
    //         "draw",
    //         {^^void(std::ostream&) const},
    //         [](auto&& self, auto& out) { return self.draw(out); },
    //     },
    // }>{};


int main() {
    auto c = Constant();
    auto v1 = Variable{10};
    auto v2 = Variable{20};

    auto stuff = std::vector{
        drawable.ref(c),
        drawable.ref(v1),
        drawable.ref(v2),
    };

    for (auto& d : stuff) {
        std::cout << "* ";
        d->draw(std::cout);
        std::cout << '\n';
    }
}

4.1.2 Interface side

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.

4.1.2.1 Types you cannot modify

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
constexpr auto DrawIntImpl = [](auto&& p_self) { 
    std::println("draw: {}", p_self); 
};

PRO_DEF_FREE_AS_MEM_DISPATCH(MemDraw, draw, DrawIntImpl);

struct Drawable : stdx::facade_builder
    ::add_convention<MemDraw, void() const> 
    ::build {};

auto s = std::string{"hello world"};
int i = 42;

auto p = stdx::make_proxy_view<Drawable>(s);
p->draw();

p = stdx::make_proxy_view<Drawable>(i);
p->draw();
constexpr auto drawable = stdx::make_interface<{
    {
        "draw",
        {^^void() const},
        [](auto&& p_self) { std::println("draw: {}", p_self); },
    },
}>;

auto s = std::string{"hello world"};
int i = 42;

auto view = drawable.ref(s);
view->draw();

view = drawable.ref(i);
view->draw();

implementation output:

draw: hello world
draw: 42

P4380R0 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);
        }
    };
};

4.1.2.2 Interface as metaclass

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
constexpr auto DrawIntImpl = [](auto&& p_self) { 
    std::println("draw: {}", p_self.content); 
};

PRO_DEF_FREE_AS_MEM_DISPATCH(MemDraw, draw, DrawIntImpl);

struct Drawable : stdx::facade_builder
    ::add_convention<MemDraw, void() const> 
    ::build {};

struct [[=Drawable::get-only-conventions{}]] my_content {
    std::string content;
};

auto c = my_content{"hello world"};
c.draw();
constexpr auto drawable = stdx::make_interface<{
    {
        "draw",
        {^^void() const},
        [](auto&& p_self) { std::println("draw: {}", p_self.content); },
    },
}>;

struct [[=drawable]] my_content {
    std::string content;
};

auto c = my_content{"hello world"};
c.draw();

expected output:

draw: hello world

P3086R5 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.

4.2 Interface as a token range

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: 42

A dedicated token type is preferred over a general std::meta::token_sequence for reasons:

4.3 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: -2147483549

This is the usual shape of a plugin interface: the host fixes the table, the plugin fills it by derive and override.

4.4 Implementing existing type-erasure facilities

This paper’s facility can supply the dispatch part of existing type-erasure wrappers. The wrapper itself still owns storage and lifetime.

4.4.1 Implementing protocol

The 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 dtor

4.4.2 Implementing proxy

With 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 dtor

ape, 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.

4.4.3 Implementing std::move_only_function

With 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 exit

4.4.4 Implementing any_view

We 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], []]

4.5 Summary

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:

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.

5 Examples

This section walks through small examples of basic use. Each one exercises a part of the proposed design, so together they sketch its shape.

5.1 Defining and using an interface

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: dog

5.2 Composing interfaces

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

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

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

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

implementation output:

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

5.3 Converting between compatible interface reference wrappers

An 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 moving

5.4 Overriding operations

When a composed interface redeclares an operation that one of its constituents already provides, the later declaration wins. This lets an interface 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: cat

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

5.5 Constructing interfaces from tokens

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

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

is equivalent to:

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

Because a token is an ordinary value, it can be produced programmatically — for example, by a consteval function that chooses between two implementations of print_name based on a 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: cat

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

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

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

5.6 Overloaded operations

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
24

const_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,
        };
    }),
}>;

5.7 Qualification via the Interface Accessor

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.

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
24

So view->value() and std::move(*view).value() select different overloads of the same operation, int*() & and int() &&, on the same underlying object.

5.8 Self Deduction

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

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

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

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

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

*view->value() = 24;

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

implementation output:

42
24
24

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

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

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

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

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

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

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

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

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

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

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

implementation output:

55.3896
5.76

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

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

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

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

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

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

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

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

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

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

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

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

implementation output:

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

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

5.9 Deduce Interface Reference Types by Tag Type

An interface operation may need to accept another reference to an interface of the same type. stdx::virtual_self provides a context-dependent placeholder for this purpose. A pointer to stdx::virtual_self denotes an interface reference 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: 18

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

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

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

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

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

auto view = player.ref(b);

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

view->attack(*view);

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

implementation output:

current health: 42
current health: 18

When the referenced interface is known statically, stdx::virtual_self_of<I> provides a convenient way to name the corresponding interface reference 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: 18

The 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 derived

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

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

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

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

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

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

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

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

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

implementation output:

copy child
copy child
move child

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

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

5.10 Operator definitions

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

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

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

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

auto view = operators.ref(c);

std::cout << *view;

implementation output:

42

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

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

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

auto view = operators.ref(c);

*view << std::cout;

implementation output:

42

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

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

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

auto view = operators.ref(c);

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

implementation output:

42

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

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

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

auto view = operators.ref(c);

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

implementation output:

42

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

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

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

auto view = operators.ref(c);

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

implementation output:

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

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

auto view = operators.ref(c);

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

implementation output:

42

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

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

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

auto view = operators.ref(c);

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

implementation output:

1764

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

struct character;

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

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

auto view = operators.ref(c);

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

implementation output:

42336

5.11 Lifetime operations

Two operation names, "~T" and "delete", are reserved by the facility to describe an object’s destruction and deallocation respectively. Unlike an ordinary named operation, which is invoked through the interface accessor produced by operator*/operator->, an interface that declares either of these reserved names causes the interface reference 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:

42

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

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

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

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

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

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

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

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

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

implementation output:

override destroy
override deallocate

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

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

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

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

auto base_view = lifetime_base.ref(*ptr);

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

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

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

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

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

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

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

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

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

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

implementation output:

override destroy
override deallocate

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

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

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

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

auto base_view = lifetime_base.ref(*ptr);

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

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

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

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

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

auto base_view = lifetime_base.ref(*ptr);

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

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

5.12 Diamond composition and operation ambiguity

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

5.13 RTTI in diamond compositions

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.

5.14 Porting from virtual functions

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

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

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

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

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

        return tokens;
    }(),
}>;

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

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

struct Animal : Named, Movable {};

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

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

constexpr auto& animal = iface<Animal>;

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

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

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

virtual_dispatch(ptr);
interface_dispatch(view);

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

virtual_dispatch(ptr);
interface_dispatch(view);

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 moving

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

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

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

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

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

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

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

func 0x401380 is interface_dispatch. interface_dispatch reads a entry directly out of the two-word interface reference 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 rax

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

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

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

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

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

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

5.15 Exposing an interface through a C ABI

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
}
#endif

Fourthly, 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: 3000

The 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?

5.16 Extension

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 dog

mimic 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 dog

The 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.

5.17 Incomplete interface

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: 45

Declaring 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:

42

6 Open design questions

6.1 make_interface as a token sequence macro

P4380R0 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.

6.1.1 Declaration shape

The macro form reads like a class hierarchy:

Before
After
inline constexpr auto drawable = stdx::make_interface<{
    {
        "draw",
        {^^void(std::ostream&) const},
        0,
    },
}>;

inline constexpr auto movable = stdx::make_interface<{
    {
        "move_to",
        {^^void(int, int) &, ^^void(int, int) && },
        0,
    },
}>;

inline constexpr auto button = stdx::make_interface<{
    drawable,
    movable,
    {
        "draw",
        {^^void(std::ostream&) const},
        [](auto&& p_self, auto& out) { return p_self.draw(out); },
    },
    {
        "move_to",
        {^^void(int, int) &, ^^void(int, int) && },
        [](auto&& p_self, int x, int y) { p_self.pos = {x, y}; },
    },
}>;
stdx::make_interface!{
    drawable {
        void draw(std::ostream&) const = 0;
    };

    movable {
        void move_to(int, int) & = 0;
        void move_to(int, int) && = 0;
    };

    button : drawable, movable {
        void draw(std::ostream&) const override = default;
        void move_to(this auto&& p_self, int x, int y) override {
            p_self.pos = {x, y};
        }
    };
};

Copying an interface also becomes a plain declaration:

Before
After
inline constexpr auto drawable_copy = 
    stdx::make_interface<{std::from_range, drawable.tokens()}>;
stdx::make_interface!{ drawable_copy = drawable; };

// macro generate:
// inline constexpr auto drawable_copy = 
//      stdx::__interface_instance_type<
//          ^^drawable_copy, 
//          {std::from_range, drawable.tokens()}
//      >{};

6.1.2 Interface identity

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.

6.2 Reusing standard templates

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 exit

We 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 dtor

6.3 Interface as a variable or a type

Here 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
constexpr auto iostream = stdx::make_interface<{
    {
        "good",
        {^^bool() const},
        [](auto&& self){ return self.good(); },
    },
}>;

bool _ = iostream->good(buffer);
bool _ = stdx::DynRef<Interface>::invokers.good(buffer);
bool _ = stdx::protocol<Interface>::invokers.good(buffer);
bool _ = UserFacade::conventions.good(buffer);

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
constexpr struct {
    __macro operator[](this auto, auto...);
    __macro operator()(this auto, auto...);
    __macro operator<<(this auto, auto);
} interface {};

interface[1,2,3];
interface(42);
interface << 42 << interface;
stdx::DynRef<Interface>::parse(42);
stdx::protocol<Interface>::parser << 42 << stdx::protocol<Interface>::parser;
UserFacade{}[1,2,3];

A variable is usable in annotation, to make P4380R0’s push-based customization:

variable
type
struct [[=animal]] cat {};

cat.make_a_sound();
struct [[=stdx::DynRef<Interface>::injection]] cat {};
struct [[=stdx::protocol<Interface>::injection]] cat {};
struct [[=AnimalFacade{}]] cat {};

cat.make_a_sound();

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; // invalid

Forward 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.

6.4 Expressing delete ptr-style destroy-and-deallocate

C++’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.

6.5 Qualified calls

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: cat

The 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();     // current

Neither 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.

6.6 RTTI facility

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: dog

A 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_1

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

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

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

The 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.

6.7 Features under consideration

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.

6.8 Surrounding problems

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.

7 Library API summary

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&&...);
}

8 Polls

9 Wording and implementation status

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.

10 References

[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

11 Appendix A: Member function injection

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