8
votes

Is it possible to write C++ code where we rely on the return value optimization (RVO) when possible, but fall back on move semantics when not? For example, the following code can not use the RVO due to the conditional, so it copies the result back:

#include <iostream>

struct Foo {
    Foo() {
        std::cout << "constructor" << std::endl;
    }
    Foo(Foo && x) {
        std::cout << "move" << std::endl;
    }
    Foo(Foo const & x) {
        std::cout << "copy" << std::endl;
    }
    ~Foo() {
        std::cout << "destructor" << std::endl;
    }
};

Foo f(bool b) {
    Foo x;
    Foo y;
    return b ? x : y;  
}

int main() {
   Foo x(f(true));
   std::cout << "fin" << std::endl;
}

This yields

constructor
constructor
copy
destructor
destructor
fin
destructor

which makes sense. Now, I could force the move constructor to be called in the above code by changing the line

    return b ? x : y;  

to

    return std::move(b ? x : y);

This gives the output

constructor
constructor
move
destructor
destructor
fin
destructor

However, I don't really like to call std::move directly.

Really, the issue is that I'm in a situation where I absolutely, positively, can not call the copy constructor even when the constructor exists. In my use case, there's too much memory to copy and although it'd be nice to just delete the copy constructor, it's not an option for a variety of reasons. At the same time, I'd like to return these objects from a function and would prefer to use the RVO. Now, I don't really want to have to remember all of the nuances of the RVO when coding and when it's applied an when it's not applied. Mostly, I want the object to be returned and I don't want the copy constructor called. Certainly, the RVO is better, but the move semantics are fine. Is there a way to the RVO when possible and the move semantics when not?


Edit 1

The following question helped me figure out what's going on. Basically, 12.8.32 of the standard states:

When the criteria for elision of a copy operation are met or would be met save for the fact that the source object is a function parameter, and the object to be copied is designated by an lvalue, overload resolution to select the constructor for the copy is first performed as if the object were designated by an rvalue. If overload resolution fails, or if the type of the first parameter of the selected constructor is not an rvalue reference to the object’s type (possibly cv-qualified), overload resolution is performed again, considering the object as an lvalue. [ Note: This two-stage overload resolution must be performed regardless of whether copy elision will occur. It determines the constructor to be called if elision is not performed, and the selected constructor must be accessible even if the call is elided. —end note ]

Alright, so to figure out what the criteria for a copy elison are, we look at 12.8.31

in a return statement in a function with a class return type, when the expression is the name of a non-volatile automatic object (other than a function or catch-clause parameter) with the same cvunqualified type as the function return type, the copy/move operation can be omitted by constructing the automatic object directly into the function’s return value

As such, if we define the code for f as:

Foo f(bool b) {
    Foo x;
    Foo y;
    if(b) return x;
    return y;
}

Then, each of our return values is an automatic object, so 12.8.31 says that it qualifies for copy elison. That kicks over to 12.8.32 which says that the copy is performed as if it were an rvalue. Now, the RVO doesn't happen because we don't know a priori which path to take, but the move constructor is called due to the requirements in 12.8.32. Technically, one move constructor is avoided when copying into x. Basically, when running, we get:

constructor
constructor
move
destructor
destructor
fin
destructor

Turning off elide on constructors generates:

constructor
constructor
move
destructor
destructor
move
destructor
fin
destructor

Now, say we go back to

Foo f(bool b) {
    Foo x;
    Foo y;
    return b ? x : y;
}

We have to look at the semantics for the conditional operator in 5.16.4

If the second and third operands are glvalues of the same value category and have the same type, the result is of that type and value category and it is a bit-field if the second or the third operand is a bit-field, or if both are bit-fields.

Since both x and y are lvalues, the conditional operator is an lvalue, but not an automatic object. Therefore, 12.8.32 doesn't kick in and we treat the return value as an lvalue and not an rvalue. This requires that the copy constructor be called. Hence, we get

constructor
constructor
copy
destructor
destructor
fin
destructor

Now, since the conditional operator in this case is basically copying out the value category, that means that the code

Foo f(bool b) {
    return b ? Foo() : Foo();
}

will return an rvalue because both branches of the conditional operator are rvalues. We see this with:

constructor
fin
destructor

If we turning off elide on constructors, we see the moves

constructor
move
destructor
move
destructor
fin
destructor

Basically, the idea is that if we return an rvalue we'll call the move constructor. If we return an lvalue, we'll call the copy constructor. When we return a non-volatile automatic object whose type matches that of the return type, we return an rvalue. If we have a decent compiler, these copies and moves may be elided with the RVO. However, at the very least, we know what constructor is called in case the RVO can't be applied.

2

2 Answers

11
votes

When the expression in the return statement is a non-volatile automatic duration object, and not a function or catch-clause parameter, with the same cv-unqualified type as the function return type, the resulting copy/move is eligible for copy elision. The standard also goes on to say that, if the only reason copy elision was forbidden was that the source object was a function parameter, and if the compiler is unable to elide a copy, the overload resolution for the copy should be done as if the expression was an rvalue. Thus, it would prefer the move constructor.

OTOH, since you are using the ternary expression, none of the conditions hold and you are stuck with a regular copy. Changing your code to

if(b)
  return x;
return y;

calls the move constructor.

Note that there is a distinction between RVO and copy elision - copy elision is what the standard allows, while RVO is a technique commonly used to elide copies in a subset of the cases where the standard allows copy elision.

6
votes

Yes, there is. Don't return the result of a ternary operator; use if/else instead. When you return a local variable directly, move semantics are used when possible. However, in your case you're not returning a local directly -- you're returning the result of an expression.

If you change your function to read like this:

Foo f(bool b) {
    Foo x;
    Foo y;
    if (b) { return x; }
    return y;
}

Then you should note that your move constructor is called instead of your copy constructor.

If you stick to returning a single local value per return statement then move semantics will be used if supported by the type.

If you don't like this approach then I would suggest that you stick with std::move. You may not like it, but you have to pick your poison -- the language is the way that it is.