Is it possible to intentionally defeat guaranteed copy elision for a particular type?
Asked Answered
L

1

8

In C++17 and above, guaranteed copy elision means that it's possible to return non-moveable objects frun a chain of functions, all the way to the ultimate caller:

struct NonMoveable {
  NonMoveable() = default;
  NonMoveable(NonMoveable&&) = delete;
};

NonMoveable Foo() { return NonMoveable(); }
NonMoveable Bar() { return Foo(); }
NonMoveable Baz() { return Bar(); }

NonMoveable non_moveable = Baz();

Is there some trick to disable guaranteed copy elision for a particular type, so that it's not possible to write functions like Bar and Baz above that pass through a NonMoveable object obtained from another function? (I'm agnostic as to whether Foo should be disallowed or not.)


I know this is a weird request. If you're interested in what I'm trying to do: I'm working on a coroutines library, where the convention is that if you return a Task from a function then all of the reference parameters for that function need to remain valid until the task is ready, i.e. until a co_await expression for the task evaluates. This all totally works out fine if a coroutine is called from another coroutine, since I've arranged for the Task type to be non-moveable and accepted by value: you can't do anything with it except immediately co_await it, and any temporaries you provide in the call to the child coroutine will live until the co_await expression evaluates.

Except you can do one more thing with Task: write functions like Bar and Baz above that do return Foo() instead of co_return co_await Foo(). If there is an argument to Foo involved it might be a temporary, in which case it's safe to co_return co_await Foo(...) but not return Foo(...).

This can get surprisingly subtle. For example a std::function<Task(SomeObj)> internally contains a return statement and will happily bind to a lambda that accepts const SomeObj&. When it's called it will provide the lambda a reference to its by-value parameter, which is destroyed when the lambda suspends.

I'm looking for a more elegant way to prevent this problem, but in the meantime I'd like to make the problematic form just not compile (which also helps identify the set of problems in user code). I expect this is not possible, but perhaps there is some trick I haven't thought of.

Labdanum answered 5/7, 2022 at 9:51 Comment(6)
You could std::move it. But that disables the copy elision for movable objects too. You can use a concept / enable_if.Murrelet
I’m not trying to disable it in a particular function; I want to disable it in all functions that return a particular type. It shouldn’t be possible to return the result of another function that returns this type.Labdanum
@jacobsa: "you can't do anything with it except immediately co_await it, and any temporaries you provide in the call to the child coroutine will live until the co_await expression evaluates." You can store it in a variable and use it later. Any temporaries passed to the function will end their lifetimes once the variable is initialized, and thus are no longer valid. What you want will not fix the problem you're trying to fix.Apophthegm
Can you perhaps show some code that fails? Maybe there is a way to make it not fail, or not compile, that doesn't involve weird and impossible stuff like disabling copy elision? IOW what's the X problem?Infecund
@NicolBolas: make sure to see the link in the text you quoted. If you own the API ecosystem around the type you can make it impossible to do anything useful with the variable, which is nearly as good as making it impossible to initialize that way, since it makes it hard to have an accidental use of this kind. (If I'm wrong in that link please let me know there.)Labdanum
@n.1.8e9-where's-my-sharem.: I shared both the reason I want this and a toy example that should be made impossible in the original question.Labdanum
A
1

You cannot disable guaranteed elision. It is a property of the concept of a prvalue in C++17 and above. It's not something any type can affect.

Furthermore, even if you could, this would not get you the effect you want. A prvalue can still be used to initialize a named variable directly. Once that happens, any temporaries used in the constructor call (for const& or && parameters) will have their lifetimes ended. If there were any reference parameters given to the constructor, they may no longer exist.

Apophthegm answered 5/7, 2022 at 13:57 Comment(7)
Regarding the first paragraph: yes, this is what I fear. I can't rule out the possibility that there is some trick I haven't thought of though.Labdanum
Regarding the second paragraph: it's always true that you could initialize a Task variable directly, but it's possible to restrict the APIs that accept a Task so that you can't actually do anything with it afterward, which is almost as good because it makes it hard to do by accident. This is an example of the sort of trick I'm hoping to find here.Labdanum
@jacobsa: "it's possible to restrict the APIs that accept a Task so that you can't actually do anything with it afterward" And thereby make the API useless. If your API forces me to use a coroutine, to explicitly make my code halt its execution until your code continues it for me, your API is dysfunctional. Pretty much every coroutine API has at least a way for you to say "I want to wait, synchronously, for the value, right now". If your API can't do that, then it's not a good API.Apophthegm
I think you're imagining something different than I'm describing. The point is that the relevant promise method's signature is void await_transform(Task), accepting by value so that you must immediately co_await and therefore we can count on all reference parameters remaining valid. That's exactly the API you describe. There is a separate API for manipulating a running coroutine as a value and joining later without the need to require reference parameters to remain valid, but that is necessarily less efficient.Labdanum
Oh I guess you're talking about the bridge from synchronous code. But that's also totally unrelated to whether it accepts by value: void WaitForTask(Task) also accomplishes what we want (temporaries used in the call to the Task-producing coroutine won't be destroyed until the task finishes).Labdanum
@jacobsa: But I can't give that task to someone else. I can't store that task in another location which will deal with it in its own time. I must, immediately upon generation, deal with it however it will be dealt with. That's insane for any construct calling itself a "task", all to "preserve reference parameters." Here's a better idea: don't allow them. Do what std::thread does and copy arguments by default. If it's important to ensure that arguments are still alive, then make them alive; don't break your API by forcing people into narrow use cases.Apophthegm
Yes, that’s the other API I referred to, which works like std::thread, copies arguments, and is moveable and joinable later. In fact I agree and it’s called Task while the non moveable one is called something else that’s not germane to this question. Distinguishing the two is useful because the common case is not needing to do anything but immediately await the result of a call, so you can be significantly more efficient if you’re not forced to copy arguments.Labdanum

© 2022 - 2024 — McMap. All rights reserved.