为什么这里没有省略复制构造函数?

6
我正在使用带有-O2的gcc。
这似乎是省略复制构造函数的简单机会,因为在bar的副本中访问foo字段的值没有任何副作用;但是复制构造函数确实被调用了,因为我得到了输出meep meep!
#include <iostream>

struct foo {
  foo(): a(5) { }
  foo(const foo& f): a(f.a) { std::cout << "meep meep!\n"; }
  int a;
};

struct bar {
  foo F() const { return f; }
  foo f;
};

int main()
{
  bar b;
  int a = b.F().a;
  return 0;
}

你是否在问为什么调用复制构造函数而不是简单地返回原始实例的字段值? - Michael Petito
1
你怎么知道它没有被省略?为什么你认为它应该被省略?请根据这两个问题修改你的问题。 - anon
@Neil,抱歉,我以为很清楚了,但是已经编辑过了。 - Jesse Beder
我猜可能是因为,虽然访问字段本身没有任何副作用,但复制构造函数却有。因此,访问字段本身具有不具有复制构造函数副作用的副作用。 - Nick Lewis
输出“meep meep”是一个相当明显的副作用。但编译器实际上可以忽略它,但可能不会这样做。在优化方面,你需要查看编译器生成的代码,而不是依赖于查看程序输出。 - anon
显示剩余2条评论
4个回答

11

这不是12.8/15中描述的两种复制构造函数省略的情况之一:

返回值优化(当从函数返回一个自动变量时,将自动变量复制到返回值的复制被省略,通过在返回值中直接构造自动变量来实现)- 不是。 f不是自动变量。

临时初始化程序(当将临时对象复制到对象时,且不是构造临时对象并将其复制,而是将临时值直接构造到目标中)- 不是。 f也不是临时值。b.F()是临时值,但它没有被复制到任何地方,只是访问了一个数据成员,因此当你退出F()时就没有东西可以省略了。

由于既不符合复制构造函数省略的两种合法情况,又因为将f复制到F()的返回值会影响程序的可观察行为,所以标准禁止对其进行省略。如果您用一些不可观察的活动替换打印,并检查汇编代码,您可能会看到该复制构造函数已被优化掉了。但这是在“as-if”规则下,而不是在复制构造函数省略规则下。


1
有趣,谢谢。不过为什么标准不允许这种情况呢? - Jesse Beder
出于同样的原因,它不允许省略任何包含对 std::cout<< 的调用的旧函数。最重要的问题是,为什么标准会允许改变程序可观察行为的“优化”?答案是,为了避免荒谬的临时复制和返回值链,并且没有人想依赖这两种情况下的复制。如果您想在您的情况下避免复制,可以返回一个 const foo &。在两种合法的复制构造函数省略情况下,您无法通过这种方式避免复制。 - Steve Jessop
所以我怀疑没有添加省略的情况来覆盖您的示例的原因是,您可以自己进行操作,在没有必要的情况下,破坏行为的特殊情况是不好的。虽然这只是一个猜测。 - Steve Jessop
听起来大概是对的;虽然我一直认为复制省略不是出于实用性考虑,而是出于理论考虑(在证明程序方面)。我(显然)没有读过标准,所以我想象它说的是,“编译器可以假设复制构造函数产生了一个理想的副本”。我仍然不确定他们为什么不这样做,但也许就像你说的那样:没有人需要它。 - Jesse Beder
我猜对于 foo a; foo b(a); 不调用复制构造函数(当调用是可观察的)被认为是一步太远了。我的意思是,那段代码几乎不能更清楚地表明它想要调用它。如果在函数中稍后既不修改 a 也不修改 b,则其他所有条件相等,编译器可以省略 b,并将所有对它的引用转换为对 a 的引用。但是,如果其他条件不相等(即如果复制是可观察的),标准规定用户已经要求复制,因此用户会得到一个副本。 - Steve Jessop

2
Copy elision只会在没有必要进行复制时发生。特别地,当有一个对象(称为A)在函数执行期间存在,另一个对象(称为B)将从第一个对象进行复制构造,并在那之后立即销毁A(即在退出函数时),就会出现这种情况。
在这种非常特定的情况下,标准允许编译器将A和B合并成两种不同的引用方式,指向同一个对象。它不是要求先创建A,然后从A中复制构造B,最后销毁A,而是允许将A和B视为指向同一对象的两种方式,因此(唯一的)对象被创建为A,函数返回后开始被称为B,但即使复制构造函数具有副作用,从A创建B的复制也可以被跳过。此外,请注意,在这种情况下,A(作为与B不同的对象)也永远不会被销毁--例如,如果你的析构函数也具有副作用,则也会被省略。
您的代码不符合该模式--第一个对象在用于初始化第二个对象之后并没有立即消失。在F()返回后,有两个对象实例。在这种情况下,[命名]返回值优化(又名复制省略)根本不适用。
复制省略适用的演示代码:
#include <iostream>

struct foo {
  foo(): a(5) { }
  foo(const foo& f): a(f.a) { std::cout << "meep meep!\n"; }
  int a;
};

int F() { 
    // RVO
    std::cout << "F\n";
    return foo();
}

int G() { 
    // NRVO
    std::cout << "G\n";
    foo x;
    return x;
}

int main() { 
    foo a = F();
    foo b = G();
    return 0;
}

在启用优化时,MS VC++和g++都会优化掉此代码中的复制构造函数。即使关闭了优化,g++也会将它们全部优化掉。关闭优化时,VC++会优化匿名返回值,但对于命名返回值则使用复制构造函数。


1
更好的理解拷贝省略是看作临时对象。这也是标准描述它的方式。如果一个临时对象在被销毁之前立即复制到永久对象中,那么它就允许被“折叠”成一个永久对象。
在函数返回值中构造了一个临时对象。但实际上它并没有参与任何操作,因此你希望它被跳过。但如果你这样做呢?
b.F().a = 5;

如果省略了复制操作,而直接对原始对象进行操作,那么你将通过非引用方式修改了变量 b。

但是编译器只有在它不被用作左值时才可能省略复制。 - Jesse Beder
@Jesse:在我的示例中,它不作为左值使用,而是作为“.”运算符的左侧使用。 - Potatoswatter
@Potatoswatter - 但是,然后 . 运算符的结果就用作左值。我不是 100% 确定我的 C++ 术语,所以也许 lvalue 不是正确的词,但我要找的概念应该是可传递的。 - Jesse Beder
@Jesse:问题在于,任何东西都可以返回对象内部的非const引用。例如,F().get_a() = 5或者get_a( F() ) = 5。复制省略是一种常见模式的特殊情况,而你并没有遵循这种模式。对于普通读者来说,看起来像是你创建了一个副作用,并且想要看到它发生。 - Potatoswatter
@Potatoswatter - 所以在这些情况下,复制品不会被省略。我建议编译器可以确定是否需要复制,并在需要时执行复制。如果foo::get_a()返回foo内部字段的引用,则不会被省略。但是,如果foo::get_a()返回字段的副本,则不需要复制foo。重点是,在这种情况下(和其他情况下),在假设复制构造函数进行“理想”复制的情况下,制作副本和不制作副本之间没有可观察到的差异(这里并非如此;但这与通常的复制省略假设相同)。 - Jesse Beder
2
@Jesse:你本质上是在调用“仿佛”规则。实际上,如果没有任何影响,编译器不必进行复制。在你的情况下,你引入了一个与复制无关的副作用,因此编译器调用了副作用。说到这一点,也许它在你的程序中并没有进行复制。也许它只是打印了一些象征性的东西。 - Potatoswatter

1

复制构造函数被调用是因为a) 没有保证你在复制字段值时没有修改,b) 因为你的复制构造函数具有副作用(打印一条消息)。


3
带有副作用的复制构造函数可以省略。总之,不要编写这样的复制构造函数。 - anon

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