Thursday, December 24, 2015

Redundant std::move

C++11! R-value references! std::move! As people start switching to using C++11, they naturally start using std::move and rvalue references to increase the performance of the programs. Nonetheless, one of the common misuses of std::move is to apply it to a return value when it isn’t required:

Foo GetFoo() {
 Foo foo;
 …
 return std::move(foo); // std::move is not needed here.
}

The reason std::move is not required is that the C++ standard provides special rules for this situation. Specifically,

§12.8/32
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 ]

This is a lot of standard-ese, but let’s break it down in order to understand what this rule means.

When does the rule apply?

When the criteria for elision of a copy operation are met ...

First, it says that this rule applies if we run into a situation when copy elision (or return value optimization) would typically take place. This situation is covered by §12.8/31 of the standard, which is also a lot of standard-ese (this is a subject for another post). In laymen’s terms, it says that, among other things, copy elision can take place if we are returning a local variable that has the same type as the function return type:

Foo GetFoo() { // The return type of GetFoo is “Foo”
 Foo foo;
 …
 return foo;  // |foo|’s type is “Foo”
}

In the above situation, we are in a situation when copy elision can be applied, since the return type of the function matches the type of the variable we’re returning.

… or would be met save for the fact that the source object is a function parameter …

The next part allows this rule to be applied even if the variable we’re returning is a function parameter. That is, copy elision cannot happen if the variable is a function parameter, but this rule can be applied nevertheless:

Foo ModifyFoo(Foo foo) { // copy elision cannot happen here.
 …
 return foo;
}

The above example looks very similar to the GetFoo example, except that the variable is a function parameter. This means that although copy elision cannot occur, the rule discussed here (§12.8/32) does apply.

… and the object to be copied is designated by an lvalue …

The last part describing when this rule can be applied says that the variable we’re operating on must be an lvalue. We’ve explored lvalues and rvalues in a previous post, but it basically says that this rule is not applied to rvalues. The reason for this is that if the object being returned is an rvalue, a move constructor would already be selected without the need for special rules.

What does the compiler have to do?

… overload resolution to select the constructor for the copy is first performed as if the object were designated by an rvalue …

This says that although the variable we’re returning is, in fact, an lvalue (this is true, since this rule only applies when we’re returning lvalues), we have to select a constructor for copying this variable as if it was an rvalue. This is the key piece of the rule: if we’re in a return statement, we don’t need to move things since it will already be treated as an rvalue.

Foo GetFoo() {
 Foo foo;
 …
 return foo; // Select a ctor for this copy as if
}             // foo was an rvalue.

However, we’re not done yet.

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 …

The rule continues. In fact, it is now putting restrictions on what must happen if Foo does not have a move constructor. That is, if we do perform this resolution as if our variable was an rvalue, but for whatever reason we don’t end up using a move constructor for our variable, then we apply the following:

… overload resolution is performed again, considering the object as an lvalue …

That is, we have to go back and select a copy constructor, but this time considering our variable for what it is: an lvalue. Consider the following:

struct Foo {
Foo();
Foo(const Foo&);
};

Foo GetFoo() {
 Foo foo;
 …
 return foo;
}

At the return statement, |foo| is first treated as an rvalue. However, the overload resolution selects the constructor taking a reference to const Foo. This is not a move constructor. Thus, the compiler goes back and this time treats |foo| as an lvalue. It again, selects the reference to const Foo.

This seems like a minor distinction, but it can make a difference in certain situations. Consider what happens if Foo also has a constructor taking a reference to non-const Foo.

struct Foo {
Foo();
Foo(const Foo&);
Foo(Foo&);
};

Foo GetFoo() {
 Foo foo;
 …
 return foo;
}

Now, this is completely legal, but let’s see what happens at the return statement now. First, we treat |foo| as an rvalue as before. We select a constructor taking a reference to const Foo, since references to const can bind to rvalues. However, since this is not a move constructor, we start again and treat |foo| as an lvalue. This time, however, we select a constructor that takes a reference to non-const Foo, since it’s a better match for this copy (|foo| is a non-const lvalue).

The note

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.

This part notes that although the rule is referencing copy elision, it is just relying on it since the rules for when it can happen are similar. However, this rule to treat the variable as an rvalue applies whether or not copy elision would actually occur.

As an aside …

A version of clang that I use on my machine provides some helpful warning flags to identify situations where moves are used but not required. The first one is pessimizing move:

$ cat test.cpp
#include

struct Foo {};

Foo GetFoo() {
 Foo foo;
 return std::move(foo);
}

$ clang -std=c++11 -c -Wpessimizing-move test.cpp
test.cpp:7:10: warning: moving a local object in a return statement prevents copy elision [-Wpessimizing-move]
 return std::move(foo);
        ^
test.cpp:7:10: note: remove std::move call here
 return std::move(foo);
        ^~~~~~~~~~   ~
1 warning generated.

In summary, this says that the compiler could have performed a copy elision here, but since we wrapped the return call in an std::move, this can no longer happen. The use of std::move here is preventing a good optimization. This makes this a pessimizing move.

The second distinct warning level is redundant move:

$ cat test.cpp
#include

struct Foo {};

Foo ModifyFoo(Foo foo) {
 return std::move(foo);
}

$ clang -std=c++11 -c -Wredundant-move test.cpp
test.cpp:6:10: warning: redundant move in return statement [-Wredundant-move]
 return std::move(foo);
        ^
test.cpp:6:10: note: remove std::move call here
 return std::move(foo);
        ^~~~~~~~~~   ~
1 warning generated.

Note that this is a separate warning, because copy elision cannot happen in this case. That is, this use of std::move is not preventing optimizations. However, it says that it isn’t required because the object would be treated as an rvalue anyway.

But, we also learned before that the variable would only be treated as an rvalue if the class has a move constructor. So, that means that in theory we can force different behavior to happen with or without the move. Consider the following:

$ cat test.cpp
#include
#include

struct Foo {
 Foo() { fprintf(stderr, "default ctor\n"); }
 Foo(const Foo&) { fprintf(stderr, "const copy ctor\n"); }
 Foo(Foo&) { fprintf(stderr, "non-const copy ctor\n"); }
};

Foo ModifyFoo(Foo foo) {
 return std::move(foo); // This line issues the warning
}

int main() {
 Foo foo = ModifyFoo(Foo());
}

$ clang -std=c++11 -Wredundant-move test.cpp
test.cpp:11:10: warning: redundant move in return statement [-Wredundant-move]
 return std::move(foo);
        ^
test.cpp:11:10: note: remove std::move call here
 return std::move(foo);
        ^~~~~~~~~~   ~
1 warning generated.

$ ./a.out
default ctor
const copy ctor

Although we get a warning that we have a redundant std::move, the move is still applied. As a result, the value in the return statement is an rvalue. This means that it can bind to a const copy constructor, which is what happens. Yet, removing the “redundant move” and compiling without warnings yields the following:

$ ./a.out
default ctor
non-const copy ctor

That is, the compiler first attempts to treat |foo| as an rvalue. However, since it doesn’t have a move constructor, it goes back and finds a constructor as if |foo| was a (non-const) lvalue. It binds to the non-const copy ctor since it’s a better match.

I guess it’s open to interpretation what “redundant” means in this case, but it is possible to get a different behavior with and without a std::move in a return statement. Note that this shouldn’t be a common situation at all, since it’s rare to encounter both a const- and a non-const- copy constructors.

No comments:

Post a Comment