Doc. no.: | P2511R0 |
Date: | 2021-12-24 |
Audience: | LEWG |
Reply-to: | Zhihao Yuan <zy at miator dot net> |
Beyond operator(): NTTP callables in type-erased call wrappers
Introduction
Non-type template parameters (NTTP) can provide information for call wrappers to erase at compile-time, eliminating the need for binding objects at runtime when fulfilling a simple demand. This paper proposes tweaking type-erased call wrappers, such as std::move_only_function
, with NTTP callable objects.
Here is an unfair Tony Table that quickly demonstrates a motivating use case:
C++11 |
q.push(std::bind(&DB::connect, std::move(db), _1, _2, _3));
|
C++14 |
q.push([db{std::move(db)}] (auto &&... args) mutable
{
return db.connect(std::forward<decltype(args)>(args)...);
});
|
C++20 |
q.push(std::bind_front(&DB::connect, std::move(db)));
q.push([db{std::move(db)}] <class ...T>(T &&... args) mutable
{
return db.connect(std::forward<T>(args)...);
});
|
P2511
|
q.emplace(nontype<&DB::connect>, std::move(db));
|
Motivation
Not all user-defined objects have an
operator()
. To pass a user-defined object to a parameter of std::move_only_function
, one must be able to use that object as
obj(some, args)
But often, the object is designed to be used as
obj.send(some, args)
I want to use such an object with move_only_function
as if the type of the object aliased its send
member function to operator()
.
Analysis
Why don’t people make their classes callable?
Given sufficient tag types, any member functions can be expressed in one overload set, namely operator()
. But we are not in that direction and are not working with constructors – a typical context where the entity to call is anonymous. Naming functions differently is the best way to disambiguate.
When there is no demand for disambiguation, people who design the class can still prefer naming their only member function in the interface “run,” “parse,” and even “apply,” since clearly,
repl.run(line);
delivers more information than
repl(line);
Can we do this with wrapping?
Lambda expression is an option. We can pass a closure object instead of the original object
pack.start([obj{std::move(obj)}] (auto... args) mutable
{
return obj.send(args...);
});
Perfect-forwarding will need more boilerplate
pack.start([obj{std::move(obj)}]
<class ...T>(T &&... args) mutable
{
return obj.send(std::forward<T>(args)...);
});
And don’t forget that we are using obj
only as an lvalue in this example. A lambda’s captures are unaware of the value category of the closure object unless using std::forward_like
together with an explicit object parameter.
Let’s just say that lambda expression is not suitable for expressing the intention of designating a differently named member function.
And it’s a part of the reason why we have std::bind_front
. You can rewrite the example above as
pack.start(std::bind_front(&Cls::send, std::move(obj)));
But bind_front
incurs a cost. Sometimes, the space penalty – size of a pointer to member function, is greater than the obj
itself. Other than that, the codegen cost is also high.
Why the ask has to have something to do with type-erasure?
Let’s recall function pointers – the most basic form of type-erasures. You can assign different functions to the same function pointer:
typedef int cmp_t(const char *, const char *);
cmp_t *p = strcmp;
p = strcasecmp;
At first glance, here we did not erase type. The turth is that we erased “nontype” compile-time information – &strcmp
and &strcasecmp
. In a hypothetical language, every function could be of different types, like what the following piece of legal C++ code shows:
p = [](auto lhs, auto rhs)
{
return string_view(lhs).compare(rhs);
};
There are two ways to understand this:
- The function pointer erased type
T
from the closure.
- C++ offshored the compile-time identity of functions into nontype values.
They are equivalent, as you may get those nontype values back into types anytime:
template<auto V> struct nontype_t {};
static_assert(!is_same_v<nontype_t<strcmp>, nontype_t<strcasecmp>>);
Type erasure is called “type erasure” because it makes code that should depend on different types, not dependent. It wouldn’t be surprising if the code supposed to be dependent were value-dependent. So it seems that a type-erased call wrapper is suitable for erasing the nontype information, &Cls::send
, from the expression
obj.send(some, args)
to make the code that uses obj
depends on neither &Cls::send
nor Cls
, where the latter case is what being erased if obj
were callable.
Proposal
This proposal consists of two parts. First, it adds nontype_t
and nontype
to the <utility>
header. They are similar to in_place_type_t
and in_place_type
, except each of the former two accepts a nontype template parameter with auto
rather than type template parameters.
Second, it adds nontype_t<V>
to move_only_function
’s constructors to allow you to do the following things:
pack.start({nontype<&Cls::send>, std::move(obj)});
pack.start({nontype<&Cls::send>, &obj});
pack.start({nontype<&Cls::send>, std::ref(obj)});
In the first case, the move_only_function
parameter owns obj
. In the other cases, the parameter holds a reference to obj
. This design mirrors the INVOKE
protocol when the argument we pass to nontype
is a pointer-to-member.
Whether Cls::send
is an explicit object member function, case 1 continues to work. Case 3 continues to unwrap reference_wrapper
because move_only_function
’s converting constructor (F&&
) has such a behavior. Case 2 stops working.
This means, if you have a non-member function with a signature like R(Cls, ...)
, you can use it in place of operator()
as well:
pack.start({nontype<send>, obj});
Another constructor converts a single nontype<V>
to move_only_function
. It is merely a shortcut to initialize from a callable object if we can pass it using a nontype template parameter.
move_only_function<cmp_t> fn = strcmp;
fn = nontype<strcasecmp>;
We propose adding the one-argument and two-argument nontype_t
constructors to function_ref
. However, no wording is attached in this revision, as P0792’s wording is under an overhaul. So instead, we will briefly describe the semantics here: The two-argument nontype_t
constructor overload set holds a reference to obj
regardless of whether the user passes it through a reference_wrapper
. obj
is only used as an lvalue to follow function_ref
’s design logic.
The nontype_t<V>
parameters are also prepended to move_only_function
’s existing in_place_type_t<T>
constructors. There is no corresponding change for function_ref
.
Discussion
How do other programming languages solve this problem?
Java® did not designate a magic method serving operator()
's role. Instead, any interface with a single abstract method is deemed a functional interface. When passing a lambda expression to a parameter of a functional interface, Java produces a closure object that implements this interface. So it doesn’t matter what the method’s name is; it may be void accept(T)
, R apply(T)
, etc. But you don’t have to use a lambda if your obj
doesn’t implement the functional interface. Method references are a more straightforward way to produce a closure object. For example, obj::consume
can make a closure object supporting the accept
method.
Python designates __call__
to be the magic method to make an object callable. If you want to call a different method to fulfill the typing.Callable
requirement, you may pass a bound method like obj.send
. A method in Python has a __self__
attribute to store the class instance.
C♯, similar to Java, doesn’t support overloading the call operator. However, its delegate language facility allows quickly defining a functional interface. Unlike Java, you cannot “implement” a delegate, so you must create delegate objects using a syntax similar to Python’s bound methods.
In a parallel universe, C++ with C++0x Concepts provides concept maps as a general mechanism for adapting a de facto interface to be used in a component that expects a common, but different interface. Here, to enable type Cls
for being used in move_only_function
, we can specify a mapping using Richard’s generalized alias declaration:
template<class... Args>
concept_map invocable<Cls, Args...>
{
using operator() = Cls::send;
};
To make this adaptation local to the users’ code, they can define the concept_map
in their own namespace.
Why this proposal is different from delegates and other solutions?
The solution given by concept maps has the right model. But invocable is not only a concept. It is centralized in a programming paradigm. That might be why the other solutions widely used in practice allow forming the adaptations that are different from use to use.
The rest of the solutions, such as delegates, are all language features that work only with member functions.
However, in C++, until C++20, functional interface means std::function
. There are also packaged_task
, folly::Function
, llvm::function_ref
… There is no generic functional interface that fits all needs.
We are proposing a framework for enabling designating a different operator()
when initializing any functional interface in C++. A third-party library can also add nontype_t<V>
to their type-erased call wrappers’ constructor overload sets. The V
they accept may be more permissive or restrictive, the storage policy they chose may be adaptive or pooled, but the end-users can enjoy expressing the same idea using the same syntax.
And a C++ class’s interface consists not only of member functions. The NTTP callable, V
, can be a pointer to explicit object member function – in other words, you can treat a free function as that member function. You can even rewrite that obj
’s call operator with another structural object with an operator()
:
nontype<[](Cls) { runtime_work(); }>
The proposed solution supports all these to satisfy users’ expectations for C++.
Should we add those constructors to all type-erased call wrappers?
I.e., std::function
and packaged_task
.
move_only_function
and function_ref
mean to replace std::function
. std::function
did not add move_only_function
’s in_place_type_t<T>
constructors even though that does not break ABI. These leave the author an impression that we are not further polishing std::function
.
The typical uses of packaged_task
do not seem to value codegen high enough to justify adding the nontype_t<V>
constructors.
Should type-passing call wrappers support NTTP callables?
Strictly speaking, they are outside the scope of this paper. But some type-passing call wrappers that require factory functions have an attractive syntax when accepting NTTP callables – you can pass them in function templates’ template-argument-list. So let me break down the typical ones: bind_front<V>
, not_fn<V>()
, and mem_fn<V>()
.
Supporting bind_front<&Cls::send>(obj)
eliminates the space penalty of bind_front(&Cls::send, obj)
. But, please be aware that if bind_front
appears alone, the compiler has no pressure optimizing type-passing code. Hence, the new form only makes sense if a type-erasure later erases the underlying wrapper object. But a type-erased call wrapper already requires wrapping the target object. This double-wrapping downgrades the usability of those call wrappers:
- One cannot in-place constructs
obj
in an owning call wrapper bypassing bind_front
;
- One must write
bind_front<&Cls::send>(std::ref(obj))
to archive reference semantics even if this expression is about to initialize a function_ref
, defeating half of the purpose of function_ref
.
The need of type-erasing a predicate such as not_fn(p)
seems rare. STL algorithms that take predicates have a type-passing interface.
Rather than overloading mem_fn
to take NTTP callables, adding an operator()
to nontype_t
will have the same, or arguably better, effect:
std::transform(begin(v), end(v), it, nontype<&Cls::pmd>);
However, it seems that the uses of std::mem_fn
largely diminished after introducing std::invoke
. Therefore, the author decides not to complicate nontype_t
in this revision of the paper.
The previous discussion (1, 2) revealed how large the design space is and how the problem ties to the library. This section will use a specific paper in the past as an example to show how these factors can affect language design.
There have been discussions about whether C++ should have expression lambda
priority_queue pq([][&1.id() < &2.id()], input);
to make lambda terse. Expression lambdas aim to address the difficulty of introducing parameters. But in our motivating example, we forwarded all parameters without introducing any. So expression lambda doesn’t directly respond to the problem.
So the ask will need to expand the scope of lambda to “anything that can produce an anonymous closure object.” It is reasonable as other languages have similar sugars. For example, Java’s method references and lambda expressions share VM mechanisms.
In that case, let’s prototype the ask: Why don’t we write the following and make everything work?
q.push_back(db.connect);
Recall that q
is a container of std::move_only_function
from Introduction, so the first question will be what db.connect
means. Luckily, Andrew Sutton had an answer to that in 2016. Here is an example from his paper:
struct S
{
void f(int&);
void f(std::string&);
};
S s;
std::transform(first, last, s.f);
s.f
produces (applied minor corrections):
[&s] <class... Args>(Args&&... args)
-> decltype(s.f(std::forward<Args>(args)...))
{
return s.f(std::forward<Args>(args)...);
}
The above suggests that, in our example, db.connect
captures db
by reference.
But move_only_function
is supposed have a unique copy of db
! In the motivating example, we std::move(db)
into an element of q
. So maybe s
should mean “capture by value” in s.f
, and we write std::move(db).connect
?
Assume it is the case. What happens if there is another function retry
taking function_ref
:
retry(db.connect);
Given the modified semantics, the above should mean “capture db
by making a copy, and pass the closure by reference using function_ref
.” Which is, of course, not satisfying. std::ref(db)
won’t help this time, so let’s go with
retry((&db)->connect);
Now db.connect
and (&db)->connect
have different meanings. This implies that if we had a pointer to db
,
auto p = &db;
(*p).connect
and p->connect
will have different meanings. This goes against the common expectation on C++ ([over.call.func]/2):
[…] the construct A->B
is generally equivalent to (*A).B
Let’s take another angle. Instead of moving db
, what if we want to construct an object of DB
in the new element in place?
It’s simple using a nontype
constructor. We only need to go from
q.emplace(nontype<&DB::connect>, std::move(db));
to
q.emplace(nontype<&DB::connect>,
std::in_place_type<DB>, "example.db", 100ms, true);
I don’t know of a solution that involves capturing. DB("example.db", 100ms, true).connect
will result in moving a subobject along with the closure. And more importantly, it requires DB
to be movable, which adds more to move_only_function
’s minimal requirements.
It seems that C++ lambdas are not only verbose to introduce parameters but also tricky to capture variables. A solution that couples naming a different id-expression
after .
operator with captures will face problems when working with varying type-erased call wrappers.
But if expression lambda solves the problem of introducing parameters, can we replace the problems caused by capturing variables with the problem we solved?
q.emplace(nontype<[][&1::connect]>, std::move(db));
This WORKS. A captureless lambda is of a structral type. It solves the problem of selecting a particular overload or specialization when connect
is an overload set. In Andrew’s example,
struct S
{
void f(int&);
void f(std::string&);
};
Right now, to select the first overload, we have to write nontype<(void (S::*)(int&))&S::f>
; with an expression lambda, it’s as simple as nontype<[][&1.f]>
.
As long as we decoupled naming from wrapping, a language design can relieve itself from making every library happy with a single type-passing language feature that does wrapping for you. In that situation, the library additions and the language additions can evolve in parallel and work together in the end.
Can this proposal target post-C++23?
Suppose all implementations of std::move_only_function
are prepared for this to come, yes. A naïve implementation that uses vtable can later add value-dependent subtypes without breaking ABI; others need to be careful.
On the other hand, the nontype_t<V>
constructors allow the users to bind implicit or explicit object member functions with the same cost. So it is reasonable to ship them in the same standard that ships std::move_only_function
and “deducing this.”
Prior Art
The earliest attempt to bind an object with a member function I can find is Rich Hickey’s “Callbacks in C++ Using Template Functors” back in 1994.
Borland C++ has a language extension – the __closure
keyword. It is very similar to the hypothetical __bound
keyword in Rich’s article. However, you may not delete
a pointer to closure or initialize it with a function.
This proposal is inspired by P2472.
Wording
The wording is relative to N4901.
Add new templates to 20.2.1 [utility.syn], header <utility>
synopsis:
namespace std {
[...]
template<size_t I>
struct in_place_index_t {
explicit in_place_index_t() = default;
};
template<size_t I> inline constexpr in_place_index_t<I> in_place_index{};
// nontype argument tag
template<auto V>
struct nontype_t {
explicit nontype_t() = default;
};
template<auto V> inline constexpr nontype_t<V> nontype{};
}
Add a definition to 20.14.3 [func.def]:
[…]
A target object is the callable object held by a call wrapper.
A constant target object is a target object of a structural type [temp.param].
A call wrapper type may additionally hold […]
Modify 20.14.17.4.3 [func.wrap.move.ctor] as indicated:
template<class... T>
static constexpr bool is-invocable-using = see below;
If noex is true, is-invocable-using<T...>
is equal to:
is_nothrow_invocable_r_v<R, T..., ArgTypes...>
Otherwise, is-invocable-using<T...>
is equal to:
is_invocable_r_v<R, T..., ArgTypes...>
template<class VT>
static constexpr bool is-callable-from = see below;
If noex is true, is-callable-from<VT>
is equal to:
is_nothrow_invocable_r_v<R, VT cv ref, ArgTypes…> &&
is_nothrow_invocable_r_v<R, VT inv-quals, ArgTypes…>
Otherwise, is-callable-from<VT>
is equal to:
is-invocable-using<VT cv ref> &&
is-invocable-using<VT inv-quals>
is_invocable_r_v<R, VT cv ref, ArgTypes…> &&
is_invocable_r_v<R, VT inv-quals, ArgTypes…>
template<auto f, class T>
static constexpr bool is-callable-as-if-from = see below;
is-callable-as-if-from<f, VT> is equal to:
is-invocable-using<decltype(f), VT cv ref> &&
is-invocable-using<decltype(f), VT inv-quals>
move_only_function() noexcept;
move_only_function(nullptr_t) noexcept;
Postconditions: *this
has no target objectstate entity.
move_only_function(move_only_function&& f) noexcept;
Postconditions: The target objectstate entities of *this
isare the target objectstate entities f
had before construction, and f
is in a valid state with an unspecified value.
template<auto f> move_only_function(nontype_t<f>) noexcept;
Constraints: is-invocable-using<decltype(f)>
is true
.
Postconditions: *this
has no state entity if f
is a null function pointer value or a null member pointer value. Otherwise, *this
has a constant target object. Such an object and f
are template-argument-equivalent [temp.type].
template<class F> move_only_function(F&& f);
Let VT
be decay_t<F>
.
Constraints:
remove_cvref_t<F>
is not the same type as move_only_function
, and
remove_cvref_t<F>
is not a specialization of in_place_type_t
, and
is-callable-from<F>
is true
.
Mandates: is_constructible_v<VT, F>
is true
.
Preconditions: VT
meets the Cpp17Destructible requirements, and if is_move_constructible_v<VT>
is true
, VT
meets the Cpp17MoveConstructible requirements.
Postconditions: *this
has no target objectstate entity if any of the following hold:
f
is a null function pointer value, or
f
is a null member pointer value, or
remove_cvref_t<F>
is a specialization of the move_only_function
class template, and f
has no target objectstate entity.
Otherwise, *this
has a target object of type VT
direct-non-list-initialized with std::forward<F>(f)
.
Throws: Any exception thrown by the initialization of the target object. May throw bad_alloc
unless VT
is a function pointer or a specialization of reference_wrapper
.
template<auto f, class T> move_only_function(nontype_t<f>, T&& x);
Let VT
be decay_t<T>
.
Constraints: is-callable-as-if-from<f, VT>
is true
.
Mandates: is_constructible_v<VT, T>
is true
.
Preconditions: VT
meets the Cpp17Destructible requirements, and if is_move_constructible_v<VT>
is true
, VT
meets the Cpp17MoveConstructible requirements.
Postconditions: *this
has no state entity if f
is a null function pointer value or a null member pointer value. Otherwise, *this
has the following properties:
- Its constant target object and
f
are template-argument-equivalent [temp.type].
- It has one bound argument entity, an object of type
VT
direct-non-list-initialized with std::forward<T>(x)
.
Throws: Any exception thrown by the initialization of the bound argument. May throw bad_alloc
unless
VT
is a specialization of reference_wrapper
, or
VT
is an object pointer and f
is of a pointer to member.
template<class T, class... Args>
explicit move_only_function(in_place_type_t<T>, Args&&... args);
[…]
template<class T, class U, class... Args>
explicit move_only_function(in_place_type_t<T>, initializer_list<U> ilist, Args&&... args);
[…]
template<auto f, class T, class... Args>
explicit move_only_function(
nontype_t<f>,
in_place_type_t<T>,
Args&&... args);
Let VT
be decay_t<T>
.
Constraints:
is_constructible_v<VT, ArgTypes...>
is true
, and
is-callable-as-if-from<f, VT>
is true
.
Mandates: VT
is the same type as T
.
Preconditions: VT
meets the Cpp17Destructible requirements, and if is_move_constructible_v<VT>
is true
, VT
meets the Cpp17MoveConstructible requirements.
Postconditions: *this
has no state entity if f
is a null function pointer value or a null member pointer value. Otherwise, *this
has the following properties:
- Its constant target object and
f
are template-argument-equivalent [temp.type].
- It has one bound argument entity, an object of type
VT
direct-non-list-initialized with std::forward<T>(x)...
.
Throws: Any exception thrown by the initialization of the bound argument. May throw bad_alloc
unless
VT
is a specialization of reference_wrapper
, or
VT
is an object pointer and f
is of a pointer to member.
template<auto f, class T, class U, class... Args>
explicit move_only_function(
nontype_t<f>,
in_place_type_t<T>,
initializer_list<U> ilist,
Args&&... args);
Let VT
be decay_t<T>
.
Constraints:
is_constructible_v<VT, initializer_list<U>&, ArgTypes...>
is true
, and
is-callable-as-if-from<f, VT>
is true
.
Mandates: VT
is the same type as T
.
Preconditions: VT
meets the Cpp17Destructible requirements, and if is_move_constructible_v<VT>
is true
, VT
meets the Cpp17MoveConstructible requirements.
Postconditions: *this
has no state entity if f
is a null function pointer value or a null member pointer value. Otherwise, *this
has the following properties:
- Its constant target object and
f
are template-argument-equivalent [temp.type].
- It has one bound argument entity, an object of type
VT
direct-non-list-initialized with ilist, std::forward<T>(x)...
.
Throws: Any exception thrown by the initialization of the bound argument. May throw bad_alloc
unless
VT
is a specialization of reference_wrapper
, or
VT
is an object pointer and f
is of a pointer to member.
move_only_function& operator=(move_only_function&& f);
[…]
move_only_function& operator=(nullptr_t) noexcept;
Effects: Destroys the target objectstate entities of *this
, if any.
Postconditions: *this
has no state entity.
Returns: *this
.
template<class F> move_only_function& operator=(F&& f);
Effects: Equivalent to: move_only_function(std::forward<F>(f)).swap(*this);
Returns: *this
.
~move_only_function();
Effects: Destroys the target objectstate entities of *this
, if any.
Modify 20.14.17.4.4 [func.wrap.move.inv] as indicated:
explicit operator bool() const noexcept;
Returns: true
if *this
has a target objectany state entity, otherwise false
.
R operator()(ArgTypes... args) cv ref noexcept(noex);
Preconditions: *this
has a target object.
Effects: Equivalent to:Let f
be an lvalue designating the target object of *this
and F
be the type of f
.
-
If *this
has a bound argument, equivalent to
return INVOKE<R>(f, static_cast<T inv-quals>(x), std::forward<ArgTypes>(args)...);
where x
is an lvalue designating the bound argument of type T
.
-
Otherwise, if *this
has a constant target object, equivalent to
return INVOKE<R>(f, std::forward<ArgTypes>(args)...);
-
Otherwise, equivalent to
return INVOKE<R>(static_cast<F inv-quals>(f), std::forward<ArgTypes>(args)...);
where f
is an lvalue designating the target object of *this
and F
is the type of f
.
Modify 20.14.17.4.5 [func.wrap.move.util] as indicated:
void swap(move_only_function& other) noexcept;
Effects: Exchanges the target objectsstate entities of *this
and other.
friend void swap(move_only_function& f1, move_only_function& f2) noexcept;
Effects: Equivalent to f1.swap(f2)
.
friend bool operator==(const move_only_function& f, nullptr_t) noexcept;
Returns: true
if f
has no target objectstate entity, otherwise false
.
Add new signatures to [func.wrap.move.class] synopsis:
[…]
template<class R, class... ArgTypes>
class move_only_function<R(ArgTypes...) cv ref noexcept(noex)> {
public:
using result_type = R;
// [func.wrap.move.ctor], constructors, assignment, and destructor
move_only_function() noexcept;
move_only_function(nullptr_t) noexcept;
move_only_function(move_only_function&&) noexcept;
template<auto f> move_only_function(nontype_t<f>) noexcept;
template<class F> move_only_function(F&&);
template<auto f, class T> move_only_function(nontype_t<f>, T&&);
template<class T, class... Args>
explicit move_only_function(in_place_type_t<T>, Args&&...);
template<class T, class U, class... Args>
explicit move_only_function(in_place_type_t<T>, initializer_list<U>, Args&&...);
template<auto f, class T, class... Args>
explicit move_only_function(
nontype_t<f>,
in_place_type_t<T>,
Args&&...);
template<auto f, class T, class U, class... Args>
explicit move_only_function(
nontype_t<f>,
in_place_type_t<T>,
initializer_list<U>,
Args&&...);
move_only_function& operator=(move_only_function&&);
move_only_function& operator=(nullptr_t) noexcept;
template<class F> move_only_function& operator=(F&&);
~move_only_function();
// [func.wrap.move.inv], invocation
explicit operator bool() const noexcept;
R operator()(ArgTypes...) cv ref noexcept(noex);
// [func.wrap.move.util], utility
void swap(move_only_function&) noexcept;
friend void swap(move_only_function&, move_only_function&) noexcept;
friend bool operator==(const move_only_function&, nullptr_t) noexcept;
private:
template<class... T>
static constexpr bool is-invocable-using = see below; // exposition only
template<class VT>
static constexpr bool is-callable-from = see below; // exposition only
template<auto f, class T>
static constexpr bool is-callable-as-if-from = see below; // exposition only
};
}
[Drafting note:
Missing wording for function_ref
.
–end note]
Implementation Experience
Here is an implementation of function_ref
with the nontype_t<V>
constructors; you may play with it in Godbolt. An implementation on move_only_function
will also create value-dependent forwarders but allocated in a small object optimization buffer or free store.
Acknowledgments
Thank Ryan McDougall for proofreading the paper.
References
Beyond operator(): NTTP callables in type-erased call wrappers
Introduction
Non-type template parameters (NTTP) can provide information for call wrappers to erase at compile-time, eliminating the need for binding objects at runtime when fulfilling a simple demand. This paper proposes tweaking type-erased call wrappers, such as
std::move_only_function
, with NTTP callable objects.Here is an unfair Tony Table that quickly demonstrates a motivating use case:
P2511
Motivation
Not all user-defined objects have an
operator()
. To pass a user-defined object to a parameter ofstd::move_only_function
, one must be able to use that object asBut often, the object is designed to be used as
I want to use such an object with
move_only_function
as if the type of the object aliased itssend
member function tooperator()
.Analysis
Why don’t people make their classes callable?
Given sufficient tag types, any member functions can be expressed in one overload set, namely
operator()
. But we are not in that direction and are not working with constructors – a typical context where the entity to call is anonymous. Naming functions differently is the best way to disambiguate.When there is no demand for disambiguation, people who design the class can still prefer naming their only member function in the interface “run,” “parse,” and even “apply,” since clearly,
delivers more information than
Can we do this with wrapping?
Lambda expression is an option. We can pass a closure object instead of the original object
Perfect-forwarding will need more boilerplate
And don’t forget that we are using
obj
only as an lvalue in this example. A lambda’s captures are unaware of the value category of the closure object unless usingstd::forward_like
[1] together with an explicit object parameter.Let’s just say that lambda expression is not suitable for expressing the intention of designating a differently named member function.
And it’s a part of the reason why we have
std::bind_front
. You can rewrite the example above asBut
bind_front
incurs a cost. Sometimes, the space penalty – size of a pointer to member function, is greater than theobj
itself. Other than that, the codegen cost is also high.Why the ask has to have something to do with type-erasure?
Let’s recall function pointers – the most basic form of type-erasures. You can assign different functions to the same function pointer:
At first glance, here we did not erase type. The turth is that we erased “nontype” compile-time information –
&strcmp
and&strcasecmp
. In a hypothetical language, every function could be of different types, like what the following piece of legal C++ code shows:There are two ways to understand this:
T
from the closure.They are equivalent, as you may get those nontype values back into types anytime:
Type erasure is called “type erasure” because it makes code that should depend on different types, not dependent. It wouldn’t be surprising if the code supposed to be dependent were value-dependent. So it seems that a type-erased call wrapper is suitable for erasing the nontype information,
&Cls::send
, from the expressionto make the code that uses
obj
depends on neither&Cls::send
norCls
, where the latter case is what being erased ifobj
were callable.Proposal
This proposal consists of two parts. First, it adds
nontype_t
andnontype
to the<utility>
header. They are similar toin_place_type_t
andin_place_type
, except each of the former two accepts a nontype template parameter withauto
rather than type template parameters.Second, it adds
nontype_t<V>
tomove_only_function
’s constructors to allow you to do the following things:In the first case, the
move_only_function
parameter ownsobj
. In the other cases, the parameter holds a reference toobj
. This design mirrors theINVOKE
protocol when the argument we pass tonontype
is a pointer-to-member.Whether
Cls::send
is an explicit object member function, case 1 continues to work. Case 3 continues to unwrapreference_wrapper
becausemove_only_function
’s converting constructor (F&&
) has such a behavior. Case 2 stops working.This means, if you have a non-member function with a signature like
R(Cls, ...)
, you can use it in place ofoperator()
as well:Another constructor converts a single
nontype<V>
tomove_only_function
. It is merely a shortcut to initialize from a callable object if we can pass it using a nontype template parameter.We propose adding the one-argument and two-argument
nontype_t
constructors tofunction_ref
[2]. However, no wording is attached in this revision, as P0792’s wording is under an overhaul. So instead, we will briefly describe the semantics here: The two-argumentnontype_t
constructor overload set holds a reference toobj
regardless of whether the user passes it through areference_wrapper
.obj
is only used as an lvalue to followfunction_ref
’s design logic.The
nontype_t<V>
parameters are also prepended tomove_only_function
’s existingin_place_type_t<T>
constructors. There is no corresponding change forfunction_ref
.Discussion
How do other programming languages solve this problem?
Java® did not designate a magic method serving
operator()
's role. Instead, any interface with a single abstract method is deemed a functional interface. When passing a lambda expression to a parameter of a functional interface, Java produces a closure object that implements this interface. So it doesn’t matter what the method’s name is; it may bevoid accept(T)
,R apply(T)
, etc. But you don’t have to use a lambda if yourobj
doesn’t implement the functional interface. Method references are a more straightforward way to produce a closure object. For example,obj::consume
can make a closure object supporting theaccept
method.Python designates
__call__
to be the magic method to make an object callable. If you want to call a different method to fulfill thetyping.Callable
requirement, you may pass a bound method likeobj.send
. A method in Python has a__self__
attribute to store the class instance.C♯, similar to Java, doesn’t support overloading the call operator. However, its delegate language facility allows quickly defining a functional interface. Unlike Java, you cannot “implement” a delegate, so you must create delegate objects using a syntax similar to Python’s bound methods.
In a parallel universe, C++ with C++0x Concepts provides concept maps as a general mechanism for adapting a de facto interface to be used in a component that expects a common, but different interface. Here, to enable type
Cls
for being used inmove_only_function
, we can specify a mapping using Richard’s generalized alias declaration[3]:To make this adaptation local to the users’ code, they can define the
concept_map
in their own namespace.[4]Why this proposal is different from delegates and other solutions?
The solution given by concept maps has the right model. But invocable is not only a concept. It is centralized in a programming paradigm. That might be why the other solutions widely used in practice allow forming the adaptations that are different from use to use.
The rest of the solutions, such as delegates, are all language features that work only with member functions.
However, in C++, until C++20, functional interface means
std::function
. There are alsopackaged_task
,folly::Function
,llvm::function_ref
… There is no generic functional interface that fits all needs.We are proposing a framework for enabling designating a different
operator()
when initializing any functional interface in C++. A third-party library can also addnontype_t<V>
to their type-erased call wrappers’ constructor overload sets. TheV
they accept may be more permissive or restrictive, the storage policy they chose may be adaptive or pooled, but the end-users can enjoy expressing the same idea using the same syntax.And a C++ class’s interface consists not only of member functions. The NTTP callable,
V
, can be a pointer to explicit object member function – in other words, you can treat a free function as that member function. You can even rewrite thatobj
’s call operator with another structural object with anoperator()
:The proposed solution supports all these to satisfy users’ expectations for C++.
Should we add those constructors to all type-erased call wrappers?
I.e.,
std::function
andpackaged_task
.move_only_function
andfunction_ref
mean to replacestd::function
.std::function
did not addmove_only_function
’sin_place_type_t<T>
constructors even though that does not break ABI. These leave the author an impression that we are not further polishingstd::function
.The typical uses of
packaged_task
do not seem to value codegen high enough to justify adding thenontype_t<V>
constructors.Should type-passing call wrappers support NTTP callables?
Strictly speaking, they are outside the scope of this paper. But some type-passing call wrappers that require factory functions have an attractive syntax when accepting NTTP callables – you can pass them in function templates’ template-argument-list. So let me break down the typical ones:
bind_front<V>
,not_fn<V>()
, andmem_fn<V>()
.Supporting
bind_front<&Cls::send>(obj)
eliminates the space penalty ofbind_front(&Cls::send, obj)
. But, please be aware that ifbind_front
appears alone, the compiler has no pressure optimizing type-passing code. Hence, the new form only makes sense if a type-erasure later erases the underlying wrapper object. But a type-erased call wrapper already requires wrapping the target object. This double-wrapping downgrades the usability of those call wrappers:obj
in an owning call wrapper bypassingbind_front
;bind_front<&Cls::send>(std::ref(obj))
to archive reference semantics even if this expression is about to initialize afunction_ref
, defeating half of the purpose offunction_ref
.The need of type-erasing a predicate such as
not_fn(p)
seems rare. STL algorithms that take predicates have a type-passing interface.Rather than overloading
mem_fn
to take NTTP callables, adding anoperator()
tonontype_t
will have the same, or arguably better, effect:However, it seems that the uses of
std::mem_fn
largely diminished after introducingstd::invoke
. Therefore, the author decides not to complicatenontype_t
in this revision of the paper.Can some form of lambda solve the problem?
The previous discussion (1, 2) revealed how large the design space is and how the problem ties to the library. This section will use a specific paper in the past as an example to show how these factors can affect language design.
There have been discussions about whether C++ should have expression lambda[5]
to make lambda terse. Expression lambdas aim to address the difficulty of introducing parameters. But in our motivating example, we forwarded all parameters without introducing any. So expression lambda doesn’t directly respond to the problem.
So the ask will need to expand the scope of lambda to “anything that can produce an anonymous closure object.” It is reasonable as other languages have similar sugars. For example, Java’s method references and lambda expressions share VM mechanisms.[6]
In that case, let’s prototype the ask: Why don’t we write the following and make everything work?
Recall that
q
is a container ofstd::move_only_function
from Introduction, so the first question will be whatdb.connect
means. Luckily, Andrew Sutton had an answer to that in 2016.[7] Here is an example from his paper:s.f
produces (applied minor corrections):The above suggests that, in our example,
db.connect
capturesdb
by reference.But
move_only_function
is supposed have a unique copy ofdb
! In the motivating example, westd::move(db)
into an element ofq
. So maybes
should mean “capture by value” ins.f
, and we writestd::move(db).connect
?Assume it is the case. What happens if there is another function
retry
takingfunction_ref
:Given the modified semantics, the above should mean “capture
db
by making a copy, and pass the closure by reference usingfunction_ref
.” Which is, of course, not satisfying.std::ref(db)
won’t help this time, so let’s go withNow
db.connect
and(&db)->connect
have different meanings. This implies that if we had a pointer todb
,(*p).connect
andp->connect
will have different meanings. This goes against the common expectation on C++ ([over.call.func]/2):Let’s take another angle. Instead of moving
db
, what if we want to construct an object ofDB
in the new element in place?It’s simple using a
nontype
constructor. We only need to go fromto
I don’t know of a solution that involves capturing.
DB("example.db", 100ms, true).connect
will result in moving a subobject along with the closure. And more importantly, it requiresDB
to be movable, which adds more tomove_only_function
’s minimal requirements.It seems that C++ lambdas are not only verbose to introduce parameters but also tricky to capture variables. A solution that couples naming a different
id-expression
after.
operator with captures will face problems when working with varying type-erased call wrappers.But if expression lambda solves the problem of introducing parameters, can we replace the problems caused by capturing variables with the problem we solved?
This WORKS. A captureless lambda is of a structral type. It solves the problem of selecting a particular overload or specialization when
connect
is an overload set. In Andrew’s example,Right now, to select the first overload, we have to write
nontype<(void (S::*)(int&))&S::f>
; with an expression lambda, it’s as simple asnontype<[][&1.f]>
.As long as we decoupled naming from wrapping, a language design can relieve itself from making every library happy with a single type-passing language feature that does wrapping for you. In that situation, the library additions and the language additions can evolve in parallel and work together in the end.
Can this proposal target post-C++23?
Suppose all implementations of
std::move_only_function
are prepared for this to come, yes. A naïve implementation that uses vtable can later add value-dependent subtypes without breaking ABI; others need to be careful.On the other hand, the
nontype_t<V>
constructors allow the users to bind implicit or explicit object member functions with the same cost. So it is reasonable to ship them in the same standard that shipsstd::move_only_function
and “deducing this.”[8]Prior Art
The earliest attempt to bind an object with a member function I can find is Rich Hickey’s “Callbacks in C++ Using Template Functors”[9] back in 1994.
Borland C++ has a language extension – the
__closure
keyword.[10] It is very similar to the hypothetical__bound
keyword in Rich’s article. However, you may notdelete
a pointer to closure or initialize it with a function.This proposal is inspired by P2472[11].
Wording
The wording is relative to N4901.
Add new templates to 20.2.1 [utility.syn], header
<utility>
synopsis:Add a definition to 20.14.3 [func.def]:
Modify 20.14.17.4.3 [func.wrap.move.ctor] as indicated:
Modify 20.14.17.4.4 [func.wrap.move.inv] as indicated:
Modify 20.14.17.4.5 [func.wrap.move.util] as indicated:
Add new signatures to [func.wrap.move.class] synopsis:
[Drafting note: Missing wording for
function_ref
. –end note]Implementation Experience
Here is an implementation of
function_ref
with thenontype_t<V>
constructors; you may play with it in Godbolt. An implementation onmove_only_function
will also create value-dependent forwarders but allocated in a small object optimization buffer or free store.Acknowledgments
Thank Ryan McDougall for proofreading the paper.
References
Ažman, Gašper. P2445R0 std::forward_like. http://wg21.link/p2445r0 ↩︎
Romeo, Vittorio. P0792R5 function_ref: a non-owning reference to a Callable. http://wg21.link/p0792r5 ↩︎
Smith, Richard. P0945R0 Generalizing alias declarations. http://wg21.link/p0945r0 ↩︎
Siek, Jeremy. N2098 Scoped Concept Maps. http://wg21.link/n2098 ↩︎
Revzin, Barry. Why were abbrev. lambdas rejected? https://brevzin.github.io/c++/2020/01/15/abbrev-lambdas/ ↩︎
Goetz, Brian. Translation of Lambda Expressions . https://cr.openjdk.java.net/~briangoetz/lambda/lambda-translation.html ↩︎
Sutton, Andrew. P0119R2 Overload sets as function arguments. http://wg21.link/p0119r2 ↩︎
Ažman et al. P0847R7 Deducing this. http://wg21.link/p0847r7 ↩︎
Hickey, Rich. Callbacks in C++ Using Template Functors. http://www.tutok.sk/fastgl/callback.html ↩︎
__closure. Language Support for the RAD Studio Libraries (C++). https://docwiki.embarcadero.com/RADStudio/Sydney/en/Closure ↩︎
Waterloo, J.J. make_function_ref: A More Functional function_ref. http://wg21.link/p2472 ↩︎