为什么从NRVO中排除了按值参数?

时间:2011-05-15 15:22:11

标签: c++ c++11 return-value-optimization

想象:

S f(S a) {
  return a;
}

为什么不允许别名a和返回值插槽?

S s = f(t);
S s = t; // can't generally transform it to this :(

如果S的复制构造函数具有副作用,则规范不允许此转换。相反,它至少需要两个副本(一个从ta,一个从a到返回值,另一个从返回值到s,只有最后一个才能被删除。请注意,我在上面写了= t来表示t到f的a副本的事实,这是唯一一个在现场仍然是强制性的副本移动/复制构造函数的副作用)。

为什么?

6 个答案:

答案 0 :(得分:19)

这就是为什么复制省略对参数没有意义的原因。它实际上是关于编译器级别的概念的实现。

复制elision的工作原理是基本上构建返回值。该值不会被复制出来;它直接在预定目的地创建。呼叫者为预期的输出提供空间,因此最终是呼叫者提供了省略的可能性。

函数内部需要执行的所有操作都是为了忽略副本,而是在调用者提供的位置构造输出。如果函数可以执行此操作,则会获得copy elision。如果函数不能,则它将使用一个或多个临时变量来存储中间结果,然后将其复制/移动到调用者提供的位置。它仍然就地构建,但输出的构造通过复制发生。

因此,特定功能之外的世界不必知道或关心某个功能是否能够完成。具体来说,函数的调用者不必了解函数的实现方式。它没有做任何不同的事情;这是函数本身决定是否可以省略。

调用者也提供值参数的存储。当您致电f(t)时,调用者会创建t的副本并将其传递给f。同样,如果S可以隐式构建int,那么f(5)将从5构建S并将其传递给f

这完全由调用者完成。被叫者不知道或不关心它是变量还是临时的;它只是给出了一堆堆栈内存(或寄存器或其他)。

现在请记住:copy elision有效,因为被调用的函数将变量直接构造到输出位置。因此,如果您试图忽略值参数的返回,则值参数的存储也必须是输出存储本身。但请记住:调用者为参数和输出提供了存储空间。因此,为了省略输出副本,调用者必须将参数直接构造到输出

要做到这一点,现在调用者需要知道它调用的函数将忽略返回值,因为如果返回参数,它只能将参数直接粘贴到输出中。这在编译器级别通常是不可能的,因为调用者不一定具有该函数的实现。如果函数是内联的,那么也许它可以工作。但否则没有。

因此,C ++委员会并不愿意考虑这种可能性。

答案 1 :(得分:3)

根据我的理解,这个限制的基本原理是调用约定可能(并且在许多情况下)要求函数和返回对象的参数位于不同的位置(存储器或寄存器)。请考虑以下修改示例:

X foo();
X bar( X a ) 
{ 
   return a;
}
int main() {
   X x = bar( foo() );
}

理论上,整套副本将是foo$tmp1)中的return语句,a的{​​{1}}参数,bar的返回语句( bar中的$tmp2)和x。编译器可以通过在main$tmp1位置a处创建$tmp2来忽略四个对象中的两个。当编译器处理x时,它可以注意到main的返回值是foo的参数并且可以使它们重合,此时它不可能知道(没有内联) bar的参数和返回是同一个对象,它必须符合调用约定,因此它将bar置于$tmp1的参数位置。

同时,它知道bar的目的只是创建$tmp2,因此它可以将两者放在同一地址。在x内部,没有太多可以做的事情:根据调用约定,参数bar位于第一个参数的位置,a必须根据调用约定(在一般情况下,在不同的位置,认为该示例可以扩展为带有更多参数的$tmp2,其中只有一个用作返回语句。

现在,如果编译器执行内联,它可以检测到实际上不需要内联函数时所需的额外副本,并且它有可能将其删除。如果标准允许省略该特定副本,则相同的代码将具有不同的行为,具体取决于函数是否内联。

答案 2 :(得分:1)

从t到a,删除副本是不合理的。该参数被声明为可变,因此复制完成是因为它应该在函数中被修改。

从a到返回值,我看不到任何复制的理由。也许是某种疏忽? by-value参数感觉就像函数体内的本地人一样...我认为没有区别。

答案 3 :(得分:1)

DavidRodríguez - 我的问题'How to allow copy elision construction for C++ classes'的问题回答给了我以下想法。诀窍是使用lambdas来延迟评估直到函数体内:

#include <iostream>

struct S
{
  S() {}
  S(const S&) { std::cout << "Copy" << std::endl; }
  S(S&&) { std::cout << "Move" << std::endl; }
};

S f1(S a) {
  return a;
}

S f2(const S& a) {
  return a;
}

#define DELAY(x) [&]{ return x; }

template <class F>
S f3(const F& a) {
  return a();
}

int main()
{
  S t;
  std::cout << "Without delay:" << std::endl;
  S s1 = f1(t);
  std::cout << "With delay:" << std::endl;
  S s2 = f3(DELAY(t));
  std::cout << "Without delay pass by ref:" << std::endl;
  S s3 = f2(t);
  std::cout << "Without delay pass by ref (temporary) (should have 0 copies, will get 1):" << std::endl;
  S s4 = f2(S());
  std::cout << "With delay (temporary) (no copies, best):" << std::endl;
  S s5 = f3(DELAY(S()));
}

此输出ideone GCC 4.5.1:

  

毫不拖延:
  复制
  复制
  延迟:
  复制

现在这很好,但可以建议DELAY版本就像传递const引用一样,如下所示:

  

没有延迟通过ref:
  复制

但是如果我们传递一个临时的const引用,我们仍然得到一个副本:

  

没有延迟通过ref(临时)(应该有0份副本,将获得1):
  复制

延迟版本省略副本:

  

延迟(临时)(无副本,最佳):

正如你所看到的,这就是所有临时案件中的副本。

延迟版本在非临时案例中生成一个副本,在临时案例中不生成副本。我不知道除了lambdas之外还有什么方法可以实现这个目标,但如果有的话,我会感兴趣。

答案 4 :(得分:0)

我觉得,因为替代方案始终可用进行优化:

S& f(S& a) { return a; }  // pass & return by reference
^^^  ^^^

如果f()按照您的示例中的说明进行编码,则可以认为副本是或预期会产生副作用;否则为什么不选择通过/返回参考?

假设NRVO适用(如您所知),那么S f(S)S& f(S&)之间没有区别!

NRVO在operator +()example)等情况下开始,因为没有值得替代的选择。

一个支持方面,所有以下功能都有不同的复制行为:

S& f(S& a) { return a; }  // 0 copy
S f(S& a) { return a; } // 1 copy
S f(S a) { A a1; return (...)? a : a1; }  // 2 copies

在第3个片段中,如果(...)在编译时已知为false,那么编译器只生成1个副本。
这意味着,当一个简单的替代方案可用时,该编译器有目的地不执行优化。

答案 5 :(得分:-1)

我认为问题是如果复制构造函数做了某些事情,那么编译器必须做出可预测次数的事情。例如,如果你有一个类在每次复制时递增一个计数器,并且有一种方法可以访问该计数器,那么符合标准的编译器必须执行定义次数的操作(否则,如何写一个单元测试?)

现在,实际编写这样的类可能是一个坏主意,但编译器的工作并不是编译器的工作,只是为了确保输出正确且一致。