为什么不尽可能使用 `make_x()` 函数省略移动构造函数?

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

我不明白为什么在最后一种情况下 移动构造函数在启用复制省略时被调用(甚至是强制性的,例如在 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
}

在这种情况下,什么规则阻碍了移动构造函数被省略?

更新

调用移动构造函数时可能更直接的情况:

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

因为在表达式X(std::forward<T>(arg))中,即使在最后一种情况下,arg是一个绑定到临时的引用,它仍然不是临时的。在函数体内,编译器无法确保 arg 未绑定到左值。考虑如果移动构造函数被省略并且您将执行此调用会发生什么:

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

x4 将成为 x2.

的别名

return 值的移动省略规则在 [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

在调用中 make_X(X(1)) 复制省略实际上发生了,但只有一次:

  1. 首先 X(1) 创建一个绑定到 arg.
  2. 的临时文件
  3. 然后X(std::forward<T>(arg))调用移动构造函数。 arg 不是临时的,因此上面的第二条规则不适用。
  4. 那么表达式 X(std::forward<T>(arg)) 的结果也应该被移动以构建 return 值,但是这个移动被省略了。

关于您的更新,std::forward 导致绑定到 xvalue 的临时 X(1) 的实现:std::forward 的 return。此 returned xvalue 不是临时的,因此 copy/elision 不再适用。

如果发生移动省略,在这种情况下会发生什么。 (c++ 语法不是上下文相关的):

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

注意:在我看到有关 C++17 的新答案后,我想增加混乱。

在 C++17 中,prvalue 的定义发生了变化,您的示例代码中不再有任何移动构造函数可以省略。这是 GCC 的 result code 示例,在 C++14 中带有选项 fno-elide-constructors,然后在 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

为了简化您的示例:

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

来自 cppreference 的 copy elision documentation(强调我的):

在 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".

从 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

在任何情况下 std::forward 都不符合要求,因为它的结果是 xvalue,而不是 prvalue:它不 return class 按值键入。因此不会发生省略。

这两种情况略有不同,理解其中的原因很重要。使用 C++17 中的新值语义,基本思想是我们尽可能地延迟将 prvalues 转换为对象的过程。

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

对于x1,我们拥有的第一个X类型的表达式是make_X的正文中的那个,基本上是return X(1)。这是 X 类型的纯右值。我们正在使用该纯右值初始化 make_X 的 return 对象,然后 make_X(1) 本身就是 X 类型的纯右值,因此我们正在延迟具体化。从 T 类型的纯右值初始化 T 类型的对象意味着直接初始化 from the initializer,因此 auto x1 = make_X(1) 简化为 X x1(1).

对于x2,归约就更简单了,我们直接应用规则即可。

对于x3,场景不同。我们有一个 X earlier 类型的纯右值(X(1) 参数)并且该纯右值绑定到一个引用!在绑定点,我们应用 the temporary materialization conversion - which means we actually create a temporary object那个对象然后被移动到return对象中,我们可以一路对后面的表达式做纯右值约简。所以这基本上减少到:

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

我们还有一个动作,但只有一个(我们可以省略链式动作)。它是对引用的绑定,需要存在单独的 X 对象。参数 argmake_X 的 return 对象必须是不同的对象 - 这意味着必须发生移动。


后两种情况:

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

在这两种情况下,我们都将引用绑定到纯右值,这再次需要临时物化转换。然后在这两种情况下,初始化器都是一个 xvalue,所以我们没有得到 prvalue 减少 - 我们只是从 xvalue 移动构造,它是一个 prvalue 的物化临时对象。