爱技术 & 爱分享
爱蛋蛋 & 爱生活

C++程序转化(NRVO)

这篇文章干啥呢? 不太好说… 大体来说,还是算程序转化语意吧

已知下面的程序片段:

#include "X.h"
X foo()
{
    X xx;
    // ...
    return xx;
}

来吧,你看到这段程序会有什么假设?

我猜,可能会存在以下假设:

  1. 每次foo()被调用,就传回xx的值。
  2. 如果class X定义了一个 copy constructor,那么当foo()被调用时,保证该 copy constructor 也会被调用。

第一个假设的真实性,必须 class X 如何定义而定。

第二个假设的真实性,也部分的依赖于 class X 如何定义而定,但最主要的还是视你的C++编译器的优化层级而定。

如果在一个高质量的C++编译器中,上述的两点对于 class X的 nontrivial definetions 都不正确。

接下来我们详细的讨论一下具体原因。

显式的初始化操作

已知有这样的定义:

X x0;

下面的三个定义,每一个都明显地以x0来初始化其class object:

void foo_bar()
{
    X x1(x0);
    X x2=x0;
    X x3=X(x0);
}

必要的程序转化有两个阶段:

  1. 重写每一个定义,其中的初始化操作会被剥除(这里所谓的定义指的是上述三行,而其实严谨的C++用词,”定义”是指”分配内存”的行为)
  2. class 的 copy constructor 调用操作会被安插进去。

所以上述代码,可能会被转化为如下代码:

void foo_bar()
{
 X x1;  //定义被重写,初始化操作被剥除(即此处不会有constructor调用)
 X x2;  //定义被重写,初始化操作被剥除(即此处不会有constructor调用)
 X x3;  //定义被重写,初始化操作被剥除(即此处不会有constructor调用)

 //编译器安插 x copy constructor 的调用操作
 x1.X::X(x0);
 x2.X::X(x0);
 x3.X::X(x0);
}

其中 x1.X::X(x0); 就表现出对 copy constructor 的调用: X::X(const X& xx);

注:此处拆分主要是为了解释对 copy constructor 的调用。

参数的初始化

当一个 class object 当做参数传给一个函数(或作为一个函数的返回值),相当于以下形式的初始化操作。

x xx=arg;

其中 xx 代表形式参数(或者返回值)而arg代表实际参数。

所以下面的这个函数:

void foo(X x0);

下面的调用方式:

X xx;
//...
foo(xx);

将会要求局部变量,x0 以 memberwise 的方式将 xx作为初值。在编译器实现技术上,有一种策略是导入所谓的临时性 object, 并调用 copy constructor 将它初始化,然后将此临时性object交给函数。

所以可能存在如下的转换:

//编译器产生出来的临时对象
X _temp0;

//编译器对 copy constructor 的调用
——temp0.X::X(xx);

//重新改写函数调用操作,以便使用上述的临时对象
foo(_temp0);

然而这样的转换只完成了一半而已,你会发现这跟之前没啥区别呀… 所以需要对foo()的声明做转换,形式参数必须由原来的 class X object 变成 class X reference:void foo(X &x0)

其中class X 声明了一个 destructor,它会在 foo()函数完成之后被调用,应用在临时性的object上。

还有一种实现方法是”copy constructor”的方式把实际参数直接构建在其应该出现的位置上,此位置视函数活动范围的不同,记录于程序堆栈中。在函数返回之前,局部变量的destructor会被执行。Borland C++编译器就是采用此方法,它也提供一个编译选项,用以指定前一种做法,以便和其早期版本兼容。

返回值的初始化

已知下面这个函数的定义:

X bar()
{
    X xx;
    //...
    return xx;
}

这里编译器会如何把局部变量xx拷贝到返回值中呢?

cfront 编译器的做法是:

  1. 首先加上一个额外参数,类型是 class object 的一个 reference。这个参数将用来放置“copy constructor”而得的返回值。
  2. 在return指令之前安插一个 copy constructor 调用操作,以欲传回的object的内容作为上述新增参数的值。

改写之后的函数没有返回值。所以改写之后的代码可能如下:

//函数转换
//以反映出 copy constructor 的应用
void bar(X &_result)
{
    X xx;
    //编译器所产生的 default  constructor 调用操作

    xx.X::X();

    //...

    //编译器所产生的 copy constructor 调用操作
    _result.X::xx(xx);
    return ;
}

现在编译器必须转换每一个 bar()调用操作,以反映其新定义。

X xx=bar();
//将被转换为下列的语句
X xx;
bar(xx);

bar().memfunc();这样子的语句可能会被转换为:

X _temp0;

(bar(_temp0),temp0).memfunc();  //注意逗号运算符的作用(整个逗号表达式的值是以逗号分隔的列表中的最后一个表达式的值。)

同理,如果程序声明了一个函数指针,Like this:

X(*pf)();
pf=bar;
//被转换为:
void (*pf)(X&);
pf=bar;

使用者层面做优化

前面介绍了一些编译器的实现和优化手段,那么作为程序员我们能做的优化有哪些呢?

看这样两段代码:

X bar(const T &y,const T &z)
{
    X xx;
    //...以y和z来处理 xx
    return xx;
}
X bar(const T &y,const T &z)
{
    //...
    return X(y,z);
}

使用前面的转换手段之后,可以看到后面的代码效率比较高,转换之后的代码如下:

void bar(X &result)
{
    __result.X::X(y,z);
    return;
}

这里的__result被直接计算出来,而不是经由 copy constructor 拷贝而得!这里只关心效率,而不是设计,所以应该根据你的使用场景来考虑到底怎么做。

Named Return Value Optimize

像前面这种函数

X bar(const T &y,const T &z)
{
    X xx;
    //...以y和z来处理 xx
    return xx;
}

一些编译器有独特的优化手段,就是把它变成如下形式。

void bar(X &__result)
{
    //default constructor被调用
    __result.X::X();
    //....直接处理__result
    return ;
}

这种就是 Named Return Value Optimize(即所有的return都返回具有名称的对象)。在现代智能C++编译器中 NRVO是一个义不容辞的优化操作。同时随着现代编译器的不断迭代更新,我们不应该对这种程序转换行为作出假设。

相关参考资料:

理解NRV优化

自己动手理解NRV优化

关于NRV优化

C++ RVO

赞(0) 传送门
本作品采用知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议进行许可。墨影 » C++程序转化(NRVO)