Document #: | P2547R1 |
Date: | 2022-07-16 |
Project: | Programming Language C++ |
Audience: |
Evolution |
Reply-to: |
Lewis Baker (Woven-Planet) <lewissbaker@gmail.com> Corentin Jabot <corentinjabot@gmail.com> Gašper Ažman <gasper.azman@gmail.com> |
swap()
)tag_invoke
CPOs
This paper proposes a language mechanism for defining customisable namespace-scope functions as a solution to the problems posed by [P2279R0] “We need a language mechanism for customisation points”.
This work is a preliminary initial design. We intend this proposal to
replace the use of tag_invoke
in
[P2300R5] in the C++26 time frame. We
need directional guidance from EWG and LEWG to refine the design over
the next few months.
customisable
, rather
than virtual
and
= 0
. This is to address initial
feedback that re-using virtual
for a feature unrelated to dynamic polymorphism was, at best,
confusing.final
keyword to annotate a function that may not be overridden.This proposal seeks to improve over existing customisation-point mechanisms in a number of ways:
unifex::any_object
) and adapters
that customise some operations and pass through others.tag_invoke
mechanism (avoids 3
layers of template instantiations and cuts down on SFINAE required to
separate implementation functions)tag_invoke
mechanism
(tag_invoke
does not distinguish
between different customisation-point functions and sometimes results in
reporting hundreds of unrelated overloads)This proposal improves on the semantics of the
tag_invoke
proposal [P1895R0], keeping namespace-scoped
customisation-points and generic customisation / forwarding, while
providing a terser and cleaner syntax accessible to less experienced
programmers.
The proposed syntax introduces use of:
customisable
for namespace-scope
functions as a way of declaring that the function declaration is a
customisation-point.override
for
customisations of a customisable function-object.default
keyword as an additional virt-specifier annotation for a
customisable function’s default implementation.final
to
create a non-customisable function-object.This paper introduces the following terms:
A function declaration for a customisable function is a customisable function prototype:
void foo(auto&&) customisable;
The set of customisable function prototypes naming the same entity represent a customisable function.
A customisable function introduces a customisable Function Object (CFO) in the scope it is declared.
The set of functions or template functions with the
override
keyword are
customisations of the corresponding customisable
function. They form the customisation overload
set.
The set of functions or template functions with the
default
keyword are
default implementations of the corresponding
customisable function. They form the default overload
set for that customisable function object.
A declaration of a customisable function with explicit template
parameters in the function declarator is a customisable function
template. e.g. cpp template <typename T, typename Object> T* any_cast<T>(Object&& object) customisable;
A declaration of namespace-scope function of the form
template <auto func, typename T> auto func(T&& object) override;
declares a generic customisation.
namespace std::execution {
template<sender S, receiver R>
auto connect(S s, R r) customisable;
operation_state }
contains
that has a
default implementationnamespace std::ranges {
template<input_range R, typename Value>
requires equality_comparable_with<range_reference_t<R>, Value>
bool contains(R range, Value v) customisable;
template<input_range R, typename Value>
requires equality_comparable_with<range_reference_t<R>, Value>
bool contains(R&& range, const Value& v) default {
for (const auto& x : range) {
if (x == v) return true;
}
return false;
}
}
As an example we define a customisation of the above customisable
function contains (as a hidden friend) for
std::set
using the
override
keyword. Note that the
name of the customised function is qualified.
namespace std {
template<class Key, class Compare, class Allocator>
class set {
// ...
private:
template<typename V>
requires requires(const set& s, const V& v) { s.contains(v); }
friend bool ranges::contains(const set& s, const V& v) override {
return s.contains(v);
}
};
}
Alternatively, we can define a customisation at namespace scope using
the override
keyword. This can
be useful when implicit conversions should be considered.
namespace std {
template<class Key, class Hash, class KeyEq, class Allocator>
class unordered_set { ... };
// Defined outside class definition.
template<class Key, class Hash, class Eq, class Allocator, class Value>
requires(const unordered_set<Key,Hash,Eq, Allocator>& s, const Value& v) {
.contains(v);
s}
bool ranges::contains(const unordered_set<Key,Hash,Eq,Allocator>& s,
const Value& v) override {
return s.contains(v);
}
}
When calling a customisable function there is no need for the
two-step using
. They
are safe to call fully qualified.
void example() {
::set<int> s = { 1, 2, 3, 4 };
stdfor (int x : { 2, 5 }) {
if (std::ranges::contains(s, x)) // calls std::set customisation
::cout << x << " Found!\n";
std}
}
Note that, as with C++20
std::ranges
CPOs,
customisable-functions cannot be found by ADL when resolving unqualified
call expressions.
void lookup_example() {
::set<int> s = { 1, 2, 3 };
std
(s, 2); // normal ADL lookup for function declarations.
contains// will not find
std::ranges::contains.
using std::ranges::contains;
(s, 2); // name lookup for 'contains' finds the std::ranges::contains
contains// "customisable function", and then follows overload resolution
// rules of customisable functions instead
// of [basic.lookup.argdep].
}
A customisable function prototype creates a name that identifies an empty object that can be passed around by value. This object represents the overload set of all overloads of that customisable function, and so can be passed to higher-order functions without having to wrap it in a lambda.
template<typename T>
void frobnicate(T& x) customisable;
struct X {
friend void frobnicate(X& x) override { ... }
};
void example() {
::vector<X> xs = ...; // 'frobnicate' is a callable-object that
std::ranges::for_each(xs, frobnicate); // can be passed to a higher-order function.
std}
A customisable function template is declared as a customisable function where the function declarator has an explicit template parameter list.
Calling the customisable function requires explicitly passing the template parameters and each specialisation of the customisable function results in a different customisation point object type.
Note: the ability to declare multiple variable templates of the same name but different parameter kinds is novel.
namespace std {
// get for types
template<typename T, typename Obj>
auto get (Obj&& obj) customisable;
// get for numeric indices
template<size_t N, typename Obj>
auto get (Obj&& obj) customisable;
// non-template get() that deduces numeric indices
// callable without explicit template args
template<size_t N, typename Obj>
requires (Obj&& obj) {
<N>(std::forward<Obj>(obj));
get}
auto get(Obj&& obj, std::integral_constant<size_t, N>) final {
-> decltype(auto) {
return get<N>(std::forward<Obj>(obj));
}
}
struct my_tuple {
int x;
float y;
friend int& std::get<int>(my_tuple& self) noexcept override { return self.x; }
friend float& std::get<float>(my_tuple& self) noexcept override { return self.y; }
friend int& std::get<0>(my_tuple& self) noexcept override { return self.x; }
friend float& std::get<1>(my_tuple& self) noexcept override { return self.y; }
};
A customisable function template can also be customised generically by providing a template parameter as the template argument to the customisable function’s template argument:
template<typename T, std::size_t N>
struct array {
[N];
T data
// Use a function-template parameter to deduce the CFO parameter
template<std::size_t Idx>
requires (Idx < N)
friend T& std::get<Idx>(array& self) noexcept override { return self.data[Idx]; }
};
template<typename First, typename Second>
struct pair {
First first;
Second second;
// Use a class-template parameter to constrain the CFO parameter
friend First& std::get<First>(pair& self) noexcept override
requires (!std::same_as<First, Second>) {
return self.first;
}
// ...
};
Usage of this customisable function template works as follows:
void example() {
= {42, 0.0f};
my_tuple t
int& x1 = std::get<0>(t);
float& y1 = std::get<1>(t);
int& x2 = std::get<int>(t);
float& y2 = std::get<float>(t);
int& x3 = std::get(t, std::integral_constant<std::size_t, 0>{});
float& y3 = std::get(t, std::integral_constant<std::size_t, 1>{});
}
Note: unlike variables and variable templates, CFOs are not
less-than-comparable, which means
cfo-name<token>
can be
unambiguously parsed as a template name and not a comparison, similar to
function templates. This allows CFOs and CFO-templates to coexist in the
same namespace.
A type can customise a set of customisable-functions generically by defining namespace-scope generic customisation.
template<typename Inner>
struct unstoppable_receiver_wrapper {
Inner inner;
// Customise get_stop_token() to return never_stop_token.
friend constexpr never_stop_token std::execution::get_stop_token(
const unstoppable_receiver_wrapper& self) noexcept override {
return {};
}
// Generically customise all other queries to forward them to the wrapped receiver.
template<auto func>
requires requires(const Inner& inner) { func(inner); }
friend constexpr auto func(const unstoppable_receiver_wrapper& self)
noexcept(noexcept(func(self.inner))) override
-> decltype(func(self.inner)) {
return func(self.inner);
}
// ... etc. for forwarding set_value/set_error/set_done customisable functions
};
This can be made even more generic by accepting a first parameter that accepts an arbitrary set of parameters and different value-categories of the first argument:
template<typename Obj, typename Member>
using member_t = decltype((std::declval<Obj>().*std::declval<Member std::remove_cvref_t<Obj>::*>()));
template<typename Inner>
struct logging_wrapper {
Inner inner;
// Forward calls with the first argument as 'logging_wrapper' to the inner object if callable
// on the inner object after printing out the name of the CPO that is being called.
template<auto func, typename Self, typename... Args>
requires std::derived_from<std::remove_cvref_t<Self>, logging_wrapper> &&
::invocable<decltype(func), member_t<Self, Inner>, Args...>
stdfriend decltype(auto) func(Self&& self, Args&&... args) noexcept(/* ... */) override {
::print("Calling {}\n", typeid(func).name());
stdreturn func(std::forward<Self>(self).inner, std::forward<Args>(args)...);
}
};
A single override
declaration
is able to provide an overload for an open set of customisable functions
by allowing the non-type template parameter
func
to be deduced to an
instance of whichever customisable function the compiler is currently
performing overload resolution for.
One of the main purposes of defining customisation points is to enable the ability to program generically. By defining a common set of operations that many different types can customize with their own type-specific behaviour we can write generic algorithms defined in terms of that common set of operations, and have them work on many different types. This is the cornerstone of generic programming.
The state of the art for defining customisable functions has evolved over time.
Early customisation points such as
std::swap()
make use of raw
argument-dependent-lookup (ADL) but require a two-step process to call
them
(using std::swap; swap(a, b);
)
to ensure the customisation is found if one exists but with a fallback
to a default implementation. It is a common programmer error to forget
to do this two-step process and just call
std::swap(a, b)
which results in
always calling the default implementation. Raw ADL calls can also give
different results depending on the context in which you call them, which
can lead to some hard to track down bugs.
The std::ranges
customisation-point objects added in C++20 improved on this by
encapsulating the two-step ADL call into a function object, making the
customisation point easier to call correctly, but also making it much
more complex to define a customisation point, as one needs to define two
nested namespaces, poison pill declarations,
inline constexpr
objects, and a
class with a constrained
operator()
overload.
The tag_invoke
proposal [P1895R0] further refines the concept of
customisation-point objects to use a single ADL name
tag_invoke
and instead
distinguish customisations of different CPOs by passing the CPO itself
as the first argument, using tag-dispatch to select the right overload.
This simplifies definitions of customisation-point objects, enables
generically customising many CPOs, and eliminates the issue of name
conflicts inherent in ADL-based approaches when different libraries use
the same function name for customisation points with different semantics
by allowing names to be namespace-scoped.
Adding a first-class language solution for defining customisation points has been suggested before; Matt Calabrese’s paper [P1292R0] “Customization Point Functions” suggests adding a language syntax for customisation points similar to the syntax proposed here.
Barry Revzin’s paper [P2279R0] “We
need a language mechanism for customization points” discusses what
he sees as the essential properties of “proper customisation” in the
context of tag_invoke
and also
seems to come to the conclusion that
tag_invoke
, despite being an
improvement on previous solutions, still leaves much to be desired and
that we should pursue a language solution.
tag_invoke
is an improvement over customization point objects as a library solution
to the static polymorphism problem. But I don’t really think it’s better
enough, and we really need a language solution to this problem. …”A discussion of [P2279R0] in a joint library/language evolution session had strong consensus for exploring a language solution to the problem of defining customisation points. This is that paper.
POLL: We should promise more committee time to exploring language mechanism for customization points ([P2279R0]), knowing that our time is scarce and this will leave less time for other work.
SF
|
WF
|
N
|
WA
|
SA
|
---|---|---|---|---|
30 | 12 | 2 | 0 | 0 |
The paper [P2300R5]
“std::execution
”
proposes a design that heavily uses customisable functions which are
currently based on tag_invoke
as
the customisation mechanism. If [P2300R5] is standardised with
customisation points defined in terms of
tag_invoke()
, retrofitting them
to support the language-based solution for customisable functions
proposed in this paper will still carry all the downsides of
tag_invoke
due to backwards
compatibility requirements.
The added complexity of CPOs and abstractions to support both
tag_invoke
and a language
solution may negate much of the benefit of using a language feature.
The committee should consider whether it is preferable to first
standardise a language-based approach to customisable functions before
adding a large number of customisable functions to the standard library
based on tag_invoke
.
A primary motivation for writing this paper was based on experience
building libraries such as libunifex, which implement some earlier
versions of sender/receiver concepts from [P2300R5] and are heavily based on
tag_invoke
customisation point
objects.
While the tag_invoke
mechanism for implementing customisation points is functional and
powerful, there are a few things that make it less than ideal as the
standard-blessed mechanism for customisable functions.
tag_invoke
).tag_invoke
, which can make for a
large overload-set that the compiler has to consider. Types that
customise a large number of CPOs have all of those customisations added
to the overload-set for every call to a CPO with that type as an
argument. This can impact compile times when used at scale, and heavily
impacts user-visible error messages.tag_invoke
forwarding
mechanism requires a lot of template instantiations when instantiating
the call operator (tag_invocable
concept for constraint,
tag_invoke_result_t
for return
type, is_nothrow_tag_invocable_v
to forward noexcept
clause, the
std::tag_invoke_t::operator()
function template. This can potentially impact compile-time in code
where there are a large number of calls to CPOs.For example, defining a hypothetical
std::ranges::contains
customisable function with a default implementation requires a lot of
boiler-plate with
tag_invoke
.
tag_invoke ([P1895R0])
|
This proposal
|
---|---|
|
|
When reading code that customises a function, it is difficult for the
eye to scan over the declaration to see which function is being
customised. You need to look for the template argument to
std::tag_t
in the first argument
of the customization instead of in the function-name position, where
most editors highlight the name.
Barry’s discussion paper [P2279R0] contains further critique of
tag_invoke
and other
customisation point mechanisms along several axes:
int
, you cannot opt in by
accidentally taking an
unsigned int
).The proposal in this paper addresses most of these axes, improving on [P1292R0] customisation point functions by adding better support for diagnosis of incorrect customisations and adding the ability to generically customise multiple customisation-points and forward them to calls on wrapped objects.
This proposal is not attempting to solve the “atomic group of functionality” or the “associated types” aspects that [P2279R0] discusses. Although in combination with C++20 concepts it does a reasonable job of matching the simplicity of Rust Traits (see Comparison to Rust Traits). The authors do not believe that this proposal would prevent the ability to define such an atomic group of functionality as a future extension to the language, or even as a library feature.
While [P2279R0] does a great job of surveying the existing techniques used for customisation points, we want to further elaborate on some limitations of those techniques not discussed in that paper.
If a generic concept defined by one library wants to use the
.foo()
member function name as a
customisation point, it will potentially conflict with another
library that also uses the
.foo()
member function name as a
customisation point with different implied semantics.
This can lead to types accidentally satisfying a given concept syntactically even if the semantics of the implementations don’t match because they are implementing a different concept.
It can also make it impossible to implement a type that satisfies two concepts if both of those concepts use conflicting customisation point names. e.g. the somewhat contrived example:
namespace GUI {
struct size { int width; int height; };
template<typename T>
concept widget =
requires (const T& w) {
{ w.size() } -> std::same_as<GUI::size>;
};
}
namespace CONTAINER {
template<typename T>
concept sized_container =
requires (const T& c) {
{ c.size() } -> std::same_as<typename T::size_type>;
};
}
A composite_widget
type that
wants to be a sized_container
of
widgets but also a widget itself would not be able to simultaneously
satisfy both the concepts as it can only define one
size()
member function.
If one wants to build a wrapper type that customises only one customisation point and forwards the rest, or that type-erases objects that support a given concept, one needs to implement a new class for each set of member names they want to forward.
e.g. For each concept we need to define a new wrapper type instead of being able to define a wrapping pattern generically.
template<foo_concept Foo>
class synchronised_foo {
Foo inner;::mutex mut;
std
void foo() { std::unique_lock lk{mut}; inner.foo(); }
};
template<bar_concept Bar>
class synchronised_bar {
Bar inner;::mutex mut;
std
void bar() { std::unique_lock lk{mut}; inner.bar(); }
void baz() { std::unique_lock lk{mut}; inner.baz(); }
};
Generally, we need to wrap up the call to an object in a generic lambda so we can pass it as an object representing an overload set.
const auto foo = [](auto&& x) -> decltype(auto) {
return static_cast<decltype(x)>(x).foo();
};
We cannot define new customisations of functions for types we cannot modify if they must be defined as member functions.
A common workaround for this is to use the CRTP pattern (or, since C++23, [P0847R7] “Deducing this”) to have each type inherit from some base class that provides default implementations for common operations, but this is effectively providing a way to modify types that inherit from it, and not purely unrelated types.
e.g. We can define two types that implement a concept and that both
inherit from foo_base
:
struct foo_base {
void thing1(this auto& self) {
::print("default thing1\n");
std}
};
struct foo_a : foo_base {};
struct foo_b : foo_base {
void thing1() { std::print("foo_b thing1\n"); }
};
We can then later extend
foo_base
to add additional
members with default implementations in a non-breaking fashion.
struct foo_base {
void thing1(this auto& self) {
::print("default thing1\n");
std}
void thing2(this auto& self, int n) {
::print("default thing2\n");
stdwhile (n-- > 0) self.thing1();
}
};
It may not be possible to retrofit or enforce that all types that satisfy a concept use the CRTP base, however.
This is the technique used by facilities like
std::swap()
.
A default implementation is defined in some namespace and then customisations are placed in an associated namespace of the arguments to the type. e.g.
namespace std {
template<typename T>
void swap(T& a, T& b) {
= move(a);
T tmp = move(b);
a = move(tmp);
b }
}
namespace customising_lib {
struct X {
friend void swap(X& a, X& b) { /* More efficient implementation */ }
};
}
namespace consumer {
template<typename T>
void reverse(std::vector<T>& v) {
for (size_t i = 0; i < v.size() / 2; ++i) {
using std::swap; // Step 1. Make default available.
(v[i], v[v.size() - i - 1]); // Step 2. Call unqualified.
swap}
}
}
Similar to the limitation of member functions, which have a single global namespace that all member function names conceptually live within, there is also a single global namespace that all functions intended to be called using ADL conceptually live within.
If two libraries decide to use the same ADL name as a customisation point then it is possible that the usages of those libraries may conflict. This can lead to either an inability for a type to implement concepts from both libraries or the potential for a type to implement the concept from one library and accidentally match the concept from another library.
Similarly to the limitation of member functions, customisation points defined in terms of raw ADL need to know the name of the ADL function in order to customise it.
This means we cannot build wrapper types that generically customise and forward multiple customisation point calls to a child object. We need to explicitly customise and forward each ADL name. This prohibits the implementation of any generic decorator pattern.
We cannot just pass an ADL function name to a higher-order function as a parameter. A name that names a function must resolve to a specific overload when used outside of a call-expression, so that the name can resolve to a function pointer.
The issues here are covered in more detail in [P1170R0] Overload sets as function parameters. One of the motivating examples from that paper is reproduced here for convenience.
e.g. While we can write:
namespace N {
struct X { ... };
();
X getX}
(N::getX()); // which calls some function 'foo' foo
we can’t necessarily write:
template <typename F>
void algorithm(F f, N::X x) {
(x);
f}
(foo, N::getX()); algorithm
As foo
could be a function
template, name an overload-set or be a function that was only found by
ADL, or any of several other situations, this code may or may not be
valid code.
If foo
was intended to be a
customisation point, it would almost always be an overload set.
One common workaround to this is to wrap the call in a lambda. e.g.
const auto foo = [](auto&& x) -> decltype(auto) {
return static_cast<decltype(x)>(x).foo();
};
However, this is a lot of boiler-plate to have to remember to write
at every call site where you want to pass
foo
as an overload-set.
swap()
)The need to perform the two-step using/call is unfortunate due to the extra line of code you need to write, especially since you must not forget to write it.
This also complicates deducing what the return type of a call to the
customisation point is. e.g. to determine the iterator type returned by
begin()
- we can’t add the
using std::begin;
default
implementation to a decltype()
expression.
// This won't find the default implementation.
template<typename Rng>
using range_iterator_t = decltype(/* what to write here? */);
Instead, one must wrap the two-step call mechanism in a namespace that has brought in the default implementation. e.g.
namespace _begin {
using std::begin; // Also shadows 'begin' defined in parent namespace.
template<typename Rng>
using range_iterator_t = decltype(begin(std::declval<Rng&>()));
}
using range_iterator_t;
A bigger problem, however, is that callers might accidentally call the default function explicitly. i.e. instead of writing
using std::swap; swap(a, b);
a programmer might write
::swap(a, b); std
In this case, the compiler will no longer perform ADL when resolving
overloads of this call and so it will generally not find any
customisations of swap()
for the
types passed to it; instead, it will always calls the default
implementation. For many types, the default implementation of
swap()
will still silently
compile just fine - it may just run a lot slower than if it called the
custom implementation.
… and therefore nondeterministic and difficult to debug when wrong.
When a raw ADL customisation-point that is intended to be called unqualified is defined so that ADL finds the right customisation, but where there is no default implementation, the two-step using+call process is generally not required when calling the customisation-point.
However, this means that the call-site will consider any overloads declared in parent namespaces which can make the overload that a call dispatches to context-sensitive.
For example: Two call expressions to the same ADL name from different contexts resolve to different overloads despite the arguments to those expressions having the same type.
namespace ns1 {
struct X {};
int foo(X const&) { return 1; }
}
namespace ns2 {
int foo(ns1::X& x) { return 2;}
}
namespace ns3 {
void bar() {
::X x;
ns1(x); // calls ns1::foo(ns1::X const&)
foo}
}
namespace ns2 {
void baz() {
::X x;
ns1(x); // calls ns2::foo(ns1::X&)
foo}
}
This problem does not usually come up when using the two-step
using
, however, as the
using
declaration shadows the
declarations from parent namespaces.
The std::ranges
library
defines a number of customisation point objects which build on ADL, but
that make the customization points easier to use correctly by
encapsulating the two-step using approach in the call operator of the
CPO.
For example: A rough approximation of the
std::ranges::swap()
CPO
defined
namespace std::ranges {
namespace __swap {
void swap(); // poison-pill so we don't find std::swap
template<typename T, typename U>
concept __has_swap = requires(T&& t, U&& u) {
(static_cast<T&&>(t), static_cast<U&&>(u));
swap};
struct __fn {
template<typename T, typename U>
requires __has_swap<T, U>
void operator()(T&& t, U&& u) const
noexcept(noexcept(swap(static_cast<T&&>(t), static_cast<U&&>(u))) {
(static_cast<T&&>(t), static_cast<U&&>(u));
swap}
template<typename T>
requires (!__has_swap<T>) && movable<T>
void operator()(T& a, T& b) const
noexcept(std::is_nothrow_move_constructible_v<T> &&
::is_nothrow_move_assignable_v<T> &&
std::is_nothrow_destructible_v<T>) {
std(std::move(a));
T tmp= std::move(b);
a = std::move(tmp);
b }
// etc. for other default implementations (e.g. for swapping arrays)
};
}
// Function object needs to be in a separate namespace
// And imported using 'using namespace' to avoid conflicts with
// hidden-friend customisations defined for types in std::ranges.
inline namespace __swap_cpo {
inline constexpr __swap::__fn swap{};
}
}
Defining customisation-points as objects has a number of benefits:
std::ranges
specifications
prohibit this).std::ranges::swap
are defined as
function overloads with the name
swap
.However, there are still some problems with this approach.
This approach still generally relies on the same approach as raw ADL which has a single-global namespace for customisation-point names and so is susceptible to conflicts between libraries.
Wrapper types that want to forward customisations to wrapped objects still need to know the names of all CPOs to be forwarded as they need to explicitly define customisations using those names.
It is not possible to define a generic customisation that can forward calls to many different customisation-points.
Defining a CPO in this way requires writing a lot of boiler-plate. This can often obscure the intent of the CPO.
E.g. the std::ranges::swap()
CPO above requires:
operator()
overloadsoperator()
overloads to prefer a
customisation over a default.Many of the reasons for defining this way are subtle and require a high-level of understanding of ADL and various corner-cases of the language.
operator()
overload inhibits
copy-elisionEven if the caller of a CPO invokes the function with a prvalue and
this resolves to calling a customisation that takes the parameter
by-value, the fact that the call goes through an intermediate call to
operator()
, which typically
takes its arguments by universal-reference and “perfectly forwards”
those arguments to the customisation, means that copy-elision of the
argument will be inhibited.
The additional indirection and the required
SFINAE+noexcept
forwarding “You
must say it three times” also results in poor compiler
diagnostics that bury the true culprit deep in the template
instantiation stack.
For example:
namespace _foo {
void foo();
struct _fn {
template<typename T, typename V>
requires requires (T& obj, V&& value) {
(obj, (V&&)value);
foo}
void operator()(T& obj, V&& value) const {
(obj, (V&&)value);
foo}
};
}
inline namespace _foo_cpo {
inline constexpr _foo::_fn foo{};
}
struct my_type {
friend void foo(my_type& self, std::string value);
};
void example() {
my_type t;(t, std::string{"hello"}); // will result in an extra call to
foo// std::string's move constructor.
}
Whereas, if we were using raw ADL,
foo()
would have resolved
directly to a call to the customisation and copy-elision would have been
performed.
Related to this extra level of indirection are some (minor)
additional costs: - stepping-through a call to a CPO when debugging
means having to step through the intermediate
operator()
. - Debug builds that
do not inline this code have to execute the intermediate function,
slowing debug execution performance (some game developers have cited
debug performance as an issue) - There are compile-time costs to
instantiating the intermediate
operator()
function
template.
Note the standard distinguishes CPOs (like
ranges::begin
) from
niebloids. A niebloid is a non-customizable
function that is never found by ADL. Both niebloids and CPOs are
implemented as objects, but a niebloid has no user-defined
customization.
tag_invoke
CPOsThe paper [P1895R0] first
introduced the tag_invoke
technique for defining customisation points, which tries to solve some
of the limitations of customisation-point objects.
In particular it tries to address the following issues:
Defining a CPO using
tag_invoke
is much like defining
a std::ranges
customisation-point object, where instead of dispatching to a named ADL
call, we instead dispatch to a call to
tag_invoke()
, with the CPO
object itself as the first parameter.
For example:
namespace N {
struct contains_t {
template<range R, typename V>
requires std::tag_invocable<contains_t, R&&, const V&>
bool operator()(R&& rng, const V& value) const
noexcept(std::is_nothrow_tag_invocable_v<contains_t, R&&, const V&>) {
return std::tag_invoke(contains_t{}, (R&&)rng, value);
}
// A default implementation if no custom implementation is defined.
template<range R, typename V>
requires (!std::tag_invocable<foo_t, const T&, const U&>) &&
::equality_comparable<
std::ranges::range_reference_t<R>, V>
stdbool operator()(R&& rng, const V& value) const {
for (const auto& x : rng) {
if (x == value) return true;
}
return false;
}
};
inline constexpr foo_t foo{};
}
Note that there is no longer a need to define the CPO and the
instance of the function object in a nested namespace; as well as no
need to define a poison-pill overload of the customisation-point. The
std::tag_invoke
CPO handles
these aspects centrally.
A type can customise this CPO by defining an overload of
tag_invoke
. e.g.
class integer_set {
::uint32_t size_;
std::unique_ptr<std::uint32_t[]> bits_;
stdpublic:
// ... iterator, begin(), end() etc. omitted for brevity.
template<typename U>
friend bool tag_invoke(std::tag_t<N::contains>,
const bit_set& self, std::uint32_t value) noexcept {
if (value >= size) return false;
return (bits[value / 32] >> (value & 31)) != 0;
}
};
Also, with tag_invoke
, a
generic wrapper can customise and forward calls to many CPOs
generically. e.g.
template<typename Inner>
struct wrapper {
Inner inner;
template<typename CPO, typename... Args>
requires std::invocable<CPO, Inner, Args...>
friend decltype(auto) tag_invoke(CPO cpo, wrapper&& self, Args&&... args)
noexcept(std::is_nothrow_invocable_v<CPO, Inner, Args...>) {
return cpo(std::move(self).inner, std::forward<Args>(args)...);
}
};
This can be used for building generic type-erasing wrappers, or types that generically forward through queries to wrapped types (e.g. this is used often when writing [P2300R4] receiver types).
There are still some problems / limitations with
tag_invoke
, however.
While the amount of boiler-plate has been reduced compared to
std::ranges
-style CPOs, there is
still a need to define a struct
with operator()
overloads that
detect whether customisations are defined and conditionally forward to
the customisation or to the default implementation.
Defining customisations requires defining a
tag_invoke()
overload rather
than defining a overload of a function named after the operation you are
customising.
As every customisation of
tag_invoke
-based CPOs
necessarily defines a
tag_invoke()
overload, this
means that all tag_invoke()
overloads defined for all associated types of all arguments to a CPO
will be considered when resolving the appropriate overload to a call to
a CPO.
This can potentially lead to increased compile-times for types that
add a lot of tag_invoke
-based
customisations; if that is not bad enough, diagnostic size just
explodes.
Overload set sizes can be reduced somewhat by careful use of hidden-friend definitions and ADL isolation techniques [O’Dwyer2019] for class templates. However, types that need to customise a large number of CPOs will still have an overload-resolution cost proportional to the number of CPOs that they customise.
Specifically, the overload set size grows with the product of cpo-count and argument count , and that is if one studiously follows hidden-friend cleanliness; if one does not, the size also grows with the number of all specializations in all associated namespaces.
Note that raw ADL does not usually have the same problem as different names are typically used for each customisation point and so overload resolution only needs to consider functions with the same name as the function currently being called and can ignore overloads for other customisation points, thus removing an entire growth factor.
The tag_invoke
approach
inherits the downsides of CPOs regarding inhibiting copy-elision of
arguments as it also introduces a layer of forwarding through the
operator()
function.
Also, as implementations generally dispatch to
tag_invoke()
through the
std::tag_invoke()
CPO rather
than doing direct ADL calls, this usually introduces two levels
of forwarding, increasing the debug runtime and function template
instantiation costs compared with
std::ranges
-style CPOs.
Summary:
tag_invoke
overloads as hidden
friendsIf you attempt to call a
tag_invoke
-based CPO with
arguments that do not match an available customisation /
default-implementation, the compile-errors can be difficult to
wade-through.
There are two levels of forwarding and indirection which add extra
lines to the diagnostic output, plus the compiler needs to list all of
the tag_invoke
overloads it
considered when looking for a valid call - which might be a large list
containing tag_invoke()
overloads unrelated to the call.
This section discusses some of the use-cases for customisable functions proposed in this paper.
We can use customisable functions to declare a set of basis operations that are included in a generic concept.
Basis operations are operations that do not have any default implementation and that need to be customised for a given type (or set of types) before they can be called.
For example, the
std::ranges::begin
and
std::range::end
CPOs are basis
operations for implementing the range concept. They have no
default implementation and they can be customised either by defining a
member function or a namespace-scope function found by ADL.
For the receiver concept defined in [P2300R0]
std::execution
paper, there are
three separate basis operations;
set_value(receiver, values…)
,
set_error(receiver, error)
and
set_done(receiver)
.
Customisable algorithms are another major use-case for this proposal.
A customisable algorithm differs from a basis-function in that it has a default implementation that is implemented in terms of some other set of basis operations. If a customisation of the algorithm is not provided for a particular set of argument types then the call to a customisable algorithm dispatches to a default implementation implemented generically in terms of some basis operations implemented by the arguments (which are typically constrained to require such basis operations for all overloads).
One of the use-cases that needs to be supported for [P2300R5]-style customisation points is the ability to generically customise many customisation points and forward them on to a wrapped object.
This is used extensively in the definition of receiver types, which often need to customise an open set of queries about the calling context and forward them on to the parent receiver if the answer is not known locally.
For example, a receiver defined using
tag_invoke
proposed by [P2300R5] often looks like this:
template<typename T, typenname... Ts>
concept one_of = (std::same_as<T, Ts> || ...);
template<typename T>
concept completion_signal = one_of<T, std::tag_t<std::execution::set_value>,
::tag_t<std::execution::set_error>,
std::tag_t<std::execution::set_done>>;
std
template<typename ParentReceiver>
struct my_receiver {
<ParentReceiver>* op;
my_operation_state
friend void tag_invoke(tag_t<std::execution::set_value>,
&& r, int value) noexcept {
my_receiver// transform the result and forward to parent receiver
::execution::set_value(std::move(r.op->receiver), value * 2);
std}
// Forward through error/done unchanged
template<typename Error>
friend void tag_invoke(tag_t<std::execution::set_error>,
&& r, Error&& error) noexcept {
my_receiver::execution::set_error(std::move(r.op->receiver), std::forward<Error>(error));
std}
friend void tag_invoke(tag_t<std::execution::set_done>, my_receiver&& r) noexcept {
::execution::set_done(std::move(r.op->receiver));
std}
// Generically forward through any receiver query supported upstream.
template<typename CPO>
requires (!completion_signal<CPO>) &&
::invocable<CPO, const ParentReceiver&>
stdfriend auto tag_invoke(CPO cpo, const my_receiver& r)
noexcept(std::is_nothrow_invocable_v<CPO, const ParentReceiver&>)
-> std::invoke_result_t<CPO, const ParentReceiver&> {
return cpo(std::as_const(r.op->receiver));
}
};
Another use-case for building generic forwarding wrappers types is building generic type-erasing wrappers.
For example, the libunifex::any_unique<CPOs…>
type defines a type-erasing wrapper that can generically type-erase any
type that satisfies the set of CPOs.
float width(const T& x) customisable; // this paper, or as-if tag_invoke CPO
float height(const T& x) customisable;
float area(const T& x) customisable;
template<typename T>
concept shape = requires(const T& x) {
(x);
width(x);
height(x);
area};
struct square {
float size;
friend float width(const square& self) override { return size; }
friend float height(const square& self) override { return size; }
friend float area(const square& self) override { return size * size; }
};
// Define a type-erased type in terms of a set of CPO signatures.
using any_shape = unifex::any_unique<
::overload<float(const unifex::this_&)>(width),
unifex::overload<float(const unifex::this_&)>(height),
unifex::overload<float(const unifex::this_&)>(area)>;
unifex
= square{2.0f};
any_shape s assert(width(s) == 2.0f);
assert(height(s) == 2.0f);
assert(area(s) == 4.0f);
Building a generic type-erasing wrapper that can be used with any set of customisable-functions is made possible by the ability to generically customise on a CPO without needing to know its name. This ability is discussed further in Generic forwarding.
Other type-erasing wrapper types are possible that make different choices on storage, copyability/movability, ownership, comparability axes.
A customisable function prototype is a
namespace-scope function declaration with the
customisable
virt-specifier.
Calls to customisable functions dispatch to the appropriate customisation or default implementation based on the static types of the arguments to that call.
A namespace-scope function declarator declares a customisable
function prototype if the declaration contains the
customisable
keyword in its
function-specifier. The keyword is placed in the same syntactic location
of the function definition that the
override
and
final
keywords are placed for
member functions. i.e. after the
noexcept
and any trailing return
type.
namespace containers {
template<typename T, std::unsigned_integral InitialValue>
(const T& value, InitialValue iv) noexcept customisable;
InitialValue hash}
Note that the declaration of a customisable function prototype is always a template (otherwise it wouldn’t be customisable).
It is ill-formed to declare a normal function of the same name as a customisable function in the same namespace as the latter declares the name to be an object or variable template.
A customisable function declaration introduces a Customisation
Function Object (CFO), which is a
constexpr
object of an
unspecified, implementation-generated trivial type with no data
members.
A CFO exists as a way to name the customisable function and can be copied and passed around as an object that represents the overload set of all implementations of this function. It allows the implementation of generic forwarders, and can more generally be used to pass an overload set generically.
Neither default implementations or customisations are member functions of CFOs, and a CFO doesn’t have call operators either. Instead, a call expression on a CFO triggers lookup and overload resolutions specific to customisable functions.
A CFO’s type cannot be used as a base-class (similar to
function-types). CFO types are default-constructible. All objects of a
given CFO type are structurally identical and are usable as NTTPs.
Members of CFO type are implicitly
[[no_unique_address]]
.
Customisations and Default Implementations are implementations of Customisable Functions.
They form separate overload sets: only if overload resolution finds no suitable customisations do we look for a default implementation.
A customisable function can have multiple default implementations and multiple customisations. Default implementations and customisations are never found by normal lookup. It is not possible to take their address.
We can declare zero or more default implementations or implementation templates for any customizable function.
Default implementations are only considered during overload resolution if no viable customisation of the function is found.
Default implementations are overloads of the customisable function
that are declared with the
default
keyword. The keyword is
placed in the same syntactic location as
customisable
, ie where the
override
and
final
keywords are placed for
member functions, after the
noexcept
and any trailing return
type.
A default implementation is declared as a separate declaration after
the prototype. This is because the
noexcept
clause, constraints and
return type of default (and overrides) functions are not handled the
same way as in the prototypes declaration, and keeping them separate
keeps the design simpler.
Example: Declaring a hypothetical customisable
std::ranges::contains()
algorithm with a default implementation provided at the point of
declaration.
namespace std::ranges {
template<input_range R, typename V>
requires equality_comparable_with<range_reference_t<R>, const V&>
bool contains(R&& range, const V& value) customisable;
template<input_range R, typename V>
requires equality_comparable_with<range_reference_t<R>, const V&>
bool contains(R&& range, const V& value) default {
for (auto&& x : range) {
if (x == value)
return true;
}
return false;
}
}
It is also valid to forward-declare a default implementation and define it later. e.g. we could write
namespace std::ranges {
// Declaration of customisable function.
template<input_range R, typename V>
requires equality_comparable_with<range_reference_t<R>, const V&>
bool contains(R&& range, const V& value) customisable;
// Forward declaration of default implementation.
template<input_range R, typename V>
requires equality_comparable_with<range_reference_t<R>, const V&>
bool contains(R&& range, const V& value) default;
}
// ... later
namespace std::ranges {
// Definition of default implementation.
template<input_range R, typename V>
requires equality_comparable_with<range_reference_t<R>, const V&>
bool contains(R&& range, const V& value) default {
for (auto&& x : range) {
if (x == value) return true;
}
return false;
}
}
If we wanted to make a customisable sender algorithm, customisations might only be required to return some type that satisfies the sender concept, whereas the default implementation will need to return a particular sender-type (something that we wouldn’t want all customisations to have to return). In this case we need to declare the customisation point with general constraints on the return type, and define a default implementation that returns a particular concrete type that satisfies those constraints. e.g.
namespace std::execution {
// Declaration of customisation-point defines only general constraints.
template<sender S, typename F>
requires invocable-with-sender-value-results<F, S>
auto then(S&& src, F&& func) customisable;
sender
// Class used by default implementation. Satisfies 'sender' concept.
template<sender S, typename F>
struct default_then_sender { ... };
// Default implementation returns a particular implementation.
template<sender S, typename F>
requires invocable-with-sender-value-results<F, S>
<remove_cvref_t<S>, remove_cvref_t<F>>
default_then_sender(S&& src, F&& func) default {
thenreturn ...;
}
}
We can also declare a customisable function with default implementations that are only valid for types that satisfy some additional constraints.
For example: Declaring the
swap()
customisable function
(which permits swapping between any two types) but only providing a
default that works with types that are
move-constructible/move-assignable.
namespace std::ranges {
template<typename T, typename U>
void swap(T&& t, U&& u) customisable;
template<typename V>
requires std::move_constructible<V> && std::assignable_from<V&, V&&>
void swap(V& t, V& u)
noexcept(std::is_nothrow_move_constructible_v<V> &&
::is_nothrow_assignable_v<V&, V>) default {
std= std::move(t);
V tmp = std::move(u);
t = std::move(tmp);
u }
}
For example, we can extend the above
swap()
example to also define a
default for swapping arrays.
namespace std::ranges {
template<typename T, typename U>
concept nothrow_swappable_with =
requires(T&& t, U&& u) { { swap((T&&)t, (U&&)u); } noexcept };
template<typename T, typename U, std::size_t N>
requires nothrow_swappable_with<T&, U&>
void swap(T(&t)[N], U(&u)[N]) noexcept default {
for (std::size_t i = 0; i < N: ++i) {
(t[i], u[i]);
swap}
}
}
It is possible to declare a default implementation for a customisable function, even if the declaration is textually within a different namespace, by fully-qualifying the function name to name the customisable function that the default is being declared for.
This can be useful in cases where you want to define a default implementation of a customisable function in terms of some concept local to a library such that it is valid for all types that implement that concept.
e.g.
namespace containers {
template<typename T, std::unsigned_integral IV>
(const T& value, IV iv) noexcept customisable;
IV hash}
namespace otherlib {
// Hash function used for types in this library
template<typename T>
::unsigned_integral hash(const T& value) noexcept;
stdtemplate<typename T>
concept hashable = requires(const T& value) { hash(value); };
// Default implementation of containers::hash() so that hash-containers
// from containers:: library can use hash-function of types defined in
// this library.
template<hashable T, std::unsigned_integral IV>
::hash(const T& value, IV iv) noexcept default {
IV containersreturn static_cast<IV>(otherlib::hash(value)) ^ IV;
}
}
Even though the declaration of this default is textually within the
otherlib
namespace, and name
lookup of names evaluated within the definition are looked up in the
scope of otherlib
, the
definition itself is only found when looking for a
containers::hash
default
implementation.
If we provide a customisation for a type that accepts a
const&
where we have a
default that accepts a universal-reference, we would often prefer
overload resolution to find the less-perfect match of the customisation
rather than the default implementation.
Let us consider the
contains()
algorithm from the Examples section. It has a default implementation
with signature:
template<range R, typename Value>
requires equality_comparable_with<range_reference_t<R>, Value>
bool contains(R&& range, const Value& v) default { ... }
We may have a customisation that takes a first argument of type
const set&
.
template<class Key, class Compare, class Allocator>
class set {
//...
template<class Value>
requires (const set& s, const Value& v) { s.contains(v); }
friend ranges::contains(const set& s, const Value& v) override { ... }
};
Consider what would happen if, instead of having the two-stage
overload resolution that first looks for customisations and then looks
for default implementations, we just define the default implementation
as a generic customisation - equivalent to changing the default
implementation to an
override
.
In particular, consider:
::set<int> s = {1, 2, 3};
std
bool result = std::ranges::contains(s, 2); // passes type 'std::set<int>&'
If the default were considered in overload resolution at the same
time as customisations, then the default implementation with a deduced
template parameter R
of type
std::set<int>&
would
actually represent a better match for overload resolution than the
customisation which has a parameter of type
const std::set<int>&
,,
and so this call would resolve to the more expensive default
implementation.
By allowing implementations to be declared as defaults, and by
defining overload resolution as a two-phase process - first try to find
an overload marked as override
,
and only if no such overload was found try to find an overload marked
default
- we can ensure that
customisations marked as
override
are preferred to
default implementations, even if a default implementation would be a
better match.
Note that this approach closely matches the approach taken by
tag_invoke
-based CPOs defined in
[P2300R5], which will generally check if there is a valid
tag_invoke()
overload that it
can dispatch to, and only if there is no valid
tag_invoke()
overload, fall back
to a default implementation.
One of the implications of this two-phase approach to name lookup is that it means that overload resolution can potentially prefer calling customisations found through implicit conversions over calling a default implementation that doesn’t require one.
For example, consider:
template<typename T, typename U>
void swap(T&& t, U&& u) customisable;
template<typename T>
requires std::move_constructible<T> && std::assignable_from<T&, T&&>
void swap(T& a, T& b) noexcept(/*...*/) default {
(std::move(a));
T tmp= std::move(b);
a = std::move(a);
b }
struct X {
int value = 0;
friend void swap(X& a, X& b) noexcept override { swap(a.value, b.value); }
};
void example1() {
int a = 0;
int b = 1;
::reference_wrapper<int> ref1 = a;
std::reference_wrapper<int> ref2 = b;
std(ref1, ref2); // calls default implementation - swaps references.
swapassert(&ref1.get() == &b);
assert(&ref2.get() == &a);
assert(a == 0 && b == 1);
}
void example2() {
{ 0 };
X a{ 1 };
X b::reference_wrapper<X> ref1 = a;
std::reference_wrapper<X> ref2 = b;
std(ref1, ref2); // calls X’s customisation. swaps contents of 'a' and 'b'
swapassert(&ref1.get() == &a); // ref1/ref2 still point to same objects
assert(&ref2.get() == &b);
assert(a.value == 1 && b.value == 0); // contents of ‘a’ and ‘b’ have been swapped.
}
In the case where we call
swap()
on std::reference_wrapper<int>
(example 1) the overload resolution finds no overrides and so falls back
to the default implementation which then swaps the
reference_wrapper
values,
swapping ref1
to reference
b
and
ref2
to reference
a
.
However, when calling swap()
on
std::reference_wrapper<X>
(example 2), the overload resolution finds the override defined for
X
(since
X
is an associated entity of
std::reference_wrapper<X>
)
and this overload is viable because
std::reference_wrapper<X>
is implicitly convertible to
X&
. Overload resolution ends
up preferring to call
swap(X&, X&)
instead of
swap(reference_wrapper<X>&, reference_wrapper<X>&)
.
This difference in behaviour may be surprising for some. It could be
made consistent in this case by explicitly defining an overload of
swap()
for
std::reference_wrapper<T>
to ensure that this always swaps the references rather than swapping the
contents of the referenced objects.
Sometimes we would like to use a given name for multiple forms of a customisable function. For example, to add overloads that take different numbers of arguments, or that take arguments of different types or concepts.
It is possible to declare multiple customisation point overloads with
the same name by declaring multiple
customisable
functions with the
same name in the same namespace.
For example, we might want to define two flavours of the sender
algorithm stop_when()
.
namespace std::execution
{
// Create a sender that runs both input senders concurrently.
// Requests cancellation of the other operation when one completes.
// Completes with result of 'source' once both operations have completed.
template<sender Source, sender Trigger>
auto stop_when(Source source, Trigger trigger) customisable;
sender
// Create a sender that sends a stop-request to 'source' if a stop-request
// is delivered on the input stop-token.
template<sender Source, stoppable_token ST>
auto stop_when(Source source, ST stop_token) customisable;
sender }
This ends up creating a single customisation function object named
std::execution::stop_when
that
is callable with and that allows customisation of either of these
signatures. i.e. that forms a single overload-set.
If a namespace-scope function declaration contains the
override
specifier then the
(possibly scoped) function name must name a previously declared
customisable function. In this case, the declaration adds an overload to
that customisable function’s customisations overload-set.
For example: Given the following customisation point declaration
namespace shapes {
template<typename T>
float area(const T& shape) noexcept customisable;
}
We can declare a customisation of this function for our own data-type as follows:
namespace mylib {
struct circle {
float radius;
};
inline float shapes::area(const circle& c) noexcept override {
return c.radius * c.radius * std::numbers::pi_v<float>;
}
}
Customisations can also be declared as hidden-friends, in which case the overloads are only considered if the containing type is considered an associated type of an argument to the call to a customisable function. e.g.
namespace mylib {
struct circle {
float radius;
friend float shapes::area(const circle& c) noexcept override {
return c.radius * c.radius * std::numbers::pi_v<float>;
}
};
}
Some customisable functions are intended to be called with explicit template arguments.
For example, if we were to hypothetically define
std::get
as a customisable
function so that user-defined types can customise it. e.g. for
supporting structured bindings, then we might declare it as follows:
namespace std {
template<typename T, typename U>
concept reference_to = std::is_reference_v<T> &&
::same_as<std::remove_cvref_t<T>, U>;
std// Get the element of 'obj' with type T
template<typename T, typename Obj>
<T> auto get<T>(Obj&& obj) customisable;
reference_to
// Get the Nth element of 'obj'
template<size_t N, typename Obj>
auto&& get<N>(Obj&& obj) customisable;
}
This ends up declaring two variable templates with the same name, each specialization of which represents a customisable function object with a distinct type and thus a distinct overload-set.
This is intended to mimic the behaviour of normal template functions,
such as std::get
, which permit
different sets of template function declarations to coexist alongside
each other.
Note that it is similarly valid for a customisable function template and a non-template customisable function of the same name to coexist beside each other as well, similar to normal functions.
e.g. The following creates an object named
N::foo
and a variable template
named N::foo<T>
. These
represent independent CFOs that can be customised separately.
namespace N {
void foo(auto& obj, const auto& value) customisable;
template<typename T>
void foo<T>(auto& obj, const T& value) customisable;
}
struct X {
friend void N::foo(X&, const int& x) override; // 1
friend void N::foo<int>(X&, const int& x) override; // 2
};
X x;::foo(x, 42); // calls 1.
N::foo<int>(x, 42); // calls 2. N
Given the example declaration of
std::get
above we could
write:
// Assume that std::tuple has customised std::get<T> and std::get<N>
::tuple<int, float, bool> t{42, 1.0f, true};
std
assert(std::get<0>(t) == 42);
assert(std::get<float>(t) == 1.0f);
// A customisable-function object that when called gets the first element
auto get_first = std::get<0>;
auto get_second = std::get<1>;
static_assert(!std::same_as<decltype(get_first), decltype(get_second)>);
// A customisable-function object that when called gets the 'float' element
auto get_float = std::get<float>;
assert(get_first(t) == 42);
assert(get_float(t) == 1.0f);
Note that the template arguments of
std::get
are never deduced, they
must always be explicitly provided by the caller.
A type can define non-template customisations of template customisable functions as follows:
template<typename First, typename Second>
struct pair {
First first;
Second second;
friend First& std::get<0>(pair& self) noexcept override { return self.first; }
friend Second& std::get<1>(pair& self) noexcept override { return self.second; }
friend First& std::get<First>(pair& self) noexcept
requires (!std::same_as<First, Second>) override {
return self.first;
}
friend Second& std::get<Second>(pair& self) noexcept
requires (!std::same_as<First, Second>) override {
return self.second;
}
};
A type can also define a generic customisation of a template customisable function that allows deduction of the template arguments from the customisable-function template mentioned in the function declarator. e.g.
template<typename T, std::size_t N>
struct array {
[N];
T data
template<size_t Idx>
requires (Idx < N)
friend T& std::get<Idx>(array& self) noexcept { return data[Idx]; }
};
When we are building wrapper types we want to be able to forward through a set of customisable function calls on the wrapper to the wrapped object.
Examples of such wrapper types include:
unifex::any_unique<cfo1, cfo2, cfo3>
where the generic type
any_unique
needs to be able to
customise the arbitrary set of user-provided CFOs.get_allocator()
to return an
allocator owned by the current operation, while forwarding other queries
to the parent receiver).The ability to be able to generically customise different CFOs is built on the ability to define an override with a declarator-id that is deducible.
Example: A receiver type that customises the
get_allocator()
query and
forwards other queries.
template<typename T, auto Signal, typename... Args>
concept receiver_of =
<T> &&
receiver<Signal> &&
completion_signalrequires(T&& r, Args&&... args) {
{ Signal((T&&)r, (Args&&)args...) } noexcept;
};
template<typename Receiver, typename Allocator>
struct allocator_receiver {
Receiver receiver;
Allocator alloc;
// Customise the get_allocator query
friend Allocator std::execution::get_allocator(const allocator_receiver& r)
noexcept override {
return r.alloc;
}
// Forward completion-signals
template<auto Signal, typename... Args>
requires receiver_of<Receiver, Signal, Args...>
friend void Signal(allocator_receiver&& r, Args&&... args) noexcept override {
(std::move(r.receiver), std::forward<Args>(args)...);
Signal}
// Forward query-like calls onto inner receiver
template<auto Query>
requires (!completion_signal<Query>) && std::invocable<decltype(Query), const Receiver&>
friend decltype(auto) Query(const allocator_receiver& r)
noexcept(std::is_nothrow_invocable_v<Query, const Receiver&>) override {
return Query(r.receiver);
}
};
In a function-call expression, if the postfix-expression operand of the call expression denotes a Customizable Function Object, then overload resolution is performed as follows:
O
be the customisable
function object in the postfix-expression operand positionF
be the decayed type of the
customisable function object,
O
,args...
be the sequence of
argument expressions for the call expressionEf
, contain the
associated entites of the type F
Ea
, contain the
associated entities of each of the argument types in
args...
customisable
then let E
be the union of
Ef
and
Ea
E
be
Ef
e
, in
E
look in its associated
namespaces of e
for
override
or
final
where either;
F
; orO
(args...)
and attempt to perform
overload resolution using this argument list on the overloads in the
customisation overload set.final
then
there must be exactly one such overload and it must be the selected
overload (otherwise this indicates that someone has declared an override
for a function marked
final
)default
(which are found in the
customisable function’s private associated namespace).Once the single, best overload is selected the next step is to check that the selected overload is a valid customisation of the customisable function prototype, described in the Validating customisations section.
The process of checking that a particular customisation of a customisable function is a valid customisation helps to eliminate bugs caused by defining a customisation that doesn’t match the expectation of a caller of the customisation point.
One of the points from [P2279R0] states that customisation-points should have:
int
, you cannot opt in by
accidentally taking an
unsigned int
)For example, if I declare the following customisable function:
template<typename Obj>
void resize(Obj& obj, size_t size) customisable;
And if I had a type, say:
struct size_wrapper {
operator size_t() const { std::print("size_t"); return 1; }
operator int() const { std::print("int"); return 2; }
};
You would never expect a call to
resize(someObj, size_wrapper{})
as the second argument to invoke the
operator int()
conversion
operator. However, if we were to allow a type to define the override
resize(some_type&, int)
,
then a call that resolves to this overload might end up calling
operator int()
unexpectedly.
To avoid this situation, the signature of customisations and default implementations are checked against the customisable function prototypes to ensure that they are consistent with the signature of at least one customisable function prototype.
This is done by attempting to match the signature of the selected
overload to the signature of each of the customisable function
prototypes - for each customisable function prototype
F
for the current customisable
function - if it was a customisable function template
prototype, then first deduce the template arguments required to match
the declarator-id such that
decltype(declarator-id)
is
F
. - Then, for each function
parameter of the declaration in-order from left to right - Attempt to
deduce template arguments so that the parameter type exactly matches the
corresponding parameter type from the selected overload. - If this step
fails then ignore this customisable function prototype - Then, attempt
to deduce template arguments such that the return type exactly matches
the return type of the selected overload [[Example:
copyable auto f() = 0;
deduces
an implicit template argument to the return type of the overload and
then checks the copyable
constraint on the deduced implicit template argument – end example]]. -
If this step fails then ignore this customisable function prototype -
Then evaluate the constraints on the declaration with the deduced
template arguments. - If the constraints are not met then ignore this
customisable function prototype - Then evaluate the
noexcept
-specifier of the
customisable function prototype using the deduced template arguments. -
If the noexcept
-specifier
evaluates to noexcept(true)
then
if the implementation does not have a
noexcept(true)
specifier, the
program is ill-formed. - If the
consteval
-ness of the prototype
does not match the
consteval
-ness of the selected
overload, discard the prototype. - If there were no customisable
function prototypes that were not discarded for which the overload was
able to successfully match then the program is ill-formed. [[Note:
Because the implementation was an invalid overload]].
Parts of this process are described in more detail Noexcept specifications, Constraining parameter-types and Constraining return types.
The above checks are not performed if the selected overload was
declared final
and in the same
namespace as the CFO being invoked. Such overloads are not considered to
be implmentations of a customisable function prototype but instead are
independent function overloads.
When a customisable function is declared, the declaration may contain
a noexcept
specification.
If the customisable function prototype has a
noexcept(true)
specification
then all customisations and default implementations of that function
must also be declared as
noexcept(true)
.
namespace somelib {
template<typename T>
void customisable_func(const T& x) noexcept customisable;
}
namespace mylib {
struct type_a { /*...*/ };
struct type_b { /*...*/ };
// OK, customisation is noexcept
friend void somelib::customisable_func(const type_a& x) noexcept {
::cout << "type_a";
std};
// Ill-formed: customisation not declared noexcept
friend void somelib::customisable_func(const type_b& x) {
::cout << "type_b";
std}
}
If the customisable function prototype has a
noexcept(false)
specification or
if the noexcept
specification is
absent then customisations and default implementations may either be
declared as noexcept(true)
or
noexcept(false)
. These rules are
the same as for function pointer convertibility around
noexcept
.
When a noexcept-expression contains a
call-expression in its argument that resolves to a call to a
customisable function, the result is evaluated based on the
noexcept
-specification of the
selected overload of the customisable function rather than the
noexcept
-specification of the
declaration of the customisable function prototype.
namespace std::ranges {
template<typename T, typename U>
void swap(T&& t, U&& u) customisable;
}
struct type_a {
friend void std::ranges::swap(type_a& a, type_a& b) noexcept override;
};
struct type_b {
friend void std::ranges::swap(type_b& a, type_b& b) override;
};
type_a a1, a2;
type_b b2, b2;static_assert(noexcept(std::ranges::swap(a1, a2)));
static_assert(!noexcept(std::ranges::swap(b1, b2)));
For example: Consider a hypothetical
make_stop_callback()
inspired
from [P2175R0]
template<stoppable_token ST, typename F>
requires std::invocable<std::decay_t<F>>
auto make_stop_callback(ST st, F func)
noexcept(std::is_nothrow_constructible_v<std::decay_t<F>, F>) customisable;
struct never_stop_token { ... };
struct never_stop_callback {};
template<typename F>
requires std::invocable<std::decay_t<F>>
(never_stop_token, F&&) noexcept override {
never_stop_callback make_stop_callbackreturn {};
}
void example() {
auto cb = make_stop_callback(never_stop_token{}, []() { do_something(); });
}
In this case, the overload resolution for the call to
make_stop_callback
finds the
never_stop_token
overload which
has argument types:
never_stop_token
and
lambda&&
.
The compiler then looks at the customisable function prototype and deduces the template arguments:
ST
is deduced to be
never_stop_token
F
is deduced to be
lambda&&
Next, the compiler evaluates
noexcept
specification: - noexcept(std::is_nothrow_constructible_v<lambda, lambda&&>)
evaluates to noexcept(true)
.
Next, as the declaration evaluated the
noexcept
specification to
noexcept(true)
, the compiler
then checks the noexcept
specification of the selected overload: - The selected overload is
declared noexcept
unconditionally, so the noexcept
specification of customisation is considered compatible.
A customisation point may put constraints on the parameter types that it is designed to handle.
For example, a customisable range-based algorithm may want to
constrain a contains()
algorithm
so that its first argument is a range and the second argument is a value
that is equality_comparable_with the elements
of that range.
Given:
template<std::ranges::range R,
::equality_comparable_with<std::ranges::range_reference_t<R>> V>
stdbool contains(R range, V value) customisable;
template<std::ranges::range R, typename V>
requires std::equality_comparable_with<
::ranges::range_reference_t<R>, const V&>
stdbool contains(R&& range, const V& value) default {
for (auto&& x : range) {
if (x == value) return true;
}
return false;
}
template<typename T>
requires std::equality_comparable<T>
bool contains(const my_container<T>& c, const T& value) noexcept override {
return c.find(value) != c.end();
}
Then when evaluating the following call to
contains()
:
<int> c;
my_containerbool result = contains(c, 42);
The compiler performs the following steps:
my_container<int>&
and
int
performs overload resolution
on overloads of the contains()
customisable function (see overload
resolution).override
specifier, then we look
at the types of the parameters of the selected overload. In our example,
the parameter types are (const my_container<int>&, const int&)
.std::ranges::range<const my_container<int>&>
std::equality_comparable_with<std::ranges::range_reference_t<const my_container<int>&>, const int&>
Important to note here is that the customisable function prototype having parameters types that are unqualified template parameters that appear to be prvalues does not necessarily mean that all customisations must define those parameters as prvalues. If you want to require customisations to accept parameters by-value then you will need to add additional constraints for this. e.g.
template<typename T>
concept prvalue = (!std::is_reference_v<T>);
// Force customisations to accept parameters by-value
auto combine(prvalue auto first, prvalue auto second) customisable;
It is also important to note that we can constrain the signature of the selected overload to have particular concrete parameter types, or to have a parameter that is a specialization of a particular template. e.g.
template<typename Shape>
(Shape s, float factor) customisable; // 'factor' param must be
Shape scale// 'float' to match signature.
// The 'request' parameter can be constrained to be a shared_ptr of some
// type that satisfies the 'httplib::request' concept.
template<typename Processor, httplib::request Request>
void process_request(Processor& p,
::shared_ptr<Request> request) customisable; std
Often, a customisation point wants to allow the return type of the customisation point to be deduced from the customisation, but still wants to be able to constrain customisations to require that they return types that satisfy some concept or other constraints.
For example, the
std::ranges::begin()
customisation point allows customisations to determine the return type
(different types of ranges usually need different iterator types) but
requires customisations to return a type that satisfies std::ranges::input_or_output_iterator
.
Where there is an existing concept that describes the return type
requirements one can use the
concept-auto
syntax for the
return type to require that the return type satisfies some concept.
For example, the
std::ranges::begin()
customisation point might be defined as follows:
namespace std::ranges {
template<typename R>
auto begin(R range) customisable;
input_or_output_iterator
template<typename T, std::size_t N>
* begin(T(&range)[N]) noexcept override {
Treturn range;
}
template<typename R>
requires (R& r) { { auto(r.begin()); } -> input_or_output_iterator }
auto begin(R&& r) noexcept(noexcept(r.begin())) default {
return r.begin();
}
}
Much like the act of constraining parameters in the previous section, the return type is also deduced from the signature of the selected overload and then constraints are then applied to the deduced template parameters.
The begin()
customisable
function prototype above uses the
concept-auto
syntax, which this
paper is defining as a syntactic sugar for the following equivalent
code:
namespace std::ranges
{
template<typename R, typename It>
requires input_or_output_iterator<It>
(R range) customisable;
It begin}
When a call to a customisable function is made, the compiler looks at the signature of the selected overload and then uses this signature (both the return type and the parameter-types) to deduce the template arguments of the customisable function prototype. Constraints can then be applied to a deduced return type.
This more explicit syntax can be used to apply multiple constraints to the return type, or apply constraints to a function of the return type without having to define a new concept that composes those constraints. e.g.
template<typename R, typename T>
requires decay_copyable<R> && some_concept<std::decay_t<R>>
(const T& x) customisable; R some_getter
This version of the proposal uses the
customisable
contextual keyword,
and uses customisable
,
default
,
override
and
final
all as trailing keywords
in function declarations. The syntax is of course subject to change.
R0 of the proposal used the
virtual
keyword, on non-member
functions, as an argument could be made that what we proposes is, in
some way, a form of static polymorphism.
However, it is clear from the initial reactions, that most people
associate virtual
with
inheritence and dynamic polymorphism. As this proposal is definitively
not that (it is a compile-time mechanism which does not impact runtime
performance), R1 introduced a different syntax.
Some additional concerns were raised that
virtual
may also be used by
multimethods, should such a proposal ever be made and be accepted. We
note in languages with a similar feature, the syntactic marker is
usually on parameters rather than on the function itself, and so there
would not be an issue in terms of keeping the design space open.
In general, we agree that there were enough arguments against
virtual
to warrant a different
syntax. We realized a syntax not associated with dynamic polymorphism
would be better perceived and more easily learned by C++ users.
We welcome suggestions to further improve the syntax.
Default arguments are not currently permitted for customisable function prototypes or for declarations of default implementations or customisations of customisable functions.
Support for default arguments may be explored as an extension in future if desired.
It should be possible to place attributes like
[[deprecated]]
and
[[noreturn]]
on customisable
function prototypes, this has not been fully fleshed out yet.
This proposal makes use of argument-dependent lookup for finding customisations of customisable functions.
The current argument dependent lookup rules can often result in the compiler searching many more associated namespaces for overloads than desired, hurting compile-times and sometimes resulting in unexpected overloads being called.
It would be great if we had better control over which set of associated namespaces the compiler considers when performing argument-dependent lookup.
There are two main features that could be considered as companions to this paper: - Declaring that certain parameters of a customisable function should not be considered when constructing the list of associated namespaces (this can build on top of this paper) - Declaring that certain template parameters of a class template should not be considered when constructing the list of associated namespaces (this is discussed in [P2256R0?]).
This is an example from Barry Revzin’s blog post on customisation points [Revzin2020], which compares C++ to Rust at the end when discussing ideals.
Rust
|
This proposal
|
---|---|
|
|
|
|
|
|
We are able to express the same semantics in C++, although without
some of the explicitness that we get from the Rust implementation of
PartialEq
. The C++ code that
customises eq()
does not make
any reference to the PartialEq
concept to indicate that we are customising a function associated with
that trait.
The implementation on the C++ side is slightly more involved:
ne()
prototype requires that eq()
is
valid. This means you can’t accidentally customise
ne()
and not define
eq()
.ne()
deals with forwarding
noexcept
-nessOur intent is to replace all
tag_invoke
usages in [P2300R5] by this proposal. ([P2300R5] makes extensive use of CPOs and
forwarding)
We also cover other usage patterns of the standard library like
swap
,
std::begin
,
ranges::begin
,
std::get
, etc.
However we have not investigated whether it would be possible to respecify the existing facilities in terms of language customisable functions without ABI breaks or subtle change of behaviour and we are not proposing to do that in this paper.
Some facilities, like ranges for loops and structured bindings are currently specified in terms of raw ADL calls.
This proposal has not yet been implemented in a compiler and so does not yet have implementation or usage experience. The authors are seeking directional guidance from the language evolution group at this point in time.