返回值优化和副作用

27
返回结果为:

返回值优化(Return value optimization,RVO)是一种优化技术,涉及复制省略(copy elision),可在某些情况下消除用于保存函数返回值的临时对象。我了解RVO的一般好处,但我有几个问题。

标准在§12.8第32段中对其进行了以下说明:(本文草案)(强调我的)。

当满足特定条件时,实现可以省略类对象的复制/移动构造,即使该对象的复制/移动构造函数和/或析构函数具有副作用。在这种情况下,实现将省略的复制/移动操作的源和目标视为仅表示同一对象的两种不同方式,并且该对象的销毁发生在两个对象将在没有进行优化的情况下被销毁的时间之后。

然后列出了许多实现可能执行此优化的标准。


我有几个关于此潜在优化的问题:

  1. 我习惯于将优化限制为不更改可观察行为。这个限制似乎不适用于RVO。我需要担心标准中提到的副作用吗?是否存在可能导致问题的边缘情况?

  2. 作为程序员,我需要做什么(或不做)才能允许执行此优化?例如,以下内容是否禁止使用复制省略(由于move):

std::vector<double> foo(int bar){
    std::vector<double> quux(bar,0);
    return std::move(quux);
}

编辑

我把这个问题作为一个新问题发布,因为我提到的具体问题在其他相关问题中没有得到直接回答。


可能是什么是复制省略和返回值优化?的重复问题。 - RedX
嗯,也许不是重复问题。你最好重新表述一下问题。 - RedX
1
@RedX 我会更改标题。它并不是真正的重复,但我明白你为什么怀疑它是。 - Marc Claesen
1
auto x = foo(42); 有两种可能的优化方式:1)从 quux 复制/移动到返回值临时变量。2)从返回值临时变量复制/移动到 x。第一种是 NRVO,仅在返回语句中的表达式是名称(即 move(quux) 禁止该优化)时才会发生。第二种仍然可以应用。 - dyp
4个回答

14
我习惯于优化程序时受到限制,以便它们不会更改可观察的行为。这是正确的做法。通常情况下,编译器可以更改代码,前提是更改不可观测。这被称为“as-if”规则。
但是,这个限制似乎不适用于RVO。是的,引用OP中的条款给了一个例外,允许省略复制构造函数,即使它有副作用。请注意,RVO只是复制省略的一种情况(C++11 12.8 / 31中的第一个要点)。
如果复制构造函数具有副作用,使得执行复制省略会导致问题,则应重新考虑设计。如果这不是您的代码,则应考虑更好的替代方案。
基本上,如果可能的话,请使用与函数返回类型相同的cv未修饰类型返回局部变量或临时变量。这样可以允许RVO,但不强制执行(编译器可能不执行RVO)。例如,以下内容是否由于移动而禁止使用复制省略?
// notice that I fixed the OP's example by adding <double>
std::vector<double> foo(int bar){
    std::vector<double> quux(bar, 0);
    return std::move(quux);
}

是的,它会因为你没有返回一个本地变量的名称。
std::vector<double> foo(int bar){
    std::vector<double> quux(bar,0);
    return quux;
}

允许RVO。如果没有执行RVO,那么移动操作比复制操作更好(这也可以解释上面使用std::move的原因),这可能会让你有些担心。不要担心,所有主要编译器都将在此处执行RVO(至少在发布版本中)。即使编译器不执行RVO,但RVO的条件得到满足,它也会尝试进行移动而不是复制。总之,在上面使用std::move肯定会进行移动操作。不使用它可能既不会复制也不会移动任何内容,并且在最坏的情况下(不太可能)会出现移动操作。

(更新:正如haohaolee在评论中指出的那样,以下段落是不正确的。然而,我仍将它们保留在这里,因为它们提出了一个可能适用于没有接受std::initializer_list参数的构造函数的类的想法(请参阅底部的参考文献)。对于std::vector,haohaolee找到了一个解决方法。)

在这个例子中,你可以返回一个大括号初始化列表,从中可以创建返回类型,来强制执行RVO(严格来说这不再是RVO,但为了简单起见我们还是这样称呼它):

std::vector<double> foo(int bar){
    return {bar, 0}; // <-- This doesn't work. Next line shows a workaround:
    // return {bar, 0.0, std::vector<double>::allocator_type{}};
}

请参阅此帖子R. Martinho Fernandes的精彩答案

注意!如果返回类型是std :: vector<int>,上面的最后一个代码将与原始代码具有不同的行为。(这是另一件事。)


我对另一个故事很好奇。我知道如果它是vector<int>,{ bar,0 }将创建一个具有2个元素的向量,但是如何强制进行多参数调用而不是初始化列表调用呢? - haohaolee
对于std::vector<double>,我在GCC 4.8.1上无法使其正常工作。它会创建一个具有两个元素bar和0的向量,除非添加第三个参数std::vector<double>::allocator_type() - haohaolee
@haohaolee:你说得对。它不像我预期的那样工作,因为使用 std::initializer_list 的构造函数占据了优先权。通过改变这个优先级的方法能够解决问题,但是使用 allocator_type 来控制重载分辨率确实有点奇怪。我不是在责备你的方法,这更多是一个 C++(核心和库)的问题。我将更新帖子以反映这些新发现。谢谢。 - Cassio Neri
很抱歉,它仍然存在问题,int bar 应该是 std::vector<double>::size_type bar,因为花括号初始化列表不允许缩小转换(gcc会发出警告)。 - haohaolee
@haohaolee 我知道,但我犹豫使用 std::vector<double>::size_type,以免与 OP 太过分歧。 :-( - Cassio Neri

5
我强烈推荐阅读Stanely B. Lippman的《深度探索C++对象模型》以获取有关命名返回值优化的详细信息和一些历史背景。例如,在第2.1章中,他对命名返回值优化有如下描述:

In a function such as bar(), where all return statements return the same named value, it is possible for the compiler itself to optimize the function by substituting the result argument for the named return value. For example, given the original definition of bar():

X bar() 
{ 
   X xx; 
   // ... process xx 
   return xx; 
} 

__result is substituted for xx by the compiler:

void 
bar( X &__result ) 
{ 
   // default constructor invocation 
   // Pseudo C++ Code 
   __result.X::X(); 

   // ... process in __result directly 

   return; 
}

(....)

Although the NRV optimization provides significant performance improvement, there are several criticisms of this approach. One is that because the optimization is done silently by the compiler, whether it was actually performed is not always clear (particularly since few compilers document the extent of its implementation or whether it is implemented at all). A second is that as the function becomes more complicated, the optimization becomes more difficult to apply. In cfront, for example, the optimization is applied only if all the named return statements occur at the top level of the function. Introduce a nested local block with a return statement, and cfront quietly turns off the optimization.


我同意这很令人困惑,我已经添加了引号。 - Paul

4
它陈述得很清楚,不是吗?它允许省略带有副作用的构造函数。因此,你应该永远不要在构造函数中使用副作用,或者如果你坚持这样做,你应该使用消除(N)RVO的技术。 至于第二个问题,我认为它禁止NRVO,因为std::move产生T&&而不是T,后者将成为NRVO(RVO)的候选对象。因为std::move会删除名称,而NRVO需要名称(感谢@DyP评论)。
我刚在MSVC上测试了以下代码:
#include <iostream>

class A
{
public:
    A()
    {
        std::cout << "Ctor\n";
    }
    A(const A&)
    {
        std::cout << "Copy ctor\n";
    }
    A(A&&)
    {
        std::cout << "Move\n";
    }

};

A foo()
{
    A a;
    return a;
}

int main() 
{
    A a = foo();
    return 0;
}

它生成Ctor,所以我们失去了移动构造函数的副作用。如果您将std::move添加到foo()中,则会消除NRVO。


与g++ 4.7相同的行为...如预期所示。 - thokra
2
这个问题涉及到标准的保证。虽然来自知名供应商的样本很有用,但似乎并不能表明人们可以依赖的行为。 - Brian Cain
1
关于第二点,我认为它禁止了NRVO,因为std::move产生的是T&&而不是T,后者才是NRVO(RVO)的候选对象。在分析表达式[expr]/5之前,引用被丢弃。禁止这样做的原因是NRVO需要一个对象的名称。 - dyp
@BrianCain,楼主引用了标准本身,我只是举了个例子。 - ixSci

0
  1. 这可能很明显,但如果您避免编写具有副作用的复制/移动构造函数(大多数情况下不需要它们),那么问题就完全不存在了。即使在简单的副作用情况下,例如构造/销毁计数,它仍然应该是可以接受的。唯一需要担心的情况是复杂的副作用,这是重新审查代码的强烈设计气味。

  2. 这听起来像是过早优化。只需编写明显且易于维护的代码,让编译器进行优化。只有在分析表明某些区域性能不佳时,您才应考虑采取改进性能的措施。


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