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

Posted

技术标签:

【中文标题】按值传递参数时的奇怪行为【英文标题】:Weird behavior when passing argument by value 【发布时间】:2016-10-01 06:31:20 【问题描述】:

偶然发现几篇文章声称如果函数无论如何都要进行复制,则按值传递可以提高性能。

我从未真正想过如何在后台实现按值传递。当你这样做时,堆栈上究竟会发生什么: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 数组作为成员变量)。 IE。编译器非常喜欢“移动”,以至于它选择它而不是根本不做任何事情。

这似乎是 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++ 多年后,我遇到了这样的问题,这真是太愚蠢了……在将近 4 年之后,编译器仍然无法就如何传递值达成一致?

【问题讨论】:

以防万一——我知道 RVO 是什么……而且我非常了解 C++。然而我找不到这两个问题的好答案 您使用什么优化级别? “编译器不能同意”,复制构造器省略是一个优化 - 所以它是否发生是一个QOI问题(你的程序的正确性不能依赖它)。 优化 lvls 无效。在标准中找不到任何关于 cctor/mctor 省略的东西来解释这里发生了什么。请注意,我们正在讨论将正确类型的 rvalues 作为参数传递给另一个函数——我有点希望这里没有任何额外的副本(或移动)...... 这个链接可以帮助你弄清楚发生了什么:en.cppreference.com/w/cpp/language/copy_elisionGood lucky ***.com/questions/12953127/… 【参考方案1】:

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

按值返回时,有一个值叫做返回值。这是由return 表达式的“参数”初始化的。它的生命周期是直到包含函数调用的完整表达式结束。

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

如果返回值是由另一个相同类型的对象初始化的,那么两个对象可以使用相同的内存位置,并且跳过复制/移动步骤(有一些条件决定何时允许或不允许) 如果调用代码使用返回值初始化相同类型的对象,则返回值和目标对象都可以使用相同的内存位置,并跳过复制/移动步骤。 (这里的“同类型对象”包括函数参数)。

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

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

    H 默认由return H(); 构建 Hh()返回值,是从(步骤 1)复制构造的。 ~H(第 1 步) Hg 的参数,是从(步骤 2)复制构造的。 G 默认由return G(); 构建 Gg()返回值,是从(第 5 步)复制构造的。 ~G(第 5 步) ~H(第 4 步)(见下文Gf 的参数,是从(步骤 6)复制构造的。 F 默认由return F(); 构建 Ff()返回值,是从(第 10 步)移动构造的。 ~F(第 10 步) ~G(第 9 步)(见下文F v 是从(第 11 步)移动构造的。 ~F~G~H(第 2、6、11 步)被破坏 - 我认为这三个不需要排序 ~F(第 14 步)

对于复制省略,步骤 1+2+3 可以组合成“h()返回值是默认构造的”。同样适用于 5+6+7 和 10+11+12。然而,也可以将 2+4 单独组合成“g 的参数是从 1 复制构造的”,也可以同时应用这两个省略号,给出“g 的参数是默认值” -建造”。

由于复制省略是可选的,因此您可能会看到来自不同编译器的不同结果。这并不意味着存在编译器错误。您会很高兴听到在 C++17 中强制要求某些复制省略场景。

如果您包含移动构造函数的输出文本,您在第二种 MSVC 案例中的输出会更有指导意义。我猜想在第一个 MSVC 案例中,它同时执行了我上面提到的两个省略,而第二个案例省略了“2+4”和“6+9”省略。

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

在 C++17 起草过程中,实现定义这些破坏是发生在我的列表中的位置,还是出现在完整表达式的末尾。可以说,早期发布的标准中没有充分说明它。 See here 进一步讨论。

【讨论】:

不知何故我错过了你的编辑......无论如何,我不同意“追溯应用”——行为是由 C++11 标准明确定义的。 MSVC 不合规。但我很高兴标准将被更改以允许 MSVC 实施——正确的举动。下一个正确的举措是禁止任何其他实现(因为复制省略成为强制性的)。 @C.M.缺陷报告被视为追溯适用于提交缺陷的文件。一个更著名的例子是 C++11 文本指定 A a; A const&amp; b a; 应该复制构造一个 b 绑定的临时对象,而不是直接绑定。 您从哪里获得此信息?我从来没有听说过——这是否意味着它也适用于 C++98?【参考方案2】:

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

【讨论】:

【参考方案3】:

MM 和 Ahmad 的回答都让我朝着正确的方向前进,但他们都不完全正确。所以我选择在下面写一个正确的答案...

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

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

参数传递也是如此——如果我们将右值作为函数参数传递,编译器可以直接创建该右值,以便在(随后调用)函数期望的情况下正确创建该右值。

我想这是 复制省略 被引入标准的主要原因(并且在 C++17 中是强制性的)。

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

    事实上,这也适用于使用右值 (C++11 12.8.p32) 初始化函数参数:

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

    当复制省略开始时,它会以一种非常特殊的方式影响对象的生命周期:

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

这解释了 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 中正确。这是丑陋的,在我看来不应该被允许。此外,您最终会在调用方(而不是在函数内部)销毁这些对象——不必要地增加代码大小并使确定给定函数是否为 nothrow 的过程变得复杂。

换句话说,我个人更喜欢 MSVC 输出 #1(因为最“自然”)。 MSVC 输出#2 和 GCC 输出都应该被禁止。不知道这个想法能不能卖给C++标准化委员会……

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

【讨论】:

这是有问题的函数参数的生命周期(不是临时的生命周期),它将在 C++17 中实现定义(未指定)。您使用&amp;a 的示例,无论该代码是否正确,都将由实现定义。 (所以这个构造不应该出现在可移植代码中)。另外,早些时候,您说 MSVC 不符合标准,但实际上它是(它在返回时立即销毁参数) @M.M MSVC case #1 在 C++11 中不符合标准(过早销毁对象)。根据复制省略的定义,临时参数和函数参数都是同一个对象。是的,我的“&a”示例将在 C++17 中表现出“未指定”行为(除非他们更改勘误表)。 MSVC 案例 1 是 C++11 中的标准投诉。函数参数不是临时的。不,您的 &amp;a 示例不会表现出未指定的行为。这是实现定义的行为。 实现将省略的复制/移动操作的源和目标简单地视为引用相同对象的两种不同方式——什么在 C++11 标准的这句话中不清楚吗?不,案例 1 不符合标准,因为有问题的对象(如 C++11 标准明确规定的那样)应该一直存在到完整表达结束(除非 C++17 中的缺陷报告同时适用于 C++ 14 和 C++11,我非常怀疑)。

以上是关于按值传递参数时的奇怪行为的主要内容,如果未能解决你的问题,请参考以下文章

C#在方法调用中,参数按值传递与按引用传递的区别是啥?

按值传递结果

哈希可枚举方法:仅传递一个参数时的行为不一致

cordova.exec 参数中的奇怪行为

Python按值传递参数和按引用传递参数

VB 参数传递:按值传递和按地址传递