Why isn't move constructor elided whenever possible with `make_x()` functions?
Asked Answered
F

3

21

I cannot figure out why in the last case is the move constructor called when copy elision is enabled (or even mandatory such as in C++17):

class X {
  public:
    X(int i) { std::clog << "converting\n"; }
    X(const X &) { std::clog << "copy\n"; }
    X(X &&) { std::clog << "move\n"; }
};

template <typename T>
X make_X(T&& arg) {
  return X(std::forward<T>(arg));
}

int main() {
  auto x1 = make_X(1);    // 1x converting ctor invoked
  auto x2 = X(X(1));      // 1x converting ctor invoked
  auto x3 = make_X(X(1)); // 1x converting and 1x move ctor invoked
}

What rules hinder the move constructor to be elided in this case?

UPDATE

Maybe more straightforward cases when move constructors are called:

X x4 = std::forward<X>(X(1));
X x5 = static_cast<X&&>(X(1));
Fleshings answered 12/1, 2018 at 12:23 Comment(13)
When do you think your move constructor is called ?Glioma
@Glioma The output does indeed show a move constructor call cpp.sh/8lkmoMarianamariand
Have you double-checked what your compiler vendor may have to say on the subject? I suspect that some compilers may approach this a little differently to try to avoid breaking legacy code.Zayas
@Zayas I don't think compilers have any options in C++17. I have tried GCC and Clang with -std=c++1z. (For instance, initialization of x1 and x2 is in C++17 correct even if both copy and move ctors of X are deleted.)Fleshings
Have a look at the output with copy elision disabled coliru.stacked-crooked.com/a/efd8922fec01bafcGlioma
@Glioma I know exactly what happens with disabled copy elision. This is not what I am asking about.Fleshings
So if i understand correctly op your question is why has the compiler been able to optimize this statement auto x2 = X(X(1)); but not this one auto x3 = make_X(X(1));Glioma
auto x2 = X(X(1)); has move as well in VS2017Correlate
@KillzoneKid Compiled against which Standard? In C++17, auto x2 = X(X(1)); IMO must not invoke move ctor or even require its existence.Fleshings
The copy can only be elided if the copy/move has no side effects, because your copy AND move both have side effects the compiler cannot assume they are safe.Snipe
@Snipe That's not true, see, e.g., en.cppreference.com/w/cpp/language/copy_elision.Fleshings
@DanielLangr cppreference while a good source is NOT the standard, please quote from the standard and not a reference site.Snipe
@Snipe Then, see C++ Standards. I believe you can find proper paragraphs by yourself (e.g. §12.8.31 in C++11).Fleshings
D
17

The two cases are subtly different, and it's important to understand why. With the new value semantics in C++17, the basic idea is that we delay the process of turning prvalues into objects as long as possible.

template <typename T>
X make_X(T&& arg) {
  return X(std::forward<T>(arg));
}

int main() {
  auto x1 = make_X(1);
  auto x2 = X(X(1));
  auto x3 = make_X(X(1));
}

For x1, the first expression we have of type X is the one in the body of make_X, which is basically return X(1). That's a prvalue of type X. We're initializing the return object of make_X with that prvalue, and then make_X(1) is itself a prvalue of type X, so we're delaying the materialization. Initializing an object of type T from a prvalue of type T means directly initializing from the initializer, so auto x1 = make_X(1) reduces to just X x1(1).

For x2, the reduction is even simpler, we just directly apply the rule.

For x3, the scenario is different. We have a prvalue of type X earlier (the X(1) argument) and that prvalue binds to a reference! At the point of binding, we apply the temporary materialization conversion - which means we actually create a temporary object. That object is then moved into the return object, and we can do prvalue reduction on the subsequent expression all the way. So this reduces to basically:

X __tmp(1);
X x3(std::move(__tmp));

We still have one move, but only one (we can elide chained moves). It's the binding to a reference that necessitates the existence of a separate X object. The argument arg and the return object of make_X must be different objects - which means a move must happen.


For the last two cases:

X x4 = std::forward<X>(X(1));
X x5 = static_cast<X&&>(X(1));

In both cases, we're binding a reference to a prvalue, which again necessitates the temporary materialization conversion. And then in both cases, the initializer is an xvalue, so we don't get the prvalue reduction - we just have move construction from the xvalue that was a materialized temporary object from a prvalue.

Duly answered 12/1, 2018 at 14:8 Comment(4)
Note I have heard of a proposal (when isn't there) to greatly extend elision permission to include (among other things) the above cases. I don't have a link or a number.Crapshooter
... and can you explain why the binding to an rvalue reference, then move construction, is not treated the same as a construction of a prvalue constructed from a prvalue? I mean, why was the second deemd worthy of elision and the other unworthy? @Yakk asking you to comment on this as well if you feel like it.Retrograde
@Retrograde Because the standard lets you elide in narrow circumstances, and that isn't one of them? An rvalue reference is not a prvalue. A prvalue is a kind of rvalue. Elision can occur when constructing an object from a prvalue, plus the NRVO and some exception based cases.Crapshooter
Yeah, your first sentences rings true. Antony Polukhin has also been saying the same and trying to push a proposal for expanding the circumstances.Retrograde
E
5

Because in the expression X(std::forward<T>(arg)), even if, in the last case, arg is a reference bound to a temporary, it is still not a temporary. Inside the function body, the compiler cannot ensure that arg is not bound to an lvalue. Consider what would happen if the move constructor was elided and you would perform this call:

auto x4 = make_X(std::move(x2));

x4 would become an alias for x2.

The rules for move elision of the return value is described in [class.copy]/32:

[...]This elision of copy/move operations, called copy elision, is permitted in the following circumstances (which may be combined to eliminate multiple copies):

  • 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 cv-unqualified 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

  • when a temporary class object that has not been bound to a reference ([class.temporary]) would be copied/moved to a class object with the same cv-unqualified type, the copy/move operation can be omitted by constructing the temporary object directly into the target of the omitted copy/move

In the call make_X(X(1)) copy elision actualy happens, but only once:

  1. First X(1) creates a temporary that is bound to arg.
  2. Then X(std::forward<T>(arg)) invokes the move constructor. arg is not a temporary so the second rule above does not apply.
  3. Then the result the expression X(std::forward<T>(arg)) should also be moved to construct the return value but this move is elided.

About your UPDATE, std::forward cause materialisation of the temporary X(1) that is bound to an xvalue: the return of std::forward. This returned xvalue is not a temporary so copy/elision is not anymore applicable.

Again what would happen in this case if move elision occured. (The c++ grammar is not contextual):

auto x7 = std::forward<X>(std::move(x2));

Nota: After I have seen a new answer about C++17 I wanted to add to confusion.

In C++17, the definition of prvalue is that changed that there are not any more any move constructor to elide inside your example code. Here example of result code of GCC with the option fno-elide-constructors in C++14 and then in C++17:

#c++ -std=c++14 -fno-elide-constructors | #c++ -std=c++17 -fno-elide-constructors
main:                                   | main:
  sub rsp, 24                           |   sub rsp, 24
  mov esi, 1                            |   mov esi, 1
  lea rdi, [rsp+15]                     |   lea rdi, [rsp+12]
  call X::X(int)                        |   call X::X(int)
  lea rsi, [rsp+15]                     |   lea rdi, [rsp+13]
  lea rdi, [rsp+14]                     |   mov esi, 1
  call X::X(X&&)                        |   call X::X(int)
  lea rsi, [rsp+14]                     |   lea rdi, [rsp+15]
  lea rdi, [rsp+11]                     |   mov esi, 1
  call X::X(X&&)                        |   call X::X(int)
  lea rdi, [rsp+14]                     |   lea rsi, [rsp+15]
  mov esi, 1                            |   lea rdi, [rsp+14]
  call X::X(int)                        |   call X::X(X&&)
  lea rsi, [rsp+14]                     |   xor eax, eax
  lea rdi, [rsp+15]                     |   add rsp, 24
  call X::X(X&&)                        |   ret               
  lea rsi, [rsp+15]
  lea rdi, [rsp+12]
  call X::X(X&&)
  lea rdi, [rsp+13]
  mov esi, 1
  call X::X(int)
  lea rsi, [rsp+13]
  lea rdi, [rsp+15]
  call X::X(X&&)
  lea rsi, [rsp+15]
  lea rdi, [rsp+14]
  call X::X(X&&)
  lea rsi, [rsp+14]
  lea rdi, [rsp+15]
  call X::X(X&&)
  xor eax, eax
  add rsp, 24
  ret
Explicate answered 12/1, 2018 at 13:28 Comment(1)
IANALL, but still: "a reference bound to a temporary... is still not a temporary" <- uh, yeah it is; the reference is quite temporary, just like in the x(x(1)) case where without ellision we should be calling the copy ctor for the second x. " Inside the function body, the compiler cannot ensure that arg is not bound to an lvalue." <- but it can inline that function and probably is doing so already.Retrograde
I
4

To simplify your example:

auto x1 = make_X(1);                // converting
auto x2 = X(X(1));                  // converting
auto x4 = X(std::forward<X>(X(1))); // converting + move

From the cppreference's copy elision documentation (emphasis mine):

Before c++17:

Under the following circumstances, the compilers are permitted, but not required to omit the copy- and move- (since C++11)construction of class objects ...

  • If a function returns a class type by value, and the return statement's expression is the name of a non-volatile object with automatic storage duration, which isn't a function parameter, or a catch clause parameter, and which has the same type (ignoring top-level cv-qualification) as the return type of the function, then copy/move (since C++11) is omitted. When that local object is constructed, it is constructed directly in the storage where the function's return value would otherwise be moved or copied to. This variant of copy elision is known as NRVO, "named return value optimization".

Since c++17:

Under the following circumstances, the compilers are required to omit the copy- and move- construction...

a) In initialization, if the initializer expression is a prvalue and the cv-unqualified version of the source type is the same class as the class of the destination, the initializer expression is used to initialize the destination object:

T x = T(T(T())); // only one call to default constructor of T, to initialize x

b) In a function call, if the operand of a return statement is a prvalue and the return type of the function is the same as the type of that prvalue.

T f() { return T{}; }
T x = f();         // only one call to default constructor of T, to initialize x
T* p = new T(f()); // only one call to default constructor of T, to initialize *p

In any case std::forward does not fit requirements, because it's result is an xvalue, not a prvalue: it does not return class type by value. Thus no elision happens.

Instantly answered 12/1, 2018 at 13:44 Comment(1)
That must be why auto x4 = X(std::move(X(1))); also results in a "convert" and "move".Montane

© 2022 - 2024 — McMap. All rights reserved.