Reputation: 6609
I have a generic function that invokes a callable it's handed; the moral equivalent of a call to std::invoke
, except it's part of a coroutine:
// Wait for some condition to be true, then invoke f with the supplied
// arguments. The caller must ensure that all references remain valid
// until the returned task is done.
template <typename F, typename Args...>
Task<void> WaitForConditionAndInvoke(F&& f, Args&&... args) {
co_await SomeCondition();
std::invoke(f, std::forward<Args>(args)...);
}
Because of the requirement that the input arguments remain valid, it's not always legal to pass WaitForConditionAndInvoke
a temporary like a lambda (unless e.g. the returned task is directly co_await
ed as part of a single expression). This is true even if using unary +
to convert a lambda with no bound state to a function pointer: the function pointer is itself a temporary, and asan seems to correctly complain about using it after it's destroyed.
My question is whether using a member function pointer is legal:
struct Object {
void SomeMethod();
};
// Some global object; we're not worried about the object's lifetime.
Object object;
Task<void> WaitForConditionAndInvokeOnGlobalObject() {
return WaitForConditionAndInvoke(&Object::SomeMethod, &object);
}
This seems to work fine, but it's unclear to me what the lifetime of the pointer that &Object::SomeMethod
evaluates to is. Is this guaranteed to be a constant expression, i.e. not a temporary? What part of the standard covers this?
Upvotes: 3
Views: 173
Reputation: 27290
As the commenters say, there's no lifetime issue here. &Object::SomeMethod
is a constant expression, just like 42
or &WaitForConditionAndInvokeOnGlobalObject
.
You might have confused yourself by naming your type struct Object
, so the expression &Object::SomeMethod
kind of looks like it involves an object... but it doesn't; Object
is the name of a type. There is no object involved in that expression; it's a simple compile-time constant, just like offsetof(Object, SomeDataMember)
or sizeof(Object)
would be.
No object involved = no lifetime issues involved.
EDIT (thus completely changing my answer): Ah, aschepler is right, you're concerned with the lifetime of the thing-referred-to-by-F&& f
in
template <typename F, typename Args...>
Task<void> WaitForConditionAndInvoke(F&& f, Args&&... args) {
co_await SomeCondition();
std::invoke(f, std::forward<Args>(args)...);
}
which is the temporary object with value &Object::SomeMethod
, which of course dies at the end of its full-expression, i.e., at the semicolon at the end of return WaitForConditionAndInvoke(&Object::SomeMethod, &object);
. As soon as you hit that semicolon, all of the temporaries go out of scope and any references to them (like, that f
captured in the coroutine frame) are definitely dangling.
Upvotes: -1
Reputation: 72431
That WaitForConditionAndInvoke
coroutine will be dangerous unless every argument including the functor f
refers to an object with lifetime long enough. For example, WaitForConditionAndInvoke(std::abs, 1)
has undefined behavior because of the object materialized to initialize a reference with the int
prvalue expression 1
. There is no difference per the Standard for constant expression arguments here, although a constant expression value could help compilers implement it in a way which "works" using a dead object's known value.
To fix this, you could have your function move every rvalue argument into a local object:
// All rvalue arguments are moved from.
// The caller must make sure all lvalue arguments remain valid until
// the returned task is done.
template <typename F, typename Args...>
Task<void> WaitForConditionAndInvoke(F&& f, Args&&... args) {
// local_f could be declared an lvalue reference or object,
// but not an rvalue reference:
F local_f(std::forward<F>(f));
// Similarly, the template arguments to tuple are lvalue references
// or object types.
std::tuple<Args...> local_args(std::forward<Args>(args)...);
co_await SomeCondition();
std::apply(std::forward<F>(local_f), std::move(local_args));
}
Or to be even safer, do as std::bind
does, and move/copy everything. The calling code can specify that the functor or functor argument(s) should be treated as a reference with no move or copy using std::ref
or std::cref
. In fact, that implementation is just:
// All arguments are moved or copied from.
// A std::reference_wrapper can be used to skip the move/copy of
// the referenced object.
template <typename F, typename Args...>
Task<void> WaitForConditionAndInvoke(F&& f, Args&&... args) {
auto bound_f = std::bind(std::forward<F>(f), std::forward<Args>(args)...);
co_await SomeCondition();
bound_f();
}
Upvotes: 3