48

我在 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 的副本。有没有办法避免复制?(而且我不想返回指向对象的指针,而是返回对象本身)


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

4

7 回答 7

51

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 规则),就会触发 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在返回槽中构建,因为它可能需要它来做其他事情(取决于布尔条件b)。

因此,一个简单的经验法则是:

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

于 2012-05-07T09:22:37.060 回答
25

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

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

于 2012-05-07T04:37:02.237 回答
14

命名返回值优化将为您完成这项工作,因为编译器会在使用它时尝试消除冗余的复制构造函数和析构函数调用

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 被破坏

(如果您想自己尝试以加深理解,请查看我的这个示例

甚至更好,就像Matthieu M.指出的那样,如果您test在声明的同一行中调用y,您还可以避免构建冗余对象和冗余分配(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);
于 2012-05-07T04:42:10.410 回答
2

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

于 2012-05-07T04:37:13.987 回答
2

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

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

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

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

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

引用C++17 n4659 标准草案15.8.3 [class.copy.elision]“复制/移动省略”

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

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

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

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”成立,则必须发生右值返回。

这在我的代码注释中非常清楚。

通过引用传递 +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。

另一方面,这是:

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

所以有一个权衡。

于 2018-11-28T13:18:40.963 回答
1

引用它会起作用。

Void(vector<> &x) {

}
于 2012-05-07T08:04:18.177 回答
-6

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

您还可以定义一个指针,在方法体中构建一个指针,然后返回该指针(或者该指针的副本是正确的)。

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

于 2012-05-07T04:44:59.503 回答