Search code examples
c++language-lawyermove-semanticsperfect-forwardingcopy-elision

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


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));

Solution

  • 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.