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

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

本文关键字:函数省 移动 构造函数 尽可能 为什么 make      更新时间:2023-10-16

我不知道为什么在最后一种情况下在启用复制 elision 时调用移动构造函数(甚至是强制性的,例如 C++17):

class X {
public:
X(int i) { std::clog << "convertingn"; }
X(const X &) { std::clog << "copyn"; }
X(X &&) { std::clog << "moven"; }
};
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));

这两种情况略有不同,了解原因很重要。对于 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 初始化make_X的返回对象,然后make_X(1)本身是X类型的 prvalue,因此我们延迟了具体化。从T类型的 prvalue 初始化T类型的对象意味着直接从初始值设定项初始化,因此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 是一个具体的临时对象。

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

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

x4将成为x2的别名。

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

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

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

  • 当尚未绑定到引用([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导致绑定到x值的临时X(1)的实现:std::forward的返回。此返回的 xvalue 不是临时的,因此复制/省略不再适用。

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

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

Nota:在我看到关于C++17的新答案后,我想增加困惑。

在 C++17 中,prvalue的定义发生了变化,即示例代码中不再有任何要 elide 的移动构造函数。以下是 GCC 的结果代码示例,选项在 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 的复制 elision 文档(强调我的):

在 c++17 之前:

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

  • 如果函数按值返回类类型,则返回 语句的表达式是非易失性对象的名称,具有 自动存储持续时间,这不是函数参数,也不是 catch 子句参数,并且具有相同的类型(忽略 顶级 CV 资格)作为函数的返回类型,则 复制/移动(自 C++11 起)被省略。当该本地对象是 构造,它直接在存储中构造,其中 否则,函数的返回值将被移动或复制到。这 复制 elision 的变体称为 NRVO,"命名返回值 优化"。

从 c++17 开始:

在以下情况下,编译器需要省略 复制和移动结构...

a) 在初始化中,如果初始值设定项表达式是 prvalue 并且 源类型的 cv 非限定版本与 目标的类,初始值设定项表达式用于 初始化目标对象:

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

b) 在函数调用中,如果return 语句的操作数是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都不符合要求,因为它的结果是一个x值,而不是一个 prvalue:它不会按值返回类类型。因此,不会发生省略。