四时宝库

程序员的知识宝库

C++高阶:编译器的返回值优化(ROV)知多少?

ROV定义

返回值优化(Return value optimization,缩写为RVO)是C++的一项编译优化技术,即删除保持函数返回值的临时对象。这可能会省略两次复制构造函数,即使复制构造函数有副作用。典型地,当一个函数返回一个对象实例,一个临时对象将被创建并通过复制构造函数把目标对象复制给这个临时对象。C++标准允许省略这些复制构造函数,即使这导致程序的不同行为,即使编译器把两个对象视作同一个具有副作用。

返回值优化,是一种属于编译器的技术,它通过转换源代码和对象的创建来加快源代码的执行速度。当函数需要返回一个对象的时候,如果自己创建一个临时对象用户返回,那么这个临时对象会消耗一个构造函数(Constructor)的调用、一个复制构造函数的调用(Copy Constructor)以及一个析构函数(Destructor)的调用的代价。而如果稍微做一点优化,就可以将成本降低到一个构造函数的代价,这样就省去了一次拷贝构造函数的调用和一次析构函数的调用。


实例分析

我们根据下面函数的运行情况,分析ROV机制原理和注意事项。

#include<iostream>
using namespace std;
class A {
public:
A() {
    cout << this << " default constructor" << endl;
}
A(int x) : x(x) {
    cout << this << " transform constructor" << endl;
}
A(const A &a) {
    cout << this << " copy constructor" << endl;
}
int x;
};

A func1() {
A temp(69);//transform constructor
cout << "&temp = " << &temp << endl;
return temp;
}

A func2() {
return A(70);
}

int main() 
{
A a = func1();
cout << "&a = " << &a <<" a.x=" << a.x << endl;
cout<<"-----------------"<<endl;
A b = func2();
cout << "&b = " << &b <<" b.x=" << b.x << endl;
return 0;
}

编译器默认优化运行结果:

思考:

为什么没有调用拷贝构造?

如果没有调用拷贝构造那么 a 对象是不是就没被初始化?

如果 a 没被初始化,为什么 a.x = 69呢?

a的地址为什么和temp地址完全一样?


理解分析

fun1()和fun2() 函数返回了一个A对象,而且是按值传递的。

①分析fun1()

使用 temp 调用临时匿名变量的拷贝构造函数”。

func1() 函数的返回值对应一个临时匿名变量,return temp;就是将 temp 拷贝给临时匿名变量,即 临时匿名变量 = temp,调用了临时匿名变量的拷贝构造函数

整个过程如下:

  1. 先开辟对象 a 的数据区;
  2. 然后开辟 temp对象的数据区;
  3. 完成 temp的构造;
  4. 将 temp拷贝给临时匿名变量;
  5. 完成临时匿名变量的构造;
  6. 将临时匿名变量拷贝给对象 a ;
  7. a 对象完成拷贝。

因为 temp 是局部对象,如果未来 temp会拷贝给 a 对象,意味着在 temp上做的所有操作都相当于是加在 a 对象上,莫不如直接将 temp当做 a 的别名,操作temp_a 就相当于操作 a 对象。

所以编译器的优化:将 temp 调用过程中的 this 指针全都替换为 a 对象的地址,这就叫做返回值优化。

②分析fun2()

内部创建了两个临时对象。

在没有任何“优化”之前,fun2代码的行为也许是这样的:

return A(70) 这行代码中,构造了一个A类的临时的无名对象(姑且叫它t1),接着把 t1 拷贝到另一块临时对象 t2(不在栈上),然后函数保存好 t2 的地址(放在 eax 寄存器中)后返回,fun2的栈区间被“撤消”(这时 t1 也就“没有”了,t1 的生存域在fun2中,所以被析构了),在 A b= fun2(); 这一句中,b 利用 t2 的地址,可以找到 t2 进行,接着进行构造。这样 b 的构造过程就完成了。然后再把 t2 也“干掉”。

整个过程如下:

  1. 先开辟对象 b 的数据区;
  2. 然后开辟A类的临时的无名对象t1的数据区;
  3. 完成 t1的构造;
  4. 将 t1拷贝给临时匿名变量t2;
  5. 完成临时匿名变量t2的构造;
  6. 将临时匿名变量t2拷贝给对象b ;
  7. b对象完成拷贝。

可以看到,在前面说的这个过程中,t1 和 t2 这两个临时的对象的存在实在是很浪费的,占用空间不说,关键是他们都只是为b的构造而存在,b构造完了之后生命也就终结了。既然这两个临时的对象对于程序员来说根本就“看不到、摸不着”,于是编译器干脆不生成它们!编译器“偷偷地”在我们写的fun函数中增加一个参数 A&,然后把 b 的地址传进去(注意,这个时候 b 的内存空间已经存在了,但对象还没有被“构造”,也就是构造函数还没有被调用),然后在函数体内部,直接用 b 来代替原来的“匿名对象”,在函数体内部就完成 b 的构造。这样,就省下了临时变量的开销。这就是所谓的“返回值优化”(编译器一般默认是打开此优化功能的)。


③反向验证:

我们可以设置让编译器不主动优化,看一下编译器内部实际的运行结果,从而验证我们前面的分析。

补充说明:关掉返回值优化的方法

//编译的时候添加一个编译选项,关掉返回值优化

g++ -fno-elide-constructors test_ROV.cpp

下面是编译关闭返回值优化的运行结果:

对于fun1(),可以看到 temp 和 a 对象的地址不同,一共1次有参构造 + 2次拷贝构造。即,temp的构造、调用了临时匿名对象的拷贝构造(temp拷贝给临时匿名对象)、a对象的拷贝构造。

对于fun2(),一共1次有参构造 + 2次拷贝构造。即,临时无名对象的构造、调用了临时匿名对象的拷贝构造、a对象的拷贝构造。

fun2()的942c地址对应的就是临时无名变量t1,作用相当于fun1()的temp。

fun1()的9448地址和fun2()的944c地址,对应的就是临时匿名变量(前面所说的t2)。


注意点总结和验证

显示写拷贝构造函数,编译器默认进行ROV优化。

②没有显示写拷贝构造函数,编译器默认不进行ROV优化(从图中能看出来a和temp的地址不一样)。

③关闭ROV优化后,拷贝构造函数一定要写准确,要不然会出现上述数值赋值错误的问题。

构造函数编写正确,ROV优化后的结果也是正确的。

构造函数编写正确,没有ROV优化,运行结果也是正确的。

构造函数编写存在问题(没有对x进行赋值),没有ROV优化时,运行结果是错误的

④没有显示写拷贝构造函数,关闭ROV优化后,编译器会执行默认拷贝构造函数。

总结

编译器在拷贝操作上会做一些优化,意味着一段拷贝代码到底调用了几次拷贝构造函数是不确定,也就意味着当进行设计的时候不能改变拷贝构造的语义因为改变了拷贝构造的语义的话只有你自己知道将拷贝构造函数改成了什么功能,但是编译器是不知道你修改了拷贝构造的语义。

  • 拷贝构造的语义是原封不动地拷贝过来,但是赋值运算符的语义是可以修改的,所以,绝大多数编译器会对拷贝构造进行优化,但不会对赋值运算符进行优化。

比如下这段代码没有改变拷贝构造的语义:原封不动地将 a 对象的 x 拷贝过去。

A(const A &a) :x(a.x){
  cout << this << " copy constructor" << endl; 
}

而如下代码则改变了拷贝构造的语义:每次拷贝构造的时候都+1,就不是原封不动地进行拷贝了。

A(const A &a) :x(a.x + 1){
  cout << this << " copy constructor" << endl; 
}

所以,如果改变了拷贝构造的语义,加上编译器的优化,那么程序的结果就是不可预期的,和拷贝次数有关。

  • 如果初始化列表中没有对成员属性进行初始化,那么会调用成员属性的默认构造函数,如果想调用拷贝构造函数,就需要显式地调用拷贝构造。
  • 编译器是通过替换指针实现返回值优化的。
  • 如果必须按值返回对象,通过RVO可以省去创建和销毁局部对象的步骤,从而改善性能。

必须定义拷贝构造函数来“打开”RVO。

尽量用函数的出参传出数据。

  • RVO的应用要遵照编译器的实现而定。这需要参考编译器文档或通过实验来判断是否使用RVO以及何时使用。

发表评论:

控制面板
您好,欢迎到访网站!
  查看权限
网站分类
最新留言
    友情链接