【问题标题】:Avoiding copy of objects with the "return" statement避免使用“return”语句复制对象
【发布时间】:2012-05-15 15:23:19
【问题描述】:

我有一个非常基本的 C++ 问题。 返回对象时如何避免复制?

这是一个例子:

std::vector<unsigned int> test(const unsigned int n)
{
    std::vector<unsigned int> x;
    for (unsigned int i = 0; i < n; ++i) {
        x.push_back(i);
    }
    return x;
}

据我了解 C++ 的工作原理,此函数将创建 2 个向量:本地向量 (x),以及将返回的 x 的副本。有没有办法避免复制? (而且我不想返回指向对象的指针,而是返回对象本身)


使用“移动语义”的函数的语法是什么(在 cmets 中有说明)?

【问题讨论】:

  • 不一定会创建副本。 NRVO 或移动语义可以防止这种情况发生。
  • 您可以依靠您的编译器来执行 NRVO 魔术或明确使用移动语义。
  • “将返回的 x 的副本”可以通过从 x 移动来构造,或者它的构造被省略以成为与 x 相同的对象。该语言的语义已经避免了任何复制。
  • 响应您的编辑 - 您根本不需要更改语法。任何符合复制省略的条件都必须使用移动构造(如果构造没有完全省略)。

标签: c++ object copy return return-value


【解决方案1】:

如果没有发生 NRVO,则保证使用 move 构造函数

因此,如果您通过值返回带有移动构造函数的对象(例如std::vector),则保证不会进行完整的向量复制,即使编译器未能进行可选的 NRVO 优化。

这两位在 C++ 规范本身中颇具影响力的用户提到了这一点:

对我对名人的吸引力不满意?

好的。我不能完全理解 C++ 标准,但我可以理解它的示例! ;-)

引用C++17 n4659 standard draft 15.8.3 [class.copy.elision] "复制/移动省略"

3 在以下复制初始化上下文中,可能会使用移动操作而不是复制操作:

  • (3.1) — 如果 return 语句 (9.6.3) 中的表达式是一个(可能带括号的)id 表达式 在主体或参数声明子句中声明的具有自动存储持续时间的对象 最内层的封闭函数或 lambda 表达式,或
  • (3.2) — 如果 throw 表达式 (8.17) 的操作数是非易失性自动对象的名称(除了 一个函数或 catch 子句参数),其范围不超出最内层的末尾 封闭 try 块(如果有的话),

首先执行为复制选择构造函数的重载解析,就像指定对象一样 通过右值。如果第一个重载决议失败或未执行,或者如果第一个参数的类型 所选构造函数的不是对对象类型的右值引用(可能是 cv 限定的),重载 再次执行解析,将对象视为左值。 [注:此两阶段过载解决方案 无论是否会发生复制省略,都必须执行。它确定要调用的构造函数 if 不执行省略,即使调用被省略,所选的构造函数也必须是可访问的。 - 结尾 注意]

4 [示例:

class Thing {
public:
  Thing();
  ~ Thing();
  Thing(Thing&&);
private:
  Thing(const Thing&);
};

Thing f(bool b) {
  Thing t;
  if (b)
    throw t;          // OK: Thing(Thing&&) used (or elided) to throw t
  return t;           // OK: Thing(Thing&&) used (or elided) to return t
}

Thing t2 = f(false);  // OK: no extra copy/move performed, t2 constructed by call to f

struct Weird {
  Weird();
  Weird(Weird&);
};

Weird g() {
  Weird w;
  return w;           // OK: first overload resolution fails, second overload resolution selects Weird(Weird&)
}

——结束示例

我不喜欢“可能被使用”的措辞,但我认为其意图是表示如果“3.1”或“3.2”成立,则必须发生右值返回。

这对我来说在代码 cmets 上很清楚。

通过引用传递 + std::vector.resize(0) 进行多次调用

如果您对test 进行多次调用,我相信这会稍微更有效率,因为当向量大小翻倍时,它会节省一些malloc() 调用+重定位副本:

void test(const unsigned int n, std::vector<int>& x) {
    x.resize(0);
    x.reserve(n);
    for (unsigned int i = 0; i < n; ++i) {
        x.push_back(i);
    }
}

std::vector<int> x;
test(10, x);
test(20, x);
test(10, x);

鉴于https://en.cppreference.com/w/cpp/container/vector/resize 说:

当调整到更小的尺寸时,向量容量永远不会减少,因为这会使所有迭代器失效,而不仅仅是那些会被等效的 pop_back() 调用序列失效的迭代器。

而且我认为编译器无法优化按值返回的版本来防止额外的 malloc。

另一方面,这个:

  • 让界面更丑
  • 当您减小矢量大小时,使用的内存比需要的多

所以有一个权衡。

【讨论】:

    【解决方案2】:

    编译器通常可以为您优化掉多余的副本(这被称为返回值优化)。见https://isocpp.org/wiki/faq/ctors#return-by-value-optimization

    【讨论】:

      【解决方案3】:

      引用它会起作用。

      Void(vector<> &x) {
      
      }
      

      【讨论】:

        【解决方案4】:

        Named Return Value Optimization 将为您完成这项工作,因为编译器会在使用时尝试消除冗余的复制构造函数和析构函数调用

        std::vector<unsigned int> test(const unsigned int n){
            std::vector<unsigned int> x;
            return x;
        }
        ...
        std::vector<unsigned int> y;
        y = test(10);
        

        带有返回值优化:

        1. y 已创建
        2. x 已创建
        3. x 赋值给 y
        4. x 被破坏

        (如果您想自己尝试更深入了解,请查看this example of mine

        甚至更好,就像Matthieu M. 指出的那样,如果您在声明y 的同一行中调用test,您还可以避免构造冗余对象和冗余分配(x 将在将存储y 的内存中构造):

        std::vector<unsigned int> y = test(10);
        

        查看他的答案以更好地了解这种情况(您还会发现这种优化并不总是适用)。

        您可以修改代码以将向量的引用传递给您的函数,这在语义上更正确,同时避免复制:

        void test(std::vector<unsigned int>& x){
            // use x.size() instead of n
            // do something with x...
        }
        ...
        std::vector<unsigned int> y;
        test(y);
        

        【讨论】:

        • 啊,我明白了。实际上,我忽略了您在分配给它之前首先构造了默认 y 的事实。在这种情况下,您的事件顺序是正确的,尽管我建议您直接初始化 y 以避免创建两个对象就足够了。抱歉打扰了。
        • @MatthieuM.:我很欣赏你的观点。现在检查我的答案:)
        【解决方案5】:

        关于 RVO(返回值优化)的工作原理似乎有些混乱。

        一个简单的例子:

        #include <iostream>
        
        struct A {
            int a;
            int b;
            int c;
            int d;
        };
        
        A create(int i) {
            A a = {i, i+1, i+2, i+3 };
            std::cout << &a << "\n";
            return a;
        }
        
        int main(int argc, char*[]) {
            A a = create(argc);
            std::cout << &a << "\n";
        }
        

        它的输出在ideone:

        0xbf928684
        0xbf928684
        

        令人惊讶?

        其实也就是RVO的效果:要返回的对象是直接在调用者就地构造的。

        如何?

        传统上,调用者(此处为main)会在堆栈上为返回值保留一些空间:返回槽;被调用者(此处为create)被传递(以某种方式)返回槽的地址以将其返回值复制到其中。然后被调用者为它在其中构建结果的局部变量分配自己的空间,就像为任何其他局部变量一样,然后在return 语句上将其复制到返回槽中。

        当编译器从代码中推断出变量可以直接构造到具有等效语义(as-if 规则)的 return slot 时,就会触发 RVO。

        请注意,这是一种常见的优化,它被标准明确列入白名单,编译器不必担心复制(或移动)构造函数可能产生的副作用。

        什么时候?

        编译器最有可能使用简单的规则,例如:

        // 1. works
        A unnamed() { return {1, 2, 3, 4}; }
        
        // 2. works
        A unique_named() {
            A a = {1, 2, 3, 4};
            return a;
        }
        
        // 3. works
        A mixed_unnamed_named(bool b) {
            if (b) { return {1, 2, 3, 4}; }
        
            A a = {1, 2, 3, 4};
            return a;
        }
        
        // 4. does not work
        A mixed_named_unnamed(bool b) {
            A a = {1, 2, 3, 4};
        
            if (b) { return {4, 3, 2, 1}; }
        
            return a;
        }
        

        在后一种情况 (4) 中,当返回 A 时无法应用优化,因为编译器无法在返回槽中构建 a,因为它可能需要它来做其他事情(取决于布尔条件 @ 987654330@)。

        一个简单的经验法则是:

        如果在 return 语句之前没有声明其他返回槽的候选者,则应应用 RVO。

        【讨论】:

        • +1 指出我们不仅可以避免复制,还可以避免构造冗余对象和冗余分配;)
        • 再案例(4),它取决于编译器(它有多聪明)和代码的细节。例如,通过您展示的具体代码,智能编译器可以注意到 a 的初始化没有副作用,并且该声明可以向下移动到 if 下。
        • @Cheersandhth.-Alf:没错,as-if 规则显然仍然有效。但在一般情况下(外线构造函数),这只能在启用 LTO 的情况下扣除。
        • @MatthieuM。如果抛出异常,它仍然有效吗?
        • @naab:我不太确定我看到了问题所在?抛出异常之前的所有局部变量都必须被销毁,无论它们位于何处(在函数框架中或在调用者的函数框架中)。所以我不明白为什么投掷会产生影响。
        【解决方案6】:

        首先,您可以将返回类型声明为 std::vector & 在这种情况下将返回引用而不是副本。

        你也可以定义一个指针,在你的方法体中建立一个指针,然后返回那个指针(或者那个指针的副本是正确的)。

        最后,许多 C++ 编译器可能会进行返回值优化 (http://en.wikipedia.org/wiki/Return_value_optimization),在某些情况下消除临时对象。

        【讨论】:

        • 太糟糕了,引用将立即非法使用(UB)。 -1 表示不好的建议。
        【解决方案7】:

        此程序可以利用命名返回值优化 (NRVO)。见这里:http://en.wikipedia.org/wiki/Copy_elision

        在 C++11 中,移动构造函数和赋值也很便宜。你可以在这里阅读教程:http://thbecker.net/articles/rvalue_references/section_01.html

        【讨论】:

        • 应该注意并不是所有的编译器都会这样做,甚至那些不会一直这样做的编译器。仍然值得研究 IFF 对象很大,您注意到副本,并且分析表明它是一个重要的瓶颈。
        猜你喜欢
        • 1970-01-01
        • 1970-01-01
        • 2021-02-20
        • 2016-07-07
        • 2013-04-29
        • 1970-01-01
        • 2021-10-03
        • 1970-01-01
        相关资源
        最近更新 更多