按值传递参数时的奇怪行为

时间:2016-10-01 06:31:21

标签: c++ gcc visual-c++

偶然发现一些文章声称,如果函数无论如何都要复制,那么通过值传递可以提高性能。

我从未真正考虑过如何在幕后实施传值。当你像这样做时,堆栈上究竟会发生什么:F v = f(g(h()))?

在思考了一下后,我得出结论,我以这样的方式实现它,即g()返回的值是在f()期望它的位置创建的。因此,基本上,没有复制/移动构造函数调用 - f()将简单地获取g()返回的对象的所有权,并在执行离开f()的范围时销毁它。同样适用于g() - 它会获取h()返回的对象的所有权,并在返回时将其销毁。

唉,编译器似乎不同意。这是测试代码:

#include <cstdio>

using std::printf;

struct H
{
    H() { printf("H ctor\n"); }
    ~H() { printf("H dtor\n"); }
    H(H const&) {}
//    H(H&&) {}
//    H(H const&) = default;
//    H(H&&) = default;
};

H h() { return H(); }

struct G
{
    G() { printf("G ctor\n"); }
    ~G() { printf("G dtor\n"); }
    G(G const&) {}
//    G(G&&) {}
//    G(G const&) = default;
//    G(G&&) = default;
};

G g(H) { return G(); }

struct F
{
    F() { printf("F ctor\n"); }
    ~F() { printf("F dtor\n"); }
};

F f(G) { return F(); }

int main()
{
    F v = f(g(h()));
    return 0;
}

在MSVC 2015上,它的输出正是我的预期:

H ctor
G ctor
H dtor
F ctor
G dtor
F dtor

但是如果你注释掉复制构造函数,它看起来像这样:

H ctor
G ctor
H dtor
F ctor
G dtor
G dtor
H dtor
F dtor

我怀疑删除用户提供的复制构造函数会导致编译器生成移动构造函数,这反过来会导致不必要的“移动”。无论有多大的对象都不会消失(尝试添加1MB数组作为成员变量)。即编译器更喜欢移动&#39;这么多,以至于它根本没有做任何事情。

这似乎是MSVC中的一个错误,但我真的希望有人解释(和/或证明)这里发生的事情。这是问题#1。

现在,如果您尝试使用GCC 5.4.0输出,那么根本没有任何意义:

H ctor
G ctor
F ctor
G dtor
H dtor
F dtor

在创建F之前必须销毁H! H是g()范围的本地!请注意,在这里使用构造函数对GCC没有任何影响。

与MSVC相同 - 对我来说看起来像个错误,但有人可以解释/证明这里发生的事情吗?这是问题#2。

经过多年与C ++的专业合作之后,我遇到了这样的问题,真是愚蠢......经过近40年的编制,仍然无法就如何传递价值达成一致意见?

3 个答案:

答案 0 :(得分:4)

对于按值传递参数,该参数是函数的局部变量,并且从函数调用的相应参数初始化。

按值返回时,会有一个名为返回值的值。这是由&#34;参数&#34;初始化的。到return表达式。它的生命周期是包含函数调用的完整表达式的结束。

还有一个名为copy elision的优化,可以在少数情况下应用。其中两个案例适用于按价值返回:

  • 如果返回值由另一个相同类型的对象初始化,则两个对象可以使用相同的内存位置,并且跳过复制/移动步骤(在允许或禁止的情况下有一些条件)
  • 如果调用代码使用返回值初始化相同类型的对象,则可以对返回值和目标对象使用相同的内存位置,并跳过复制/移动步骤。 (这里&#34;相同类型的对象&#34;包括函数参数)。

这两者可以同时应用。此外,从C ++ 14开始,复制elision对于编译器是可选的。

在你的电话f(g(h()))中,这是对象列表(没有复制省略):

  1. H默认构造为return H();
  2. Hh()返回值,是从(步骤1)复制构造的。
  3. ~H(第1步)
  4. Hg的参数是从(步骤2)复制构造的。
  5. G默认构造为return G();
  6. Gg()返回值,是从(步骤5)复制构造的。
  7. ~G(第5步)
  8. ~H(第4步)(见下文
  9. Gf的参数,是从(步骤6)复制构造的。
  10. F默认构造为return F();
  11. Ff()返回值,是从(步骤10)移动构造的。
  12. ~F(第10步)
  13. ~G(第9步)(见下文
  14. F v是从(步骤11)移动构造的。
  15. ~F~G~H(第2步,第6步,第11步)被销毁 - 我认为没有必要订购三个
  16. ~F(第14步)
  17. 对于复制省略,步骤1 + 2 + 3可以组合成&#34; h()的返回值是默认构造的&#34;。同样地,对于5 + 6 + 7和10 + 11 + 12。然而,也可以将2 + 4单独组合到&#34; g的参数是从1&#34;复制构造的,并且也可以同时应用这两个等级,给予& #34; g的参数是默认构造的&#34;。

    因为复制省略是可选的,所以您可能会看到来自不同编译器的不同结果。它并不意味着存在编译器错误。你会很高兴听到在C ++ 17中,一些复制方案正在强制执行。

    如果您包含move-constructor的输出文本,那么第二个MSVC案例中的输出会更有启发性。我猜想在第一个MSVC案例中它执行了我上面提到的两个同时完成,而第二个案例省略了&#34; 2 + 4&#34;和&#34; 6 + 9&#34;省音。

    下面:gcc和clang延迟了函数参数的破坏,直到函数调用的全表达式结束。这就是你的gcc输出与MSVC不同的原因。

    从C ++ 17起草过程开始,实现定义是否在我列表中或者在完整表达式结束时出现了这些破坏。可以说,在早期公布的标准中没有充分说明。 See here进一步讨论。

答案 1 :(得分:2)

此行为是因为称为copy elision的优化技术。简而言之,您提到的所有输出都是有效的!是的!因为这种技术是(唯一的)允许修改程序的行为。有关详细信息,请访问What are copy elision and return value optimization?

答案 2 :(得分:0)

M.M和艾哈迈德的回答都向我发出了正确的方向,但他们都没有完全正确。所以我选择在下面写下正确的答案......

  • C ++中的函数调用和返回具有以下语义:
    • 作为函数参数传递的值将复制到函数范围并调用函数
    • 返回值将复制到调用者的范围,被销毁(当我们到达返回完整表达式的结尾时)并且执行离开函数范围

当谈到在类似IA-32的架构上实现它时,很明显这些副本不是必需的 - 在堆栈上分配未初始化的空间(用于返回值)并以这种方式定义函数调用约定是微不足道的它知道在哪里构建返回值。

相同的参数传递 - 如果我们将rvalue作为函数参数传递,编译器可以指示创建该rvalue,使得它将被正确创建(随后调用)函数期望它。

我认为这是 copy elision 被引入标准的主要原因(并且在C ++ 17中是强制性的)。

我一般熟悉复制省略,之前阅读this page。不幸的是我错过了两件事:

  1. 这也适用于使用rvalue初始化函数参数(C ++ 11 12.8.p32):
  2.   

    当一个临时类对象尚未绑定到引用时   (12.2)将被复制/移动到具有相同的类对象   cv-unqualified类型,可以省略复制/移动操作   将临时对象直接构造到目标中   省略了复制/移动

    1. 当复制精灵开始以一种非常特殊的方式影响对象的生命周期时:
    2.   

      当满足某些条件时,允许省略实现   复制/移动类对象的构造,即使复制/移动   对象的构造函数和/或析构函数具有副作用。在   在这种情况下,实施处理的来源和目标   省略了复制/移动操作,只是两种不同的引用方式   对于同一个对象,该对象的破坏发生在   稍后的时间,这两个对象将被销毁   没有优化。这种复制/移动操作的省略,称为   在以下情况下允许复制省略(可以   合并以消除多份副本)

      这解释了GCC输出 - 我们将一些右值传递给一个函数,复制省略开始了,我们最终得到一个对象通过两种不同的方式被引用,而生命周期=所有这些对象中最长的(这是一个临时的生命周期)我们的F v = ...;表达式)。所以,基本上,GCC输出完全符合标准。

      现在,这也意味着MSVC不符合标准!它成功应用了两个复制精简版,但结果对象的生命周期太短。

      第二个MSVC输出符合标准 - 它应用了RVO,但决定不对模块参数应用复制省略。我仍然认为它是MSVC中的一个错误,即使从标准的角度来看代码还可以。

      感谢M.M和Ahmad向我推进正确的方向。

      现在对标准执行的终身规则的咆哮很少 - 我认为它只适用于 与RVO。

      唉,当应用于函数参数的删除副本时,它没有多大意义。实际上,结合C ++ 17强制复制省略规则,它允许像这样的疯狂代码:

      T bar();
      T* foo(T a) { return &a; }
      
      auto v = foo(bar())->my_method();
      

      此规则强制T仅在完整表达结束时销毁。这段代码在C ++ 17中会变得正确。它很丑陋,在我看来不应该被允许。另外,你最终会在调用方(而不是函数内部)中销毁这些对象 - 不必要地增加代码大小并使得确定给定函数是否是无效的过程变得复杂。

      换句话说,我个人更喜欢MSVC输出#1(最像'自然')。应禁止MSVC输出#2和GCC输出。我想知道这个想法是否可以出售给C ++标准化委员会......

      编辑:显然在C ++ 17中,临时的生命周期将变为“未指定”,从而允许MSVC的行为。语言中另一个不必要的黑暗角落。他们应该只是强制要求MSVC的行为。