构造函数语义学——程序转化语义学

前言

 
考虑如下程序:

1
2
3
4
5
6
#include "X.h"
X foo(){
X xx;
...
return xx;
}

我们可能会做出如下假设:

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

第一个假设的正确性视class X的定义而定。第二个假设的正确性,则视编译器的优化程度而定。我们甚至可以假设,在一个高品质C++编译器中,上述两点假设均不正确。


Explicit Initialization

 
已知存在如下定义:

1
X x0;

下面有三个定义,每一个都显式地以x0的值来初始化class object:
1
2
3
4
5
void foo_var(){
X x1(x0);
X x2 = x0;
X x3 = X(x0);
}

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

  1. 重写每一个定义,剥离其中的初始化操作
  2. class的copy constructor调用操作被插入程序

经过转化后的foo_bar()程序看起来如下所示:

1
2
3
4
5
6
7
8
9
10
void foo_bar(){
//剥离初始化操作
X x1;
X x2;
X x3;
//插入copy constructor调用操作
x1.X::X(x0);//调用 X::X(const X& xx)
x2.X::X(x0);
x3.X::X(x0);
}


Argument Initialization

 
C++ standard中提及,将一个class object作为函数参数传递(或作为函数返回值)等价于以下形式的初始化:

1
X xx = arg;

因此,若有函数及调用语句如下:
1
2
3
4
void foo(X x0);
X xx;
//...
foo(xx);

将会要求local instance x0以memberwise的方式将xx作为初值。
在编译器实现技术上,有一种策略是导入临时对象,并调用copy constructor将它初始化,然后将临时对象转交函数:
1
2
3
X _temp0;
_temp0.X::X(xx);
foo(_temp0);//改写调用

然而这样的转换并不完备,问题出在foo的声明式中。临时对象已经得到了正确的初值,但传入函数作为参数时又再一次导致了临时对象的生成。为了保证函数被正确调用,编译器会修改foo的声明,其形参会由一个class object变为相应的reference:
1
void foo(X& x0);

当foo完成后,临时对象将调用X::~X(),确保自身被析构。

另有一种编译器实现策略:copy construct,它将实际参数直接建构在其应该的位置上,在函数返回之前,局部对象(不同于刚才的临时对象)会被析构(我认为此法就是真正的建立一个object,然后在使用完毕后析构之)。


Return Value Initialization

 
考虑如下函数定义:

1
2
3
4
5
X bar(){
X xx;
//处理xx
return xx;
}

我们需要关注的问题是bar()的返回值将如何从局部对象xx中拷贝。一种解决方案是双阶段转化:

  1. 首先加上一个额外参数,其类型是class object的一个reference,该参数用来放置被copy construct得到的返回值。
  2. 在return指令前插入一个copy constructor操作,将局部对象的copy作为引用初值传递回去。

转换后的bar函数大致如下:

1
2
3
4
5
6
7
void bar(X& _result){
X xx;
xx.X::X();
//处理xx
_result.X::X(xx);
return;
}

同时现在的bar调用操作也被转换,以确保和新的定义匹配:
1
2
3
4
5
//开发者撰写
X xx = bar();
//转换后
X xx;
bar(xx);

对于返回值的相关使用也被改写:
1
2
3
4
5
//开发者撰写
bar().memfunc();//调用成员函数
//转换后
X _temp0;
(bar(_temp0),_temp0).memfunc();//,表达式

同样的,如果程序声明了一个函数指针:
1
2
3
4
5
6
//开发者撰写
X (*pf)();
pf = bar;
//转换后
void (*pf)(X&);
rx = bar;


Optimization at the User Level

 
这种做法就是More Effective C++提及的RVO:

1
2
3
X bar(const T &y,const T &z){
return X(y,z);
}

如果编译器对RVO作出改写,那么应该有形式如下:
1
2
3
4
5
X _result;
void bar(X &_result,const T &y,const T&z){
_result.X::X(y,z);
return ;
}

仅仅付出了一个构造函数的代价,而这正是我们想要的。


Optimization at the Compiler Level

 
在一个诸如bar()的函数中,所有的return指令返回相同的named value,因此编译器可能会自己作出优化:方法是以result参数取代named return value:

1
2
3
4
5
6
7
8
9
10
11
X bar(){
X xx;
...//处理xx
return xx;
}
void bar(X &_result){
//调用default constructor
_result.X::X();
...//处理res
return ;
}

(此种策略就是把xx转换为了_result)
这种编译器优化操作被称为Named Return Value(NRV)。

NRV受到的批判

尽管NRV优化提供了重大的效率改善,但它仍然饱受批判,其原因大致有三:

  1. 优化由编译器自主执行,其执行程度不可控(很少有编译器会说明其实现程度或是否实现)。
  2. 如果函数相当复杂,优化就变得难以执行,许多人认为这种情况应当使用RVO取代之。
  3. 优化可能破坏了原有的程序

一般而言,当一个class object作为另一个class object的初值这种情况发生时,C++允许编译器具有较大的弹性发挥空间。其优点在于机器码产生时效率得到了明显的提高,但缺点就是你无法清楚你的代码究竟被编译成为了何种形式。


是否需要定义copy constructor

 

1
2
3
4
5
6
7
class Point3d{
public:
Point3d(float x,float y,float z);
//...
private:
float _x,_y,_z;
};

上述class有必要定义copy constructor吗?如果不定义,那么编译器会将default copy constructor视作trivial,其memberwise初始化操作会导致bitwise copy,这无疑兼具高效性与安全性。

那么上述class就一定没有必要定义copy constructor吗?答案是否定的。如果class object多次以传值的姿态作为函数参数,又或者作为函数返回值,那么explicit copy constructor有助于编译器执行NRV优化,当然我们也可以显式地使用RVO优化。


总结

 
copy constructor的使用多多少少使得编译器对你的代码做出了转化,如果我们能够了解那些转换,以及copy constructor优化后的可能状态,就可以对程序的执行效率有清楚的认知与控制。