C++对象模型之编译器如何处理函数返回一个对象

来源:互联网 发布:linux不能图形界面切换 编辑:程序博客网 时间:2024/05/16 00:53

转载自:http://blog.csdn.net/ljianhui/article/details/46318801

1、与经验不符的输出
我们知道,当发生以下三种情况之一时,对象对应的类的复制构造函数将会被调用:
1)对一个对象做显示的初始化操作时
2)当对象被当作参数传递给某个函数时
3)当函数返回一个类的对象时

所以,当我们设计一个函数(普通或成员函数)时,经验告诉我们,出于效率的考虑,应该尽可能返回一个对象的指针或引用,而不是直接返回一个对象。因为在直接返回一个对象可能会引起对象的复制构造过程,这意味着会发生一定量的内存复制和对象创建的动作,从而降低了程序的效率。这个设计的想法是正确的,但是实际上,当函数返回一个对象时,上述的复制构造过程一定会发生吗?

例如,对于如下的代码:
[cpp] view plaincopyprint?
  1. class X  
  2. {  
  3.     public:  
  4.         X()  
  5.         {  
  6.             mData = 100;  
  7.             cout << "X::X()" << endl;  
  8.         }  
  9.         X(const X& rhs)  
  10.         {  
  11.             mData = rhs.mData;  
  12.             cout << "X::X(const X&rhs)" << endl;  
  13.         }  
  14.         void setData(int n)  
  15.         {  
  16.             mData = n;  
  17.         }  
  18.         void print()  
  19.         {  
  20.             cout << "X::mData == " << mData << endl;  
  21.         }  
  22.     private:  
  23.         int mData;  
  24. };  
  25.   
  26. X func()  
  27. {  
  28.     X xx;  
  29.     xx.setData(101);  
  30.     return xx;  
  31. }  
  32.   
  33. int main()  
  34. {  
  35.     X xx = func();  
  36.     return 0;  
  37. }  

看了上面的代码片断,你认为这个程序应该输出什么呢?若按照书本上的说法进行分析,在func函数中,定义了类X的一个局部对象xx,所以类X的构造函数会被调用;在func函数返回的返回值是一个对象,那么该函数将返回对象xx的一个副本,所以类X的复制构造函数会被调用;在main函数中,同样定义了类X的一个局部对象xx,而该对象是通过函数func返回的对象作为初值进行构造的,所以类X的复制因构造该副本而被调用。也就是说,根据这个分析,输入结果应该是:
X::X()
X::X(const X&rhs)
X::X(const X&rhs)

原本我也认为输出的应该是上面的三行,但是实际的运行结果如下图所示,它完全出乎我的意料:


从运行结果来看,只输出了一行“X::X()”,也就是说它只构造出了一个类X对象,而且没有发生任何的复制构造过程。我们的分析依据都是正确的,程序代码非常简单,分析的流程也正确,但是程序的行为究竟为什么与我们的分析不符呢?其实一切都是编译器出于优化的考虑,暗中修改了我们程序的代码。下面先介绍编译器处理返回对象的一种方法,再介绍编译器的优化究竟对我们的代码动了什么手脚。

2、编译器处理返回对象的一种方法
当函数调用完毕后,会销毁其局部对象,若函数返回一个局部对象,编译器如何把这个局部对象复制出来呢?方法如下:
1)首先为函数加上一个额外的参数,类型是类对象的引用。这个参数将用来存放被“复制建构”而得的返回值
2)在return指令之前安插一个复制构造调用操作,以便将欲传回的对象的内容当做上述新参数的初值。

该方法的两个操作会重新改写函数,使它不用返回任何值。根据这个方法,func函数的操作可能转换为如下的伪代码:
[cpp] view plaincopyprint?在CODE上查看代码片派生到我的代码片
  1. // C++伪代码,模拟构造函数和复制构造函数的调用  
  2. void func(X &__result)  
  3. {  
  4.     X xx;  
  5.     xx.X::X(); // 调用类X的默认构造函数  
  6.      
  7.     xx.setData(101);  
  8.      
  9.     __result.X::X(xx); // 调用类X的复制构造函数  
  10.      
  11.     return;  
  12. }  

现在编译器必须转换每一个func()调用操作,以符合其新的定义,即
X xx = func();
会被转换成为下列语句:
X xx; // 并不调用类X的构造函数
func(xx);

所以,main函数会被转换成如下伪代码
[cpp] view plaincopyprint?在CODE上查看代码片派生到我的代码片
  1. // C++伪代码,模拟构造函数和复制构造函数的调用   
  2. int main()  
  3. {  
  4.     X xx; // 并不调用类X的构造函数  
  5.     func(xx);  
  6.     return 0;  
  7. }  

根据上述编译器的操作,可以得到如下的输出:
X::X()
X::X(const X&rhs)
第一行为func函数中局部对象xx的构造,第二行为为了达到返回的目的而发生的复制构造操作。这个结果虽然与第1节中的分析有所不同,从编译使用这个方法却能减少一次复制构造函数的调用,提高了效率,毕竟对同一个对象复制两次也没有什么好处。而且编译对这个程序还会做进一步的优化,在第3节会详细讲述。

考虑函数func的另一种使用情况,就是直接使用函数func的返回值,而不将其赋给一个变量。把main函数修改成如下所示:
[cpp] view plaincopyprint?在CODE上查看代码片派生到我的代码片
  1. int main()  
  2. {  
  3.     func().print();  
  4.     return 0;  
  5. }  

当遇到上述情况时,为使代码正确运行,编译器可能会进行如下转换:
[cpp] view plaincopyprint?在CODE上查看代码片派生到我的代码片
  1. // C++伪代码,模拟编译器的相关处理操作  
  2. int main()  
  3. {  
  4.     {  
  5.         X __temp;  
  6.         func(__temp);  
  7.         __temp.print();  
  8.     }  
  9. }  

在《深度探索C++对象模型》一书中的例子,对于这种情况,转换后的代码没有放在一个花括号内,但是我个人认为这样做更合理。因为根据C++的定义,func函数返回的是一个临时对象,所以当语句
func().print();
运行结束后,该临时变量应该被销毁。把转换后的语句放在一对花括号中,当运行完转换后的语句后,__temp临时变量就会因出了作用域而被销毁。但是编译器是否这样做,我就不知道了。

同理,如果程序中定义了一个函数指针变量,并指向了该函数,则编译器还需要改写该函数指针的定义。

3、NRV优化
在第2节中已经分析了编译器如何处理返回一个对象的函数,但是其结果与我们程序的输出还是不一样,这是因为编译器对程序做了进一步的优化,方法就是以增加的类对象引用的参数(result参数)取代返回值的名字(named return value)。

使用此策略,func函数转换成如下的伪代码:
[cpp] view plaincopyprint?在CODE上查看代码片派生到我的代码片
  1. // C++伪代码,模拟构造函数和复制构造函数的优化  
  2. void func(X &__result)  
  3. {  
  4.     __result.X::X(); // 调用类X的默认构造函数  
  5.      
  6.     __result.setData(101);  
  7.      
  8.     return;  
  9. }  

main函数转换后的伪代码不变,如下:
[cpp] view plaincopyprint?在CODE上查看代码片派生到我的代码片
  1. int main()  
  2. {  
  3.     X xx; // 并不调用类X的构造函数  
  4.     func(xx);  
  5.     return 0;  
  6. }  

通过这个优化,可以看在在main函数的调用过程中,只会调用一次构造函数,且不会调用复制构造函数。这样的编译优化操作,被称为Named Return Value(NRV)优化。NRV优化如今被视为标准C++编译器的一个义不容辞的优化操作。所以第1节中产生的出人意料的输出,正是NRV优化的结果。

虽然NRV优化提供了重要的效率改善,但是优化是由编译器默默完成的,而它是否真的被完成,并不十分清楚,而且一旦函数变得比较复杂,优化也难以施行。

0 0