Ryan McDougall
2018-10-22 18:18:46 UTC
âThis is motivated by increasing usage of things like executors and the task
queue where it is useful to embed move-only types like a std::promise within
a type erased function. That isn't possible without this version of a type
erased function.â -- Chandler Carruth[1]
Thanks
âstd::function and Beyondâ N4159
<http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2014/n4159.pdf>,
âQualified std::function Signaturesâ P0045R1
<http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2017/p0045r1.pdf>, and
âA polymorphic wrapper for all Callable objects (rev. 3)â P0288R1
<http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2017/p0288r1.pdf> et
al. for laying the groundwork for this paper. Thank you to David Krauss and
Arthur OâDwyer for your discussion and feedback. Thank you all authors of
alternative type erased callable containers for your proof and inspiration.
Motivation
std::function models both CopyConstructible and CopyAssignable, and
requires its erased target type to declare a copy constructor. This means
std::function is hopelessly incompatible with std::unique_ptr,
std::promise, or any other move-only type. This is a functional gap felt by
C++ users to the degree that thereâs some half-dozen popular and
high-quality implementations available under the name âunique_functionâ.
[2][3][4][5][6]
C++ has many move-only vocabulary types, and when introduced they impose
tighter constraints on the interface, and can become âviralâ -- causing any
previously copyable paths to require move or forwarding operations. Consider
class DbResult {
private:
std::unique_ptr<Blob> data_; // now required!
};
class Reactor {
private:
std::map<std::string, std::function<void()>> reactions_;
};
reactor.on_event(âdb-result-readyâ, [&] {
reactor.on_event(âhas-towelâ, [result = std::move(db.result())] {
auto meaning = find(result); // 42
});
});
It is not enough to simply std::move the DbResult into the lambda, as
Reactor::on_event is unable to assign to a move-only lambda as implemented
with std::function.
This is a recurring pattern in much concurrent code, such as work queues,
task graphs, or command buffers. The committee implicitly understood the
need when it created std::packaged_task, also a type-erased polymorphic
container, but that type is tightly bound to std::futures, which may not be
fit for purpose in any code base that doesnât already rely on them.
If we are developing any kind of asynchronous work queue we need
1.
inheritance based polymorphism (and a mechanism to manage heap allocated
derived objects, such as std::shared_ptr)
2.
type-erased container with small object optimization like std::function
(for copy-only callable types), std::packaged_task+std::future (for
move-only callable types)
However if any facet of our runtime precludes use of std::future -- such as
if it provides its own future type, or does not use futures at all, we are
again left without the ability to use std::unique_ptr or any other
non-copyable type.
auto unique =
std::make_unique<BankTransfer>(âDrEvilâ, 1000000000);
auto do_bank_transfer =
[transfer = std::move(unique)] (Bank* to, Bank* from) {
return from->send(transfer, to);
};
ThreadSafeQueue<std::function<int()>> transactions1;
transactions1.emplace(do_bank_transfer); // Compile Error!!
// ...
ThreadSafeQueue<std::packaged_task<int()>> transactions2;
ThreadSafeQueue<int> results
transactions2.emplace(do_bank_transfer);
hpy::async([&] {
while (!transactions2.empty()) {
transactions2.top()();
results.push_back(transactions2.top().get_future()); // ??
}
});
In the above example we simply present wasted human time and computational
time due to an unnecessary synchronization with std::future, however a more
complex system may indeed need their own future implementation which
std::packaged_task cannot interoperate with at all.
Comparison Table
std::promise<Foo> p;
std::unique_ptr<Foo> f;
std::queue<std::function<void()>> a;
std::queue<std::unique_function<void()>> b;
using namespace std;
Before Proposal
After Proposal
1
auto s = make_shared<Foo>(move(f));
a.emplace([s] { s->work(); });
// ...
auto shared = a.top();
shared();
b.emplace([u = move(f)] {
u->work();
});
// ...
auto unique = move(b.top());
unique();
2
a.emplace([r = f.get()] {
r->work();
});
b.emplace([u = move(f)] {
u->work();
});
3
atomic<Foo*> result{nullptr};
a.emplace([&result] {
result = new Foo;
});
// ...
spin_on(result);
result->work();
auto future = p.get_future();
b.emplace([p = move(p)] {
p.set_value(Foo{});
});
// ...
future.get().work();
As you can see, attempts to work around the limitation of std::function
results in unacceptable undermining of uniqueness semantics, life-time
safety, and/or ease of use:
1.
Loss of move-only semantics and extra LoC and a heap allocation per
shared pointer.
2.
Queue lifetime must not exceed enclosing scope lifetime (or dangling
pointer).
3.
Poor usability or unnecessary complexity in search of better performance.
Alternatives
Papers âstd::function and Beyondâ N4159
<http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2014/n4159.pdf>, âA
polymorphic wrapper for all Callable objects (rev. 3)â P0288R1
<http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2017/p0288r1.pdf> have
already argued for fixing std::function to support non-copyable types
(among other issues) some time ago. Yet it seems self evident that as long
and as widely as std::function has been in use, any change that breaks the
observable surface area of std::function is a non-starter. The question is
would the use case we outlined herein, if overlaid onto the existing
std::function, cause previously valid code to break, or conversely
previously incorrect code to become easily written?
Letâs assume we have a means of âfixingâ std::function to allow mixing of
copy and move semantics. Clearly all existing code would continue to
function as all existing target callable types are CopyConstructible and
CopyAssignable, but what if we mix erased instances with copy and move
semantics? How should the following operations on std::function be defined
in terms of their target callable types?
Letâs temporarily ignore the details of memory management, and consider p
to be the underlying pointer to the erased instance.
std2::function<void()> c = C{}; // Copy-only
std2::function<void()> m = M{}; // Move-only
Operation
Definition
Result
c = std::move(m);
*((M*)c.p) = move(*((M*)m.p));
Move
m = std::move(c);
*((C*)c.p) = move(*((C*)m.p));
Copy
c = m;
*((M*)c.p) = *((M*)m.p);
Throw ???
m = c;
*((C*)c.p) = *((C*)m.p);
Copy
Again we face an unacceptable undermining of uniqueness semantics; in
addition we have changed the observable surface area of assignment by
necessitating a new runtime error reporting mechanism for when erased
targets conflicting behavior, affecting the exception-safety of existing
code.
Shallow v. Deep Const
âShallowâ or âDeepâ const in a type erased context means whether the
constness of the erasing type is extended towards the erased type. For our
discussion we consider whether the the erasing container is const callable
if and only if the underlying type is const callable.
It is by now understood that the standard requires const correctness to
imply thread-safety, and if the container operator() is const, the
underlying callable typeâs operator() must also be const in order to hope
of satisfying this obligation. So a shallow const container could not admit
a thread-safe call operation in general, and both N4159
<http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2014/n4159.pdf> and
P0045R1
<http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2017/p0045r1.pdf> draw
attention to the unfortunate outcome. The solution presented in those
papers it to include the constness of the callable in signature, and to
have the constness of container operator() be conditional on the signature.
struct Accessor {
void operator()() const;
// ...
} access;
struct Mutator {
void operator()();
// ...
} mutate;
std3::function<void() const> a = access; // ok
std3::function<void()> b = access; // ok
std3::function<void()> c = mutate; // ok
std3::function<void() const> d = mutate; // compile error
a = b; // compile error: target type more restrictive
b = a; // ok
b = c; // ok
This proposal, while in the authorâs opinion is highly recommended
improvement, itâs best presented in referenced papers, and for simplicityâs
sake isnât pursued further here.
Otherwise, without the above but desiring deep const semantics, we would
wish that const containers require const callable targets. However, since
we have erase the constness of target instance, we are left with throwing
when deep const is violated, breaking existing exception-safe code.
const std4::function<void()> a; // deep const
std::function<void()> b = mutate; // non-const target
a = b; // target copied
a(); // now throws!
Necessity and Cost
We can all agree type erased containers should support as much as feasible
the const correctness of its target types, but we began our argument with a
specific asynchronous use case that involved deferred computations, often
invoked on foreign threads. If this pattern as seen âin the wildâ makes use
of thread safe queues, is thread safety of the container itself actually
sufficient to justify the costs associated with extra synchronization or
exception safety? Even if we can guarantee const container only calls const
target methods, we still cannot guarantee the target itself makes the
connection between const and thread safety.
Should a proposed std::unique_function emulate âbrokenâ shallow const
correctness of std::function for uniformity, or is âfixingâ the
contradiction worth breaking runtime changes? And is std::unique_/function
more like a container or pointer (where const is understood to be shallow),
or is it more like an opaque type (where const is generally considered to
be deep)? Historically we allow container const to vary independently of
parameterized types when they exist, suggesting std::function should remain
a shallow container.
While this question is relevant to the specification of
std::unique_function, it is ultimately orthogonal to the question of the
need for std::unique_function to exist, which is this paperâs main concern.
Conclusion
So long as we have move-only lambdas and type erased callable containers, a
move-only capable std::function is required. As uniqueness semantics of
container are orthogonal to const-correctness of the interface we recommend
not conflating the two, and pursuing P0045R1
<http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2017/p0045r1.pdf> for
const-correctness as an independent feature request.
References
[1] https://reviews.llvm.org/D48349
[2] http://llvm.org/doxygen/FunctionExtras_8h_source.html#l00046
[3]
https://github.com/STEllAR-GROUP/hpx/blob/master/hpx/util/unique_function.hpp
[4]
https://github.com/potswa/cxx_function/blob/master/cxx_function.hpp#L1192
[5]
https://github.com/Naios/function2/blob/master/include/function2/function2.hpp#L1406
[6] https://github.com/facebook/folly/blob/master/folly/Function.h
queue where it is useful to embed move-only types like a std::promise within
a type erased function. That isn't possible without this version of a type
erased function.â -- Chandler Carruth[1]
Thanks
âstd::function and Beyondâ N4159
<http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2014/n4159.pdf>,
âQualified std::function Signaturesâ P0045R1
<http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2017/p0045r1.pdf>, and
âA polymorphic wrapper for all Callable objects (rev. 3)â P0288R1
<http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2017/p0288r1.pdf> et
al. for laying the groundwork for this paper. Thank you to David Krauss and
Arthur OâDwyer for your discussion and feedback. Thank you all authors of
alternative type erased callable containers for your proof and inspiration.
Motivation
std::function models both CopyConstructible and CopyAssignable, and
requires its erased target type to declare a copy constructor. This means
std::function is hopelessly incompatible with std::unique_ptr,
std::promise, or any other move-only type. This is a functional gap felt by
C++ users to the degree that thereâs some half-dozen popular and
high-quality implementations available under the name âunique_functionâ.
[2][3][4][5][6]
C++ has many move-only vocabulary types, and when introduced they impose
tighter constraints on the interface, and can become âviralâ -- causing any
previously copyable paths to require move or forwarding operations. Consider
class DbResult {
private:
std::unique_ptr<Blob> data_; // now required!
};
class Reactor {
private:
std::map<std::string, std::function<void()>> reactions_;
};
reactor.on_event(âdb-result-readyâ, [&] {
reactor.on_event(âhas-towelâ, [result = std::move(db.result())] {
auto meaning = find(result); // 42
});
});
It is not enough to simply std::move the DbResult into the lambda, as
Reactor::on_event is unable to assign to a move-only lambda as implemented
with std::function.
This is a recurring pattern in much concurrent code, such as work queues,
task graphs, or command buffers. The committee implicitly understood the
need when it created std::packaged_task, also a type-erased polymorphic
container, but that type is tightly bound to std::futures, which may not be
fit for purpose in any code base that doesnât already rely on them.
If we are developing any kind of asynchronous work queue we need
1.
inheritance based polymorphism (and a mechanism to manage heap allocated
derived objects, such as std::shared_ptr)
2.
type-erased container with small object optimization like std::function
(for copy-only callable types), std::packaged_task+std::future (for
move-only callable types)
However if any facet of our runtime precludes use of std::future -- such as
if it provides its own future type, or does not use futures at all, we are
again left without the ability to use std::unique_ptr or any other
non-copyable type.
auto unique =
std::make_unique<BankTransfer>(âDrEvilâ, 1000000000);
auto do_bank_transfer =
[transfer = std::move(unique)] (Bank* to, Bank* from) {
return from->send(transfer, to);
};
ThreadSafeQueue<std::function<int()>> transactions1;
transactions1.emplace(do_bank_transfer); // Compile Error!!
// ...
ThreadSafeQueue<std::packaged_task<int()>> transactions2;
ThreadSafeQueue<int> results
transactions2.emplace(do_bank_transfer);
hpy::async([&] {
while (!transactions2.empty()) {
transactions2.top()();
results.push_back(transactions2.top().get_future()); // ??
}
});
In the above example we simply present wasted human time and computational
time due to an unnecessary synchronization with std::future, however a more
complex system may indeed need their own future implementation which
std::packaged_task cannot interoperate with at all.
Comparison Table
std::promise<Foo> p;
std::unique_ptr<Foo> f;
std::queue<std::function<void()>> a;
std::queue<std::unique_function<void()>> b;
using namespace std;
Before Proposal
After Proposal
1
auto s = make_shared<Foo>(move(f));
a.emplace([s] { s->work(); });
// ...
auto shared = a.top();
shared();
b.emplace([u = move(f)] {
u->work();
});
// ...
auto unique = move(b.top());
unique();
2
a.emplace([r = f.get()] {
r->work();
});
b.emplace([u = move(f)] {
u->work();
});
3
atomic<Foo*> result{nullptr};
a.emplace([&result] {
result = new Foo;
});
// ...
spin_on(result);
result->work();
auto future = p.get_future();
b.emplace([p = move(p)] {
p.set_value(Foo{});
});
// ...
future.get().work();
As you can see, attempts to work around the limitation of std::function
results in unacceptable undermining of uniqueness semantics, life-time
safety, and/or ease of use:
1.
Loss of move-only semantics and extra LoC and a heap allocation per
shared pointer.
2.
Queue lifetime must not exceed enclosing scope lifetime (or dangling
pointer).
3.
Poor usability or unnecessary complexity in search of better performance.
Alternatives
Papers âstd::function and Beyondâ N4159
<http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2014/n4159.pdf>, âA
polymorphic wrapper for all Callable objects (rev. 3)â P0288R1
<http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2017/p0288r1.pdf> have
already argued for fixing std::function to support non-copyable types
(among other issues) some time ago. Yet it seems self evident that as long
and as widely as std::function has been in use, any change that breaks the
observable surface area of std::function is a non-starter. The question is
would the use case we outlined herein, if overlaid onto the existing
std::function, cause previously valid code to break, or conversely
previously incorrect code to become easily written?
Letâs assume we have a means of âfixingâ std::function to allow mixing of
copy and move semantics. Clearly all existing code would continue to
function as all existing target callable types are CopyConstructible and
CopyAssignable, but what if we mix erased instances with copy and move
semantics? How should the following operations on std::function be defined
in terms of their target callable types?
Letâs temporarily ignore the details of memory management, and consider p
to be the underlying pointer to the erased instance.
std2::function<void()> c = C{}; // Copy-only
std2::function<void()> m = M{}; // Move-only
Operation
Definition
Result
c = std::move(m);
*((M*)c.p) = move(*((M*)m.p));
Move
m = std::move(c);
*((C*)c.p) = move(*((C*)m.p));
Copy
c = m;
*((M*)c.p) = *((M*)m.p);
Throw ???
m = c;
*((C*)c.p) = *((C*)m.p);
Copy
Again we face an unacceptable undermining of uniqueness semantics; in
addition we have changed the observable surface area of assignment by
necessitating a new runtime error reporting mechanism for when erased
targets conflicting behavior, affecting the exception-safety of existing
code.
Shallow v. Deep Const
âShallowâ or âDeepâ const in a type erased context means whether the
constness of the erasing type is extended towards the erased type. For our
discussion we consider whether the the erasing container is const callable
if and only if the underlying type is const callable.
It is by now understood that the standard requires const correctness to
imply thread-safety, and if the container operator() is const, the
underlying callable typeâs operator() must also be const in order to hope
of satisfying this obligation. So a shallow const container could not admit
a thread-safe call operation in general, and both N4159
<http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2014/n4159.pdf> and
P0045R1
<http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2017/p0045r1.pdf> draw
attention to the unfortunate outcome. The solution presented in those
papers it to include the constness of the callable in signature, and to
have the constness of container operator() be conditional on the signature.
struct Accessor {
void operator()() const;
// ...
} access;
struct Mutator {
void operator()();
// ...
} mutate;
std3::function<void() const> a = access; // ok
std3::function<void()> b = access; // ok
std3::function<void()> c = mutate; // ok
std3::function<void() const> d = mutate; // compile error
a = b; // compile error: target type more restrictive
b = a; // ok
b = c; // ok
This proposal, while in the authorâs opinion is highly recommended
improvement, itâs best presented in referenced papers, and for simplicityâs
sake isnât pursued further here.
Otherwise, without the above but desiring deep const semantics, we would
wish that const containers require const callable targets. However, since
we have erase the constness of target instance, we are left with throwing
when deep const is violated, breaking existing exception-safe code.
const std4::function<void()> a; // deep const
std::function<void()> b = mutate; // non-const target
a = b; // target copied
a(); // now throws!
Necessity and Cost
We can all agree type erased containers should support as much as feasible
the const correctness of its target types, but we began our argument with a
specific asynchronous use case that involved deferred computations, often
invoked on foreign threads. If this pattern as seen âin the wildâ makes use
of thread safe queues, is thread safety of the container itself actually
sufficient to justify the costs associated with extra synchronization or
exception safety? Even if we can guarantee const container only calls const
target methods, we still cannot guarantee the target itself makes the
connection between const and thread safety.
Should a proposed std::unique_function emulate âbrokenâ shallow const
correctness of std::function for uniformity, or is âfixingâ the
contradiction worth breaking runtime changes? And is std::unique_/function
more like a container or pointer (where const is understood to be shallow),
or is it more like an opaque type (where const is generally considered to
be deep)? Historically we allow container const to vary independently of
parameterized types when they exist, suggesting std::function should remain
a shallow container.
While this question is relevant to the specification of
std::unique_function, it is ultimately orthogonal to the question of the
need for std::unique_function to exist, which is this paperâs main concern.
Conclusion
So long as we have move-only lambdas and type erased callable containers, a
move-only capable std::function is required. As uniqueness semantics of
container are orthogonal to const-correctness of the interface we recommend
not conflating the two, and pursuing P0045R1
<http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2017/p0045r1.pdf> for
const-correctness as an independent feature request.
References
[1] https://reviews.llvm.org/D48349
[2] http://llvm.org/doxygen/FunctionExtras_8h_source.html#l00046
[3]
https://github.com/STEllAR-GROUP/hpx/blob/master/hpx/util/unique_function.hpp
[4]
https://github.com/potswa/cxx_function/blob/master/cxx_function.hpp#L1192
[5]
https://github.com/Naios/function2/blob/master/include/function2/function2.hpp#L1406
[6] https://github.com/facebook/folly/blob/master/folly/Function.h
--
You received this message because you are subscribed to the Google Groups "ISO C++ Standard - Future Proposals" group.
To unsubscribe from this group and stop receiving emails from it, send an email to std-proposals+***@isocpp.org.
To post to this group, send email to std-***@isocpp.org.
To view this discussion on the web visit https://groups.google.com/a/isocpp.org/d/msgid/std-proposals/CAHn%2BA5NyaUQjAeudtObjn08Trz4iJ%2Bu-GGt2J5VnmtmZJiPVxg%40mail.gmail.com.
You received this message because you are subscribed to the Google Groups "ISO C++ Standard - Future Proposals" group.
To unsubscribe from this group and stop receiving emails from it, send an email to std-proposals+***@isocpp.org.
To post to this group, send email to std-***@isocpp.org.
To view this discussion on the web visit https://groups.google.com/a/isocpp.org/d/msgid/std-proposals/CAHn%2BA5NyaUQjAeudtObjn08Trz4iJ%2Bu-GGt2J5VnmtmZJiPVxg%40mail.gmail.com.