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

21

我无法理解为什么在最后一种情况下启用复制省略时(甚至是强制性的,例如在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));

2
你认为什么时候会调用移动构造函数? - Pumkko
请查看禁用复制省略的输出:http://coliru.stacked-crooked.com/a/efd8922fec01bafc - Pumkko
@Pumkko 我非常清楚禁用复制省略会发生什么。但这不是我所询问的内容。 - Daniel Langr
@KillzoneKid 编译使用哪个标准?在 C++17 中,auto x2 = X(X(1)); 我认为不应该调用移动构造函数甚至需要它的存在。 - Daniel Langr
@Mgetz 然后,看看C++标准。我相信你可以自己找到合适的段落(例如,在C++11中的§12.8.31)。 - Daniel Langr
显示剩余8条评论
3个回答

17

这两种情况略有不同,理解其原因很重要。在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的prvalue。我们用该prvalue初始化make_X函数的返回对象,然后make_X(1)本身也是一个类型为X的prvalue,因此我们延迟了实例化。从类型为T的prvalue直接初始化类型为T的对象意味着直接从初始值from the initializer初始化,因此auto x1 = make_X(1)简化为X x1(1)
对于变量x2,简化规则更加简单,我们直接应用该规则即可。
对于x3,情况有所不同。我们有一个类型为X的prvalue(即X(1)参数),并且该prvalue绑定到引用!在绑定点,我们应用临时材料化转换 - 这意味着我们实际上创建了一个临时对象。然后将对象移动到返回对象中,并且我们可以对随后的表达式进行prvalue缩减。因此,这基本上缩减为:
X __tmp(1);
X x3(std::move(__tmp));

我们还有一步操作,但只有一步(我们可以省略链接的步骤)。绑定到引用需要存在一个单独的X对象。参数argmake_X的返回对象必须是不同的对象 - 这意味着必须进行移动操作。

对于最后两种情况:

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

在这两种情况下,我们都将引用绑定到prvalue,这再次需要临时材料转换。然后在这两种情况下,初始化程序是xvalue,因此我们不会得到prvalue的减少-我们只是从xvalue进行移动构造,该xvalue是从prvalue材料化的临时对象。

1
注意,我听说有一个提案(什么时候没有呢)要大大扩展省略许可,包括(除其他外)上述情况。我没有链接或号码。 - Yakk - Adam Nevraumont
你能解释一下为什么将rvalue引用绑定,然后进行移动构造不被视为从prvalue构造的prvalue的构造方式?我的意思是,为什么第二种被认为值得省略而另一种不值得?@Yakk也请您发表评论,如果您愿意的话。 - einpoklum
@einpoklum 因为标准只允许在特定情况下省略,而这不是其中之一?rvalue引用不是prvalue。prvalue是rvalue的一种。当从prvalue构造对象时,可以发生省略,加上NRVO和一些基于异常的情况。 - Yakk - Adam Nevraumont
是的,你的第一句话说得对。Antony Polukhin也在说同样的话,并试图推动扩大情况的提案。 - einpoklum

5

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

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

x4将成为x2的别名。

返回值移动省略的规则在[class.copy]/32中描述:

[...]这种称为复制省略的复制/移动操作可以在以下情况下允许(可以组合以消除多个副本):

  • 在具有类返回类型的函数中的返回语句中,当表达式是与函数返回类型具有相同cv-unqualified类型的非易失性自动对象(而不是函数或catch-clause参数)的名称时,可以通过直接构造自动对象到函数的返回值中来省略复制/移动操作

  • 当一个未绑定到引用([class.temporary])的临时类对象将被复制/移动到具有相同cv-unqualified类型的类对象时,可以通过直接构造临时对象到省略的复制/移动的目标中来省略复制/移动操作

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

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

关于您的更新,std::forward会导致绑定到xvalue的临时X(1)实例化:即std::forward的返回值。这个返回的xvalue不再是一个临时对象,所以复制/省略不再适用。

如果移动省略发生在这种情况下会发生什么(c++语法不具有上下文):

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

注:看到有关C++17的新答案后,我想增加一些混淆。

在C++17中,“prvalue”的定义已更改,因此您的示例代码中不再有任何移动构造函数可省略。这里是使用C++14和C++17选项“fno-elide-constructors”进行编译的GCC的结果代码示例:

#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

虽然我不是所有人,但仍然:“绑定到临时对象的引用…仍然不是临时对象”<- 是的,它是;引用是相当临时的,就像在x(x(1))的情况下一样,如果没有省略,我们应该调用第二个x的复制构造函数。 “在函数体内,编译器无法确保arg未绑定到lvalue。”<- 但它可以内联该函数,并且可能已经这样做了。 - einpoklum

4

简化您的示例:

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

来自cppreference的复制省略文档(重点是我的):

c++17之前:

在以下情况下,编译器被允许但不要求省略类对象的复制和移动(自C++11以来)构造...

  • 如果函数通过值返回类类型,并且return语句的表达式是具有自动存储期的非易失性对象的名称,该对象不是函数参数或catch子句参数,并且该对象具有与函数的返回类型相同的类型(忽略顶层cv限定符),则省略复制/移动(自C++11以来)。当构造该局部对象时,它直接在函数返回值应该移动或复制到的存储中构造。这种复制省略的变体称为NRVO,“命名返回值优化”。

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:它不通过值返回类类型。因此不会发生省略。

2
这就是为什么 auto x4 = X(std::move(X(1))); 也会产生“转换”和“移动”的原因。 - Eljay

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接