“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]
“std::function and Beyond“ N4159, “Qualified std::function Signatures” P0045R1, and “A polymorphic wrapper for all Callable objects (rev. 3)” P0288R1 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.
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
inheritance based polymorphism (and a mechanism to manage heap allocated derived objects, such as std::shared_ptr)
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.
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));
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; }); | 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:
Loss of move-only semantics and extra LoC and a heap allocation per shared pointer.
Queue lifetime must not exceed enclosing scope lifetime (or dangling pointer).
Poor usability or unnecessary complexity in search of better performance.
Papers “std::function and Beyond“ N4159, “A polymorphic wrapper for all Callable objects (rev. 3)” P0288R1 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” 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 and P0045R1 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!
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.
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 for const-correctness as an independent feature request.
[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-proposal...@isocpp.org.
To post to this group, send email to std-pr...@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.
To view this discussion on the web visit https://groups.google.com/a/isocpp.org/d/msgid/std-proposals/CAJH_FNW1PVw2BeYJCWfKNKN3_67eLY_6wG1vgNJOZx9oQS%2Bs%3DQ%40mail.gmail.com.
To view this discussion on the web visit https://groups.google.com/a/isocpp.org/d/msgid/std-proposals/CAHn%2BA5NHiEO1rTKfZOryWnW-NTqLGgim6epp-Bt03iU5wd-EEA%40mail.gmail.com.
To view this discussion on the web visit https://groups.google.com/a/isocpp.org/d/msgid/std-proposals/CAJH_FNWs-rP4hc6V3pE1vTgQgtTUKfNsJOmOzsxWvwRzsC2ywA%40mail.gmail.com.
A new revision of David Krauss's paper seems like the cleanest approach to preserve history in discussions, but yes, we can definitely talk in SD. There will be a few of us there.
--
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-proposal...@isocpp.org.
To post to this group, send email to std-pr...@isocpp.org.
To view this discussion on the web visit https://groups.google.com/a/isocpp.org/d/msgid/std-proposals/CAJH_FNUBdcsx5tQa6kYLhpPO8aGck_4dG3t18Ytdh%3DfWPHTbsA%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-proposal...@isocpp.org.
To post to this group, send email to std-pr...@isocpp.org.
To view this discussion on the web visit https://groups.google.com/a/isocpp.org/d/msgid/std-proposals/MW2PR1501MB2154FB67DE8B9F102CC016EEA2F50%40MW2PR1501MB2154.namprd15.prod.outlook.com.
To view this discussion on the web visit https://groups.google.com/a/isocpp.org/d/msgid/std-proposals/DF91B617-FD32-486E-A5B4-65AB33809C5B%40me.com.
From my point of view there are 2 issues:1) Do we need std::unique_function: Yes
2) Do we want it to be move-only version of std::function, or should it be improved version where
```std::unique_function<void() const> f;``` only admits targets with const callable operators?
I think there's legitimate debate about latter, but it should not preclude the former. Should I just present it as an open question?
Lastly standardese. I don't speak it at all (yet). I can simply lift David's -- is that acceptable?
To recapitulate:
- Rename to P0288
- Fold in P0045 as an option
- Steal P0045 standardese
- Ready by friday
^ This is the request?
To view this discussion on the web visit https://groups.google.com/a/isocpp.org/d/msgid/std-proposals/CAP3wax__0hayf2KweVWRusT_xEquEkVCXRxt13tXutsZ0%2Baymw%40mail.gmail.com.