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

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/ljianhui/article/details/46318801
1、与经验不符的输出
我们知道,当发生以下三种情况之一时,对象对应的类的复制构造函数将会被调用:
1)对一个对象做显示的初始化操作时
2)当对象被当作参数传递给某个函数时
3)当函数返回一个类的对象时

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

例如,对于如下的代码:
class X
{
    public:
        X()
        {
            mData = 100;
            cout << "X::X()" << endl;
        }
        X(const X& rhs)
        {
            mData = rhs.mData;
            cout << "X::X(const X&rhs)" << endl;
        }
        void setData(int n)
        {
            mData = n;
        }
        void print()
        {
            cout << "X::mData == " << mData << endl;
        }
    private:
        int mData;
};

X func()
{
    X xx;
    xx.setData(101);
    return xx;
}

int main()
{
    X xx = func();
    return 0;
}

看了上面的代码片断,你认为这个程序应该输出什么呢?若按照书本上的说法进行分析,在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函数的操作可能转换为如下的伪代码:
// C++伪代码,模拟构造函数和复制构造函数的调用
void func(X &__result)
{
    X xx;
    xx.X::X(); // 调用类X的默认构造函数
   
    xx.setData(101);
   
    __result.X::X(xx); // 调用类X的复制构造函数
   
    return;
}

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

所以,main函数会被转换成如下伪代码
// C++伪代码,模拟构造函数和复制构造函数的调用 
int main()
{
    X xx; // 并不调用类X的构造函数
    func(xx);
    return 0;
}

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

考虑函数func的另一种使用情况,就是直接使用函数func的返回值,而不将其赋给一个变量。把main函数修改成如下所示:
int main()
{
    func().print();
    return 0;
}

当遇到上述情况时,为使代码正确运行,编译器可能会进行如下转换:
// C++伪代码,模拟编译器的相关处理操作
int main()
{
    {
        X __temp;
        func(__temp);
        __temp.print();
    }
}

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

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

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

使用此策略,func函数转换成如下的伪代码:
// C++伪代码,模拟构造函数和复制构造函数的优化
void func(X &__result)
{
    __result.X::X(); // 调用类X的默认构造函数
   
    __result.setData(101);
   
    return;
}

main函数转换后的伪代码不变,如下:
int main()
{
    X xx; // 并不调用类X的构造函数
    func(xx);
    return 0;
}

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

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


猜你喜欢

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