优化编译器能否添加std::move?

26

如果编译器能够证明lvalue不会再次使用,那么它能否执行自动lvalue-to-rvalue转换?以下示例将澄清我的意思:

void Foo(vector<int> values) { ...}

void Bar() {
  vector<int> my_values {1, 2, 3};
  Foo(my_values);  // may the compiler pretend I used std::move here?
}
如果在注释行中添加std::move,则可以将向量移动到Foo的参数中,而不是复制。但是,如所写,我没有使用std::move。很容易静态地证明在注释行后不会再使用my_values,所以编译器是否允许移动向量,还是必须复制它?
2个回答

33
编译器需要表现为从vectorFoo调用发生了复制。
如果编译器能够证明在抽象机器行为中没有可观测的副作用(不是在实际计算机中!)涉及将std::vector移动到Foo,则可以这样做。
在您上面的情况下,这种情况(移动没有抽象机器可见的副作用)是正确的;然而,编译器可能无法证明它。
复制std::vector<T>时可能会出现以下可观察行为:
- 调用元素的复制构造函数。使用int进行此操作是无法观察到的。 - 在不同时间调用默认的std::allocator<>。这会调用::new::delete(可能1)。在任何情况下,::new::delete在上述程序中未被替换,因此您不能在标准下观察到它。 - 在不同对象上多次调用T的析构函数。使用int不可观察。 - 调用Foovector仍非空。没有人检查它,所以如果它为空,那么就好像它不存在一样。 - 指向外部向量元素的引用、指针或迭代器与内部的不同。未对Foo之外的向量元素采取任何引用、向量或指针。
虽然您可能会说“但是如果系统内存不足,向量很大,这不是可观察到的吗?”:
抽象机器没有“内存不足”条件,它只是有时因非约束性原因而分配失败(抛出std::bad_alloc)。它不失败是抽象机器的有效行为,通过不分配(实际)内存(在实际计算机上)而不失败也是有效的,只要内存的不存在没有可观察的副作用。
稍微玩具化一点的情况:
int main() {
  int* x = new int[std::size_t(-1)];
  delete[] x;
}

虽然该程序明显分配了过多的内存,但编译器可以自由选择不分配任何内存。

我们可以更进一步。即使:

int main() {
  int* x = new int[std::size_t(-1)];
  x[std::size_t(-2)] = 2;
  std::cout << x[std::size_t(-2)] << '\n';
  delete[] x;
}

可以转化为std::cout << 2 << '\n';。那个大缓冲区必须存在于抽象状态下,但只要你的“真实”程序像抽象机器一样运行,它实际上不必分配它。
不幸的是,在任何合理的规模下这样做都很困难。C++程序中有很多信息泄漏的方式。因此,依靠这样的优化(即使发生了)也不会有好的结果。
重要的事实是,即使没有调用std::move,编译器也不需要像复制一样运行的情况是存在的。
当您在一个看起来像return X;的行中从函数返回本地变量,并且X是标识符,并且该本地变量具有自动存储期(在堆栈上),操作隐式移动,并且编译器(如果可以)可以将返回值和本地变量的存在省略为一个对象(甚至省略move)。
当您从临时对象构造对象时,情况也是如此-操作隐式移动(因为它绑定到rvalue)并且可以完全省略移动。
在这两种情况下,编译器必须将其视为移动(而不是复制),并且它可以省略移动操作。
std::vector<int> foo() {
  std::vector<int> x = {1,2,3,4};
  return x;
}

这段代码中的x没有使用std::move,但它被移动到返回值中,并且可以省略这个移动操作(x和返回值可以合并为一个对象)。

示例代码:

std::vector<int> foo() {
  std::vector<int> x = {1,2,3,4};
  return std::move(x);
}

块省略也是如此:
std::vector<int> foo(std::vector<int> x) {
  return x;
}

我们甚至可以阻止这个动作:

std::vector<int> foo() {
  std::vector<int> x = {1,2,3,4};
  return (std::vector<int> const&)x;
}

甚至更多:
std::vector<int> foo() {
  std::vector<int> x = {1,2,3,4};
  return 0,x;
}

由于隐式移动规则是故意脆弱的。(0,x是备受诟病的运算符的用法之一。)
现在,不建议依赖于像最后一个基于,的例子中不发生隐式移动的情况:自从将隐式移动添加到语言中以来,标准委员会已经将一个隐式复制案例更改为隐式移动,因为他们认为它是无害的(其中函数返回具有A(B&&)构造函数的类型A,并且返回语句是return b;,其中b的类型为B;在C++11发布时进行了复制,现在进行了移动。) 隐式移动的进一步扩展不能被排除:明确地转换为const&可能是现在和未来防止它的最可靠方法。

1
你有没有任何链接可以让我了解更多关于这个工作原理或者抽象机器的定义? - kmdreko
2
@vu1p3n0x 找到一份 C++ 标准了吗?这里有一份草案这里还有一份。C++ 的行为是根据一个抽象机器的标准来规定的。 - Yakk - Adam Nevraumont
1
我要补充一点,由于分离编译,::operator new 可以在不同的翻译单元中被替换,然后链接在一起,因此在没有整个程序知识的情况下省略调用是棘手的。此外,合并规则适用于新表达式,而不是对 ::operator new 的直接调用,这就是 std::allocator 所做的。 - T.C.
你的第二个阻塞省略示例(返回参数)可能实际上并没有阻塞省略(这取决于规范中从未定义的“本地”一词的精确定义 - 参数是否是“本地”的?)。然而,大多数实现可能无论规范是否允许,都无法省略此情况,除非它们内联该函数。 - Chris Dodd
@m.m. 前三段非常明确,是吗?(抱歉删除了上一条评论)。其余部分是辩解和阐述。 - Yakk - Adam Nevraumont
显示剩余3条评论

2
在这种情况下,编译器可以移出my_values。因为这不会导致可观察行为上的任何差异。
引用C++标准中的可观察行为定义:

符合规范的实现最低要求是:

  • 对于易失对象的访问必须严格按照抽象机器的规则进行评估。
  • 在程序终止时,写入文件的所有数据都必须与根据抽象语义执行程序可能产生的结果之一相同。
  • 交互式设备的输入和输出动态应以这样的方式进行,即提示输出实际上在程序等待输入之前已被传递。什么构成交互式设备是由实现定义的。
稍微解释一下:“文件”在此处包括标准输出流,并且对于未由C++标准定义的函数调用(例如操作系统调用或调用第三方库),必须假定这些函数可能会写入文件,因此非标准函数调用也必须被视为可观察行为。
然而,您展示的代码(如您所示)没有volatile变量和非标准函数调用。因此,两个版本(移动或不移动)必须具有相同的可观察行为,因此编译器可以执行任何一个(甚至可以完全优化函数)。
当然,在实践中,对于编译器来说往往不那么容易证明不存在非标准函数调用,因此会错过许多像这样的优化机会。例如,在这种情况下,编译器可能尚不知道默认的::operator new是否已被替换为生成输出的函数。

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