Copy Elision 中的返回值优化和右值拷贝优化

本文最后更新于:4 years ago

1.引子

1.1 问题

对于一个类 MyString ,可以用字符串来实例化一个对象,比如:

1
MyString s2("123a");   // 会调用一次构造函数。

但是当我们用下面的方法去实例化一个对象时,会经历了什么?是只调用一次构造函数,还是调用一次构造函数 + 一次拷贝构造呢?

1
MyString s1 = “123a”;

答案是:只调用了一次构造函数。

1.2 完整过程

MyString s1 = “123a”; 的完整过程应该是以下两步:

1
2
3
4
5
// 1.先调用构造函数,创建一个匿名对象。
MyString("123a");

// 2.再将匿名对象 拷贝给 对象s1。
MyString s1 = MyString("123a");

所以讲道理,应该是先调用一次构造函数,再调用一次拷贝构造。为什么编译器只调用了一次构造函数?

2.Copy Elision

原因就是编译器优化了这一过程,优化方式就是拷贝省略(Copy Elision)中的右值拷贝优化,以避免对 临时对象 无谓的拷贝。

1
2
// 用匿名对象初始化对象s1,就相当于给匿名对象取了个名字 s1,不发生拷贝构造。
MyString s1 = MyString("123a");

具体来说,Copy Elision 包含两个方面:

  • 右值拷贝优化。
  • 返回值优化(RVO 和 NRVO)。

2.1 右值拷贝优化

当一个类的 临时对象 被拷贝赋予同一类型的另一个对象时,通过直接利用该临时对象的方法来避免拷贝操作。

比如有个类 A,有构造函数、拷贝构造、和析构函数:

1
2
3
4
5
class A {
A(){ cout << "构造函数" << endl; }
A(const A &another){ cout << "拷贝构造" << endl; }
~A(){ cout << "析构函数" << endl; }
};

我们用临时对象初始化新的对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
A Fun(){
return A(); // 返回临时对象
}

void Fun(A a){
cout << "void Fun(A a)()" << endl;
}

int main(){
A a1 = A(); // 用 临时对象 初始化 a1,相当于给临时对象命名为 a1
A a2 = Fun(); // 用 临时对象 初始化 a2,相当于给临时对象命名为 a2
Fun(A()); // 用 临时对象 做参数。
}

打印的结果是:

1
2
3
4
5
6
7
构造函数   // A a1 = A() 中临时对象的构造
构造函数 // fun() 中临时对象的构造
构造函数 // Fun(A()) 中 A() 构造匿名对象,并命名为 a
void Fun(A a)()
析构函数 // Fun(A()) 中 a 的析构
析构函数 // a2 的析构
析构函数 // a1 的析构

所以上述过程均不会发生拷贝构造,这就是右值拷贝优化。

2.2 返回值优化(RVO)

RVO:return value optimization

NRVO:Named Return Value Optimization

返回值优化包括具名返回值优化(NRVO)与无名返回值优化(URVO),两者的区别在于返回值是具名的局部变量还是无名的临时对象。

但实际上,无论是具名的局部变量还是无名的临时对象,被当作返回值时,都应该拷贝构造一个新的临时对象,并返回新的临时对象。

依旧是类A:

1
2
3
4
5
class A {
A(){ cout << "构造函数" << endl; }
A(const A &another){ cout << "拷贝构造" << endl; }
~A(){ cout << "析构函数" << endl; }
};

我们返回 A类的对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
A FunNRVO(){
A a;
return a; // 具名返回值 NRVO
}

A FunRVO(){
return A(); // 无名返回值 URVO
}

int main(){
FunNRVO();
FUNRVO();
return 0
}

打印结果为:

1
2
3
4
构造函数    // a 的构造,并把 a 当作临时对象返回。
析构函数 // FunNRVO()结束后,a 的析构。
构造函数 // FunRVO()中,临时对象的构造。
析构函数 // FunRVO()结束后,临时对象的析构。

按道理来说,函数返回对象应该是:

  • 先将返回的对象拷贝到一个临时对象中
  • 并析构掉返回的对象,
  • 再将临时对象返回。

但是由于编译的返回值优化,省去了中间的拷贝构造。

2.3 举个例子

这个例子使用了 右值拷贝优化返回值优化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class A {
A(){ cout << "构造函数" << endl; }
A(const A &another){ cout << "拷贝构造" << endl; }
~A(){ cout << "析构函数" << endl; }
};

A FunNRVO(){
A a;
return a; // 具名返回值 NRVO
}

int main(){
A my_a = FunNRVO();
return 0;
}

打印结果为:

1
2
构造函数   // FunNRVO()中 a 的构造。
析构函数 // my_a 的析构。

-fno-elide-constructors 选项可以关闭 Copy Elision ,加上选项,编译运行 g++ main.cc -o out -fno-elide-constructors && ./out

打印的结果为:

1
2
3
4
5
6
构造函数   // FunNRVO()中 a 的构造。
拷贝构造 // 将 a 拷贝为临时对象,并返回。【NRVO优化】
析构函数 // FunNRVO()中 a 的析构。 【NRVO优化后,无需析构】
拷贝构造 // 临时对象拷贝为 my_a。 【右值拷贝优化】
析构函数 // 临时对象的析构。 【右值拷贝优化后,无需析构】
析构函数 // my_a 的析构。

3.返回值优化的特例

RVO是把 将要返回的局部变量 直接构造在临时对象所在的内存中,达到少调用一次copy ctor的目的。

但是,如果编译器不知道他要返回那个变量,就无法优化,比如:

1
2
3
4
5
A Fun(int num){
A a1, a2;
if (num > 0) { return a1; }
else { return a2; }
}

打印结果为:

1
2
3
4
5
构造函数   // a1 的构造
构造函数 // a2 的构造
拷贝构造 // 临时对象的拷贝构造
析构函数 // a2 的析构
析构函数 // a1 的析构

4.总结

右值拷贝优化:优化用 临时变量 初始化新对象的拷贝构造。

返回值优化:优化 用临时变量 做返回值时的拷贝构造。

值得注意的是,以上均是对临时变量的优化。

除拷贝省略(Copy Elision)之外,还有移动语义(Move Semantic)、完美转发(Perfect Forwarding)来消除对象交互之间的无谓的拷贝。有机会来填坑…👋👋


Copy Elision 中的返回值优化和右值拷贝优化
https://www.aimtao.net/copy-elision/
Posted on
2020-05-31
Licensed under