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

本文最后更新于:4 个月前

一、引子

1.问题

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

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

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

MyString s1 = “123a”;

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

2.完整过程

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

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

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

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

二、Copy Elision

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

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

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

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

1.右值拷贝优化

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

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

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

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

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());      // 用 临时对象 做参数。
}

打印的结果是:

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

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

2.返回值优化(RVO)

RVO:return value optimization

NRVO:Named Return Value Optimization

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

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

依旧是类A:

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

我们返回 A类的对象:

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

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

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

打印结果为:

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

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

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

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

3.举个例子

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

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;
}

打印结果为:

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

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

打印的结果为:

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

三、返回值优化的特例

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

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

A Fun(int num){
  A a1, a2;
	if (num > 0) { return a1; } 
  else { return a2; }
}

打印结果为:

构造函数   // a1 的构造
构造函数   // a2 的构造
拷贝构造   // 临时对象的拷贝构造
析构函数   // a2 的析构
析构函数   // a1 的析构

四、总结

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

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

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

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


 目录

致敬李文亮及各路英雄好汉!