为什么按值传递的参数被排除在NRVO之外?

42

想象一下:

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,在移动/复制构造函数具有副作用的情况下,该复制是唯一仍然必须的复制)。为什么?

1
因为返回未更改的参数并不是非常有用的? - Bo Persson
5
如果我们改变参数,会发生什么?S f(S s) { for(E &e : s) e.toupper(); return s; }编译器可以执行NRVO,并忽略return s,因为返回值已经就位。这样可以少进行一次复制/移动操作! - Johannes Schaub - litb
2
@Matthieu,代码是否被内联并不重要。如果它具有副作用且无法应用NRVO(即可观测的副作用),则始终必须执行复制操作。我认为,如果NRVO在我的情况下可以应用,则可以少复制一次。 - Johannes Schaub - litb
1
@Bo,@Neil:当然问题不是:“这通常是否是一种好的/容易的优化?”问题是,“为什么标准包含额外的文本来禁止此优化?”必须有一个积极的理由去禁止它,“不值得做”只能解释在标准未允许时的情况。 - Steve Jessop
1
@Steve 标准禁止这种做法吗?我的印象是它只是没有明确允许。 - user2100815
显示剩余14条评论
6个回答

29
这就是为什么拷贝省略对于参数没有意义的原因。它实际上是关于编译器层面的概念实现方式。
拷贝省略通过在目标位置内部构造返回值来实现。值不会被复制出来;直接在目标位置创建。提供目标输出空间的调用方,最终提供了省略的可能性。
函数内部所需做的一切就是在调用方提供的位置上构建输出以省略复制。如果函数能够做到这一点,就可以省略复制。如果函数不能这样做,则会使用一个或多个临时变量来存储中间结果,然后将其复制/移动到调用方提供的位置。仍然是在原地构造,但是输出的构造是通过复制完成的。
因此,特定函数之外的世界不必知道或关心函数是否执行拷贝省略。特别是函数的调用方不必知道函数的实现方式。调用方不做任何不同;决定是否可以进行省略的是函数本身。
值参数的存储也由调用方提供。当您调用 f(t) 时,是调用方创建 t 的副本并将其传递给 f。同样,如果 S 从 int 隐式构造,则 f(5) 将从 5 构造一个 S 并将其传递给 f。
这都是由调用方完成的。被调用者不知道也不关心它是变量还是临时变量;它只是获得了一块堆栈内存(或寄存器或其他)。
现在记住:拷贝省略的工作原理是因为被调用的函数直接在输出位置上构造变量。因此,如果您试图从值参数中省略返回值,则值参数的存储必须也是输出存储本身。但要记住:提供参数和输出的存储的是调用方。因此,为了省略输出复制,调用方必须将参数直接构造到输出中。
为了做到这一点,现在调用方需要知道它正在调用的函数将省略返回值,因为仅当参数将被返回时,才能将参数直接插入输出。在编译器层面,这通常是不可能的,因为调用者不一定拥有函数的实现。如果函数被内联,那么可能会奏效。但否则不行。
因此,C++委员会没有费心考虑可能性。

7
在C++03和C++11之间,委员会将“表达式是非易失性自动对象的名称”更改为“表达式是非易失性自动对象的名称(不包括函数或catch语句参数)”。因此,委员会并不是“没有考虑到可能性”。在C++03中是允许的(可能是意外的),然后委员会在C++11中费尽心思禁止它。 - Steve Jessop
就像@SteveJessop所说的那样。我无法相信Nicol的答案竟然得到了这么多赞,它显然是错误的。 - Quuxplusone
2
看起来事后是正确的 ;) 任何编译器是否实现了从参数中可能意外允许NRVO的功能?我可能会_假设_没有,因为Nicol给出了令人信服的理由。而且在C++11修改措辞之后(尽管没有进行真正的讨论),他们不被允许这样做。因此,在实际情况下,也许根据C++<11的_意图_,以及现在 - 这似乎是一个正确的答案。我相信,通过高级LTO等,编译器_可以_在这种情况下执行复制省略,但似乎委员会并不想考虑使其成为明确定义的事情。 - underscore_d
1
这并不完全正确。复制省略是关于避免各种对象的复制初始化,与“返回值”无关。在语言中,没有要求强制执行调用者站点上的复制初始化。 - FrankHB
对于现代实现,关键点在于参数的省略和返回对象不能同时存在于函数的实现中,特别是在没有整个程序分析的情况下跨不同的TUs。这样的函数代码通常需要遵守ABI规则,这可能需要调用者进行复制。(专门的ABI仍然可以允许不同的方式。) - FrankHB
语言标准是与ABI无关的,因此它不能将其作为要求或保证。在这种情况下允许省略仍然是技术上可行的,但也有些令人困惑:考虑如何在可移植程序中惯用地进行对象的显式复制。似乎委员会只是没有费心做出权衡。 - FrankHB

3

我理解这个限制的原因是调用约定可能(在许多情况下)要求函数的参数和返回对象位于不同的位置(无论是内存还是寄存器)。考虑以下修改后的示例:

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

理论上,整个副本集将作为foo$tmp1)的返回语句,bar的参数abar的返回语句($tmp2)和main中的x。编译器可以通过在a的位置创建$tmp1和在x的位置创建$tmp2来省略其中两个对象。当编译器处理main时,它可以注意到foo的返回值是bar的参数,并使它们重合,在这一点上,它不可能知道(除非内联)bar的参数和返回值是同一个对象,并且必须遵守调用约定,因此它将$tmp1放置在bar的参数位置。

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

现在,如果编译器执行内联,它可以检测到如果函数没有内联,将需要额外的副本,但实际上并不需要,因此它有机会省略它。如果标准允许省略特定的副本,则相同的代码将具有不同的行为,具体取决于函数是否被内联。


2
这是有时候在某些情况下不做它的原因,而不是禁止它的理由。同样的代码已经根据复制省略具有不同的行为。 - Puppy
那么你的回复归结为需要在制作额外不必要的副本时期望的副作用?基本上,由于编译器对它们进行了优化而未被调用的所有这些临时对象的复制构造函数和析构函数的所需副作用可能会丢失。对我来说,一个依赖于这些事情的程序极其难以想象是可维护的、健壮的甚至可读的。 - Öö Tiib
1
结构体测试 { static int created; static int destroyed; test() { ++created; } test( test const & ) { ++created; } ~test() { ++destroyed; } }; int test::created = 0; int test::destroyed = 0; 最终的 test::createdtest::destroyed 数字可能会有所不同,但这已经存在很长时间了。当前标准允许复制省略,这将具有完全相同的问题。也就是说,程序员必须知道何时和何地可以省略副本,并理解这意味着什么以及如何影响程序的语义。 - David Rodríguez - dribeas
1
好的,但是目前我们已经有可选的复制省略了,因此这些计数可能已经因平台和函数而异,如果在一个函数中编译器发现省略副本是有利的,而在另一个函数中不是,则可能会有所不同。因此,大多数人可能不关心是否进一步省略一些不必要的副本,例如从函数参数到返回值的副本。 - Öö Tiib
1
@DavidRodríguez-dribeas - 我认为您所忽略的(或者至少在两年前是这样),是C++ 始终允许 符合规范的实现在多个位置内联相同的函数,并在不同的位置执行可选的复制省略。 (或其他任何未指定的行为,例如)。您非常害怕的情况实际上是完全合法的,今天已经发生了。因此,我们仍然面临约翰尼斯最初的问题:是什么让丹麦人感到害怕而开启[DR 1148](http://www.open-std.org/jtc1/sc22/wg21/docs/cwg_defects.html#1148)? - Quuxplusone
显示剩余3条评论

2

David Rodríguez - dribeas对我的问题 “如何允许C++类进行复制省略构造” 给了我以下想法。诀窍是使用lambda延迟评估直到函数体内部:

#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引用传递一个临时变量,我们仍然会得到一份拷贝:

无延迟通过引用传递(临时变量)(应为0次,实为1次):
复制

其中延迟版本省略了拷贝:

带延迟(临时)(没有拷贝,最好):

正如您所看到的,这省略了临时情况下的所有拷贝。
延迟版本在非临时情况下产生一次复制,在临时情况下不产生任何复制。我不知道除了lambda之外还有什么其他方法可以实现这一点,但如果有的话,我会很感兴趣。

1

从t到a,省略复制是不合理的。参数被声明为可变的,因此进行复制,因为预计在函数中进行修改。

从a到返回值i,我看不出任何需要复制的原因。也许这是某种疏忽?按值传递的参数在函数体内感觉像局部变量...我在那里看不出任何区别。


1
不是疏忽,我认为C++03没有特别处理函数参数(因此我认为在C++03中允许省略,可能是无意的)。C++0x FDIS将“除函数或catch子句参数之外”的文本添加到允许NRVO的内容中。 - Steve Jessop
那么我认为没有其他原因,只是委员会的一些成员使用传递常量引用并进行复制的遗留库。因此,他们决定鼓励这种模式。 - Öö Tiib
对于所有感兴趣的人:我在 #llvm 频道中问了一下,他们说可能没有人想到过那种优化。不过我想知道 clang/gcc 在典型的 C++03 类的复制构造函数中删除 / new 调用时如何处理。它们是否能够优化掉复制操作,因为这些操作并没有可观察的副作用。否则,如果没有任何问题,规范没有禁止这样做也是不错的选择。 - Johannes Schaub - litb
@JohannesSchaub-litb,“没有人想到过这种优化”这种说法是不正确的。丹麦国家机构中有人注意到了这种优化是被允许的,并且费尽心思地在C++11中禁止了之前在C++03中被允许的内容。(来源:http://www.open-std.org/jtc1/sc22/wg21/docs/cwg_defects.html#1148)因此,我们需要比“那个”更好的答案。丹麦机构认为这种优化太危险而不允许它,一定有一些原因。(戴上锡纸帽子的时候,但Öö Tiib可能对他上面的建议有所发现...) - Quuxplusone

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

在第三个代码片段中,如果编译时已知 (...) 的值为 false,那么编译器只会生成一个副本。
这意味着,当存在一种简单的替代方案时,编译器有意不执行优化。

@MartinBa,const S& f(const S& a);也适用于临时对象。 - iammilind
是的,const& 版本也适用于临时变量,但这不是你在回答中提出的。 (而且参数为 const 似乎真的没有什么用)。 - Martin Ba
@MartinBa,这就是我的观点。我们不需要根据OP的要求进行另一种优化。已经有几种可用的替代方案,其中一些有用,一些无用。 - iammilind
1
@iammilind提出的解决方法在S是模板类型参数且类型为int的情况下非常低效。C++的一个巨大优势是我们通常不必为基本类型与用户定义类型编写不同的代码;如果没有必要,就不要放弃这个优势。此外,问题不是“我该如何解决这个问题”,而是“为什么这个问题首先存在”。 - Quuxplusone
“假设应用了 NRVO(正如您所问),那么 S f(S)S& f(S&) 之间没有区别!”这是错误的。这意味着以值传递方式传递 S 的调用者将看到其被修改。但实际上并非如此。按值传递的参数始终会被复制,因此如果应用了 RVO,则会应用于返回的副本,而不是传入的实例。函数所做的修改仅在其返回的值中可见,而不在调用者传递的任何实例中。因此,这个答案不仅没有回答问题,而且它的离题也是错误的。 - underscore_d
即使使用了const限定符的参数,如果预期不会使用odr,则解决方法无效。(在C++11/14时代,这对于声明为static类成员的constexpr对象尤其麻烦。具有讽刺意味的是,对于constexpr函数的要求可能会减少痛苦。) - FrankHB

-2

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

现在,实际上编写这样的类可能是一个坏主意,但编译器的工作不是找出这一点,而只是确保输出正确和一致。


3
在特定情况下,即使复制构造函数具有副作用,也可以明确允许省略复制。 - ildjarn
好的,没错。但我认为这些情况下事件的顺序本来就无法保证。 - bdow
2
我的意思是,你的理由没有任何意义,因为已经存在法律情况,会跳过复制构造函数。 - ildjarn

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