为什么在返回参数时不允许使用RVO?

26
在[C++11: 12.8/31]中有这样的说明:
引用自该规范: 在具有类返回类型的函数的返回语句中,当表达式是非易失性自动对象的名称(除了函数或catch子句参数),且具有与函数返回类型相同的cv非限定类型时,可以通过直接将自动对象构造到函数的返回值中来省略复制/移动操作。
这意味着:
#include <iostream>

using namespace std;

struct X
{
    X() { }
    X(const X& other) { cout << "X(const X& other)" << endl; }
};

X no_rvo(X x) {
    cout << "no_rvo" << endl;
    return x;
}

int main() {
    X x_orig;
    X x_copy = no_rvo(x_orig);

    return 0;
}

将打印
X(const X& other)
no_rvo
X(const X& other)

为什么需要第二个拷贝构造函数?编译器不能简单地延长x的生命周期吗?

在这个例子中,实际上有三个对象可能被复制构造(no_rvo 的参数、no_rvo 的返回值和 x_copy)。可以省略 x_copy 的构造(通过直接在 x_copy 中构造 no_rvo 的返回值)。 - Mankarse
@Mankarse:我认为这正是OP所问的——为什么x_copy的构造没有被省略? - jweyrich
@jweyrich:不是的 - 这个问题是关于为什么返回值的构造没有被省略。我的观点只是说,说代码将打印X(const X& other) no_rvo X(const X& other)是不准确的,因为如果 x_copy 的构造未被省略,代码也可以打印X(const X& other) no_rvo X(const X& other) X(const X& other) - Mankarse
@Mankarse:哦,确实是这样。我现在明白你的意思了。它__可能/也许__会打印。 - jweyrich
3个回答

15

假设no_rvo在与main不同的文件中定义,因此当编译main时,编译器只会看到声明。

X no_rvo(X x);

而且函数不清楚返回的类型为X的对象是否与参数有任何关系。 从该函数的角度来看,no_rvo的实现可以如下:

X no_rvo(X x) { X other; return other; }

所以,例如在编译该行代码时

X const& x = no_rvo(X());

当进行最大化优化时,它将执行以下操作:

  • 生成要传递给no_rvo的临时X参数
  • 调用no_rvo,并将其返回值绑定到x
  • 销毁传递给no_rvo的临时对象。

��果no_rvo的返回值与传递给它的对象是相同的,则销毁临时对象将意味着销毁返回的对象。但这是错误的,因为返回的对象已经绑定到一个引用上,因此延长了其生命周期,超出了该语句。然而,简单地不销毁参数也不是解决方案,因为如果no_rvo的定义是我上面展示的替代实现,则会出现错误。因此,如果允许函数重用参数作为返回值,就可能出现编译器无法确定正确行为的情况。

请注意,使用常见实现方式,编译器无论如何都无法优化掉它,因此不能正式允许这种行为并不是什么大损失。另外,请注意,如果编译器可以证明这不会导致可观察行为发生变化(所谓的as-if规则),它仍然可以将复制优化掉。


基本上,这将与参数复制构造函数的省略和分离编译(当它们一起使用时)不兼容。因为如果省略参数的复制构造函数,则必须由调用者执行(因为在(分离)编译时被调用方无法知道其参数是否从临时对象构造而来)。 - Mankarse
1
谢谢解释!如果采用一种调用约定,其中函数负责销毁其传递的按值传递参数,这种方式是否可行?然后调用者就不再关心no_rvo的定义方式了。 - Valentin Milea
我的解释:调用站点需要知道如何将参数传递给被调用的函数,而不需要了解被调用函数的实现(例如,如果被调用函数在单独的翻译单元中定义),以生成调用过程。但是,带有参数的RVO将是一种依赖于实现的优化,并且它会影响调用代码必须传递参数的接口。因此,你不能使用带有参数的RVO,因为调用代码应该对被调用函数的实现是无知的。 - Alexander Guyer

5
RVO通常的实现方式是,调用代码传递一个内存块的地址,函数将在这个内存块中构建其结果对象。
当函数结果直接是一个非形式参数的自动变量时,该本地变量可以简单地放置在调用方提供的内存块中,然后返回语句根本不需要进行任何复制。
对于按值传递的参数,调用机器代码必须将其实际参数复制初始化到形式参数的位置,然后跳转到函数。为了在那里放置其结果,函数首先必须销毁形式参数对象,这有一些棘手的特殊情况(例如,当该构造直接或间接引用形式参数对象时)。因此,优化应逻辑上使用一个单独的由调用者提供的内存块用于函数结果,而不是将结果位置与形式参数位置相对应。
然而,未通过寄存器传递的函数结果通常由调用者提供。也就是说,关于RVO谈论的合理内容,即用于表示形式参数的return表达式的一种减少的RVO,将会发生。它与“通过直接在函数的返回值中构造自动对象”这段文本不符。
总结一下,数据流要求调用者传递一个值,这意味着必须由调用者初始化形式参数的存储,而不是函数。因此,在一般情况下,无法避免从形式参数进行复制(这个诡计术语涵盖了编译器可以做非常特殊的事情的特殊情况,特别是对于内联机器代码)。但是,函数初始化任何其他本地自动对象的存储,然后做RVO就没有问题了。

4
虽然常见的实现方式本来就不支持这种优化,但仅此并不能解释标准为什么禁止它。毕竟,这只是一种优化而已,因此并不必须去做。 - celtschk
@celtschk 这不仅仅是常见实现的问题,而是所有现有实现的问题。还要注意,这种优化不能在函数内部完成,而必须由调用方在调用约定的支持下执行。请注意,只有调用方知道参数是rvalue并且空间可以被重用。还要注意,调用方和被调用方必须在每次调用时就是否在函数调用期间语义上销毁参数达成一致。 - David Rodríguez - dribeas
3
即使当前的编译器没有以允许这种优化的方式实现,也并不重要。编译器编写者可以选择忽略这个优化是被允许的事实。编译器编写者也知道如果这种优化发生了,那么它会是由编译器完成的,但在这种情况下未进行此优化,因为编译器没有这样做。 - celtschk
@celtschk,您可能在评论中错过了一些细节...在一个独立编译模型下处理函数的编译器不可能知道是否可以省略复制。这个知识存在于调用者中,而不是函数中。从参数到返回语句的复制是函数的责任,但它是否适用的知识并不存在于函数中。在具有独立编译模型的语言中无法实现这一点。 - David Rodríguez - dribeas
2
@DavidRodríguez-dribeas:如果无法实现,那么标准就更没有理由明确禁止它了。这是不必要的,多余的括号只会浪费空间和时间。如果你明确禁止某些事情,那么你这样做是因为它是可能的,但会产生你不想要的后果。 - celtschk
显示剩余3条评论

0
无论谁负责销毁函数参数,它们也不能是在调用点创建的结果对象。 这在C++11中是允许的,在C++17中是必需的,并且在函数参数中使用NRVO(1)实现起来会很困难/不可能。
考虑以下函数和调用:
X no_rvo(X x);

X x_orig;
X x_copy = no_rvo(x_orig);

发生的情况如下:
1. 从x_orig复制构造一个X x参数 2. 控制权转移到no_rvo函数,函数执行其工作 3. 按照实现定义的顺序: - 控制权返回给调用者 - 由控制权的持有者销毁函数参数
如果调用者负责销毁x,那么调用者怎么知道x是与x_copy相同的对象呢?除非查看no_rvo的定义,否则是不可能知道的,或者它需要获取额外的运行时信息来告知它哪个参数(如果有的话)是与结果对象相同的对象。
如果被调用者负责销毁x,那意味着x_copy不能是x,因为x会被no_rvo销毁。
(1) NRVO指的是Named Return Value Optimization。
(2) 第3步的相关措辞在[expr.call] p6中:

在定义它的函数返回或者在封闭的完整表达式结束时,参数的生命周期是实现定义的。 每个参数的初始化和销毁发生在函数调用所出现的完整表达式的上下文中。

请注意,C++11 [expr.call] p4曾经有一段措辞,后来被替换掉,因为它与现有的ABI(如System-V)不兼容:

每个参数的初始化和销毁发生在调用函数的上下文中。


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