【问题标题】:Why are by-value parameters excluded from NRVO?为什么按值参数从 NRVO 中排除?
【发布时间】:2011-05-15 15:22:11
【问题描述】:

想象一下:

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 的事实,这是在移动/复制构造函数存在副作用的情况下仍然是强制性的唯一副本。

这是为什么呢?

【问题讨论】:

  • 因为原样返回参数不是很有用?
  • @BoP 如果我们更改参数会怎样? S f(S s) { for(E &e : s) e.toupper(); return s; }。编译器可以执行 NRVO,并忽略 return s,因为返回值已经到位。少一份/少搬一份!
  • 嗯,也许它与调用约定有关?调用者必须知道要在哪个传出参数槽中找到返回值。这似乎与其他允许的 NRVO 形式不同。我想得到一个很好的答案来解释它:)
  • @Matthieu 代码是否内联都没关系。如果副本有副作用并且 NRVO 无法应用(即,可观察到的副作用),则必须始终完成副本。我认为,如果 NRVO 可以适用于我的情况,我会少获得一份。
  • @Bo, @Neil:问题当然不是,“这通常是一个好的/容易的优化吗?”,问题是,“为什么标准包含额外的文本,只是为了禁止这个优化?”。禁止它一定有一个积极的理由,“它不值得做”仅在只是标准忽略允许它的问题时才解释这种情况。

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


【解决方案1】:

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

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

为了删除副本,函数内部需要做的就是在调用者提供的位置构造输出。如果函数可以做到这一点,你会得到复制省略。如果函数不能,那么它将使用一个或多个临时变量来存储中间结果,然后将其复制/移动到调用者提供的位置。它仍然是就地构造的,但输出的构造是通过复制发生的。

因此,特定函数之外的世界不必知道或关心函数是否会省略。具体来说,函数的调用者不必知道函数是如何实现的。它没有做任何不同的事情。是函数本身决定是否可以省略。

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

这一切都是由调用者完成的。被调用者不知道也不关心它是变量还是临时变量;它只是给了一个堆栈内存(或寄存器或其他任何东西)。

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

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

因此,C++ 委员会并没有考虑到这种可能性。

【讨论】:

  • 在 C++03 和 C++11 之间,委员会将“表达式是非易失性自动对象的名称”更改为“表达式是非易失性自动对象的名称” (除了函数或 catch 子句参数)”。因此,委员会并非“不愿考虑这种可能性”。它在 C++03 中被允许(可能是偶然的),然后委员会竭尽全力在 C++11 中禁止它。
  • @SteveJessop 说了什么。我不敢相信尼科尔的回答得到了如此多的支持。这是公然不正确的。
  • 追溯看起来是正确的;)是否有任何编译器从参数中实现了对 NRVO 的可能意外允许?我可能假设不是,因为 Nicol 给出了令人信服的理由。在 C++11 修改措辞之后(尽管没有真正的讨论),他们不允许这样做。因此,实际上,也许按照 C++ intention,到目前为止 - 这看起来是一个正确的答案。我确信对于高级 LTO 等,编译器可以在这种情况下执行复制省略,但委员会似乎不想考虑将其作为一个明确定义的事情。
  • 这并不完全正确。复制省略是为了避免各种对象的复制初始化,这与“返回值”无关。在该语言中,不需要在调用者站点强制执行复制初始化。
  • 对于当代的实现,关键是在函数的实现不可见的情况下,参数的省略和返回的对象不能共存,特别是在没有整个程序分析的情况下跨不同的TU。此类函数的代码通常需要遵守 ABI 规则,这可能反过来要求调用者进行复制。 (一个专门的 ABI 仍然可以允许不同的方式。)
【解决方案2】:

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

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

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

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

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

【讨论】:

  • 这是在某些情况下有时不这样做的原因,而不是完全禁止它的原因。根据复制省略,相同的代码已经具有不同的行为。
  • @DeadMG:我不太理解这个论点,如果不允许这样做,那么兼容的编译器会根据实际内联(而不是 inline标识符存在,但真正的代码内联)。
  • struct test { 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 的最终数字可能不同,但这种情况已经存在很长时间了。当前的标准确实允许复制省略,这将具有完全相同的问题。也就是说,程序员必须知道副本可能在何时何地被忽略,并理解这意味着什么以及它如何影响程序的语义。
  • 好的,但是目前我们已经有了可选的复制省略,因此如果在一个函数编译器发现删除副本有利可图,而在其他函数编译器没有发现的话,每个平台和每个函数的计数可能已经不同。所以我们大多数人不太可能关心是否进一步省略了一些不需要的副本,例如从函数参数到返回值的副本。
  • @DavidRodríguez-dribeas — 我相信您缺少的一点(或至少 缺少,两年前)是 C++ 一直 允许一致的实现在多个地方内联相同的函数,并在不同的地方以不同的方式执行可选的复制省略。 (或任何其他未指明的行为,就此而言。)您如此害怕的情况实际上是完全合法的,并且今天已经发生。所以我们仍然留下约翰内斯最初的问题:是什么让丹麦人害怕到打开DR 1148
【解决方案3】:

David Rodríguez - dribeas 对我的问题的回答 '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】:

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

    从 a 到返回值我看不到任何复制的理由。也许这是某种疏忽?按值参数感觉就像函数体内的本地人......我看不出有什么区别。

    【讨论】:

    • 我认为不是疏忽。 C++03 没有对函数参数进行特殊处理(所以我认为 C++03 中允许省略,可能是无意的)。 C++0x FDIS 在允许 NRVO 的文本中添加“除了函数或 catch 子句参数之外”。
    • 然后我看不出其他原因,但一些委员会成员的遗留库通过 const 引用传递然后复制。所以他们决定鼓励这种模式。
    • 对于所有感兴趣的人:我在#llvm 频道问过,他们说可能没有人想到那个优化。我想知道 clang/gcc 如何处理典型 C++03 类的复制 ctor 中的 delete/new 调用。他们是否可以在知道它们不是可观察到的副作用的情况下优化副本。否则,如果没有任何问题,我认为没有规范会很好!
    • @JohannesSchaub-litb 说“没有人想到优化”是不正确的。丹麦国家机构中的某个人确实注意到优化是允许的,并且在 C++11 中禁止以前允许 在 C++03 中。 (来源:open-std.org/jtc1/sc22/wg21/docs/cwg_defects.html#1148)所以我们需要一个比 that 更好的答案。丹麦机构认为这种优化太危险而不允许这样做一定是有原因的。 (锡箔帽时间,但 Öö Tiib 可能会对他的上述建议有所了解......)
    【解决方案5】:

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

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

    如果 f() 的编码如您的示例中所述,那么假设 有意复制 或预期副作用是完全可以的;否则为什么不通过参考选择通行证/退货?

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

    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 个 sn-p 中,如果在编译时知道 (...)false,那么编译器只会生成 1 个副本。
    这意味着,当有一个简单的替代方案可用时,编译器故意不执行优化。

    【讨论】:

    • @MartinBa, const S&amp; f(const S&amp; a); 也适用于临时工。
    • 是的,const&amp; 版本也适用于临时工,但这不是您在答案中提出的。 (而且 arg be const 似乎真的没用)
    • @MartinBa,这就是我的论点。我们不需要 OP 要求的其他优化。已经有几种选择,有些有用,有些没用。
    • @iammilind 建议的解决方法在Sint 类型的模板类型参数的情况下效率极低。 C++ 的一大优点是我们通常不必为原始类型和用户定义类型编写不同的代码。如果我们不必这样做,我们就不要放弃这种优势。 此外,问题不是“我该如何解决这个问题”,而是“为什么这个问题首先存在”。
    • "假设如果 NRVO 适用(如您所问),那么 S f(S)S&amp; f(S&amp;) 之间没有区别!" 这是错误的。这意味着按值传入S 的调用者会看到它被修改。但这不是它的工作方式。始终按值复制参数,因此如果应用了 RVO,它将应用于返回的副本,而不是传入的实例。函数所做的修改仅在其返回值中可见,而不是任何内容调用者传入的实例。所以,这个答案不仅没有回答所问的问题,而且它的切线是错误的。
    【解决方案6】:

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

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

    【讨论】:

    • 在某些情况下特别允许复制省略,无论复制构造函数是否有副作用。
    • 好的,没错。但我认为这些情况下无论如何都无法保证事件的顺序。
    • 我的意思是,您的理由毫无意义,因为已经在法律情况下会跳过复制构造函数。
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2020-09-22
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多