第6章:执行期语意学
想象一下我们有下面这个简单的式子:
if(yy ==xx.getValue( ))...
其中xx和yy定义为:
X xx;
Y yy;
Class Y定义为:
Class Y{
Public:
Y( );
~Y( );
Bool operator==(const Y&) const;
//...
};
Class X定义为:
Class X{
Public:
X( );
~X( );
Operator Y( ) const;
X getValue( );
//...
};
首先,让我们决定equality(等号)运算符所参考到的真正实体。在这个例子中,它将被决议(resolves)为“被overloaded的Y成员实体”。下面是该式子的第一次转换:
If(yy.operator==(xx.getValue( )))
Y的equality(等号)运算符需要一个类型为Y的参数,然而getValue( )传回的却是一个类型为X的object。若非有什么方法可以把一个X object转换为一个Y object,那么这个式子就算错!本例中X提供一个conversion运算符,把一个X object转换为一个Y object。它必须施行于getValue( )的返回值身上。下面是该式子的第二次转换:
If(yy.operator==(xx.getValue( ).operator Y( )))
到目前为止所发生的一切都是编译器根据class的隐含语意,对我们的程序代码所做的“增胖”操作。如果我们需要,我们也可以明确地写出那样的式子。不,我们并不建议那么做,不过你如果那么做,会使编译器速度稍微快一些。
虽然程序的语意是正确的,当其教育性却尚不能说是正确的。接下来我们必须产生一个临时对象,用来放置函数调用所传回的值:
n 产生一个临时的class X object,用以放置getValue( )的返回值:
X temp1=xx.getValue( );
n 产生一个临时的class Y object,放置operator Y( )的返回值:
Y temp2=temp1.operator Y( );
n 产生一个临时的int object,放置equality(等号)运算符的返回值:
Int temp3=yy.operator==(temp2);
最后,适当的desturctor将被施行于每一个临时性的class object身上。这导致我们的式子被转换为以下形式:
//以下是条件句if(yy==xx.getValue( ))...的转换
{
X temp1=xx.getValue( );
Y temp2=temp1.operator Y( );
Int temp3=yy.operator==(temp2);
If(temp3)...
Temp2.Y::~Y( );
Temp1.X::~X( );
}
哦,代码似乎不少!这是C++的一件困难事情:不太容易从程序代码看出表达式的复杂度。
6.1对象的构造和析构
一般而言,constructor和destructor的安插都如你预期:
//C++伪代码
{
Point point;
//point.Point::Point( )一般而言会被安插在这里
...
//point.Point::~Point( )一般而言会被安插在这里
}
如果一个区段(译注:以{}括起来的区域)或函数中有一个以上的离开点,情况会稍微混乱一些。Destructor必须被放在每一个离开点(当时object还存活)之前,例如:
在这个例子中,point的destructor必须在switch指令四个出口的return操作前被生产出来。另外也很可能在这个区段的结束符号(右大括号)之前被生成出来——即使程序分析的结构发现绝不会进行到那里。
一般而言我们会把object尽可能放置在使用它的那个程序区段附近,这样做可以节省不必要的对象产生操作和摧毁操作。
全局对象
如果我们有以下程序片段:
Matrixidentity;
Main()
{
//identity必须在此被初始化
Matrixml=identity;
...
Return0;
}
C++保证,一定会在main( )函数中第一次用到identity之前,把identity构造出来,而在main()函数结束之前把identity摧毁掉。像identity这样的所谓的globalobject如果有constructor和destructor的话,我们说它需要静态的初始化和内存释放操作。
C++程序中所有的globalobjects都被放置在程序的datasegment中。如果明确指定给它一个值,object将以该值为初值。否则object所配置的内存内容为0。因此在下面这段代码中:
Intv1=1024;
Int v2;
V1和v2都被配置于程序的datasegment,v1值为1024,v2值为0(这和C略有不同,C并不自动设定初值)。在C语言中一个globalobject只能够被一个常量表达式(可以在编译时期求其值的那种)设定初值。当然,constructor并不是常量表达式,虽然classobject在编译时期可以被放置于datasegment中并且内容为0,但constructor一直要到程序激活(startup)时才会实施。必须对一个“放置于programdata segment中的object的初始化表达式”做评估,这正是为什么一个object需要静态初始化的原因。
当cfront还是唯一的C++编译器,而且跨平台移植性比效率的考虑更重要的时候,有一个可移植但成本颇高的静态初始化(以及内存释放)方法,我把它称为munch。下面是munch策略的实现:
1、为每一个需要静态初始化的档案产生一个__sti()函数,内带必要的constructor调用操作或inlineexpansions。
2、类似情况,在每一个需要静态的内存释放操作的文件中,产生一个__std()函数。
3、提供一组runtimelibrary "munch"函数:一个_main( )函数(用以调用可执行文件中的所有__sti( )函数),以及一个exit( )函数(以类似方式调用所有的__std( )函数)。
如图所示:
Cfront在你的程序中安插一个_main()函数调用操作,作为main()函数的第一个指令。这里的exit()和Clibrary的exit()不同。为了链接前者,在cfront的CC命令中必须先制定C++standard library。
最后一个需要解决的问题是,如何收集一个程序中的各个objectfiles的__sti()函数和__std()函数。
我们的解决方法是使用nm命令。nm会倾印出object file的符号表格项目。一个可执行文件系由.o文件产生出来,nm将施行于可执行文件身上。其输出被导入(“pipedinto”)munch程序中。Munch程序会“用力咀嚼”符号表格中的名称,搜寻以__sti或__std开头的名称,然后把函数名称加到一个sti()函数和std()函数的跳离表格中。接下来它把这个表格写到一个小的programtext文件中,然后,CC命令被重新激活,将这个内容表格的文件加以编译。然后整个可执行文件被重新链接。_main()和exit()于是在各个表格上走访一遍,轮流调用每一个项目(代表一个函数地址)。
Cfront2.0版之前并不支持nonclass object的静态初始化操作:也就是说,C语言的限制仍然残留着。所以,像下面这样的例子,每一个初始化都被表示为不合法:
Externint i;
//以下操作都要求静态初始化,在cfront2.0前,这些都不合法
Int j =I;
Int* pi= new int(i);
Doublesal = cpmpute_sal(get_employee(i));
支持“nonclass objects的静态初始化”,在某种程度上是支持virtual base classes的一个副产品。Virtual base classes怎么会扯进这个主题呢?哦,以一个derived class的pointers或reference来存取virtual base class subobject,是一种nonconstant expression,必须在执行期才能加以评估求值。例如,尽管下面程序片段在编译器时期可知:
//constant expression
Vertex3d* pv=new Pvertex;
Point3d* p3d=pv;
其virtual base class Point的subobject在每一个derived class中的位置却可能会变动,因此不能够在编译时期设定下来.。下面的初始化操作:
//Point是Point3d的一个virtual base class
//pt的初始化操作需要某种形式的执行器评估
Point* pt=p3d;
需要编译器提供内部扩充,以支持class object的静态初始化(至少涵盖class object的指针和references)。例如:
Point* pt=p3d->vbcPoint; //这是在执行期里做的评估
提供必要的支持以涵盖所有的nonclass objects。
使用被静态初始化的objects有一些缺点。例如,如果exception handling被支持,那些objects将不能够被放置于try区段之内。这对于被静态调用的constructors可能是特别无法接受的。因为任何的throw操作将必然触发exception handling library默认的terminate( )函数。另一个缺点是为了控制“需要跨越模块做静态初始化”objects的相依顺序而扯出来的复杂度。
局部静态对象(Local Static Objects)
假设我们有以下程序片段:
Const Matrix&
Identity( ){
Static Matrix mat_identity;
//...
Return mat_identity;
}
Local static class object保证了什么样的语意?
1.Mat_identity的constructor必须只能施行一次,虽然上述函数可能会被调用多次。
2.Mat_identity的destructor必须只能施行一次,虽然上述函数可能会被调用多次。
编译器的策略之一就是,无条件地在程序其实(startup)时构造出对象来。然而这会导致所有的local static class objects都在程序起始时被初始化,即使它们所在的那个函数从不曾被调用过。因此,只在identity( )被调用时才把mat_identity构造起来,是比较好的做法(现在的C++ Standard已经强制要求这一点)。我们该怎么做呢?
以下就是cfront之中的做法:首先,导入一个临时性对象以保护mat_identity的初始化操作。第一次处理identity时,这个临时对象被评估为false,于是constructor会被调用,然后临时对象被改为true。这样就解决了构造的问题。而在相反的那一端,destructor也需要有条件的施行与mat_identity身上,但只有在mat_identity已经被构造起来时才算数。要判断mat_identity是否被构造,很简单。如果那个临时对象为ture,就表示构造好了。困难的是,由于cfront产生C码,mat_identity对函数而言仍然是local,因此没有办法在静态的内存释放函数(std)中存取它。解决的方法有点诡异:取出local object的地址。(由于object是static,其地址在downstream component中将会被转换到程序内用来放置global object的data segment中)下面是cfront的输出:
最后,destructor必须在“与text program file”有关联的静态内存释放函数中被有条件的调用:
请记住,指针的使用是cfront所特有的:然而条件是解构则是所有编译器都需要的。
对象数组
假设我们有下列的数组定义:
Point knots[10];
需要完成什么东西呢?如果Point既没有定义一个constructor也没有定义一个destructor,那么我们的工作不会比建立一个“内建类型所组成的数组”更多。
然而Point的确定义了一个default destructor,所以这个destructor必须轮流施行与每一个元素之上。一般而言这是经由一个或多个runtime library函数达成。在cfront中,一个被命名为vec_new( )的函数,产生出以class object构造而成的数组。函数类型通常如下:
Void*
Vec_new(
Void* array, //数组起始地址
Size_t elem_size, //每一个class object的大小
Int elem_count, //数组中的元素数目
Void (*constructor)(void*),
Void (*destructor)(void*,char)
}
其中的constructor和destructor参数是这个class的default constructor和default destructor的函数指针。参数array带有的若不是具名数组的地址,就是0。如果是0,那么数组将经由应用程序的new运算符,被配置与堆上。
参数elem_count表示数组中的元素数目。在vec_new( )中,constructor施行与elem_count个元素之上。对于支持exception handling的编译器而言,vec_new中destructor的提供是必要的。下面是编译器可能针对我们的10个Point元素所做的vec_nec( )调用操作:
Point knots[10];
Vec_new(&knots,sizeof(Point),10,&Point::Point,0);
如果Point也定义了一个destructor,当knots的生命结束时,该destructor也必须施行与那10个Point元素身上。
Void*
Vec_delete(
Void* array,
Size_t elem_size,
Int elem_count,
Voie (*destructor)(void* ,char)
}
如果程序员提供一个或多个明显初值给一个用class objects组成的数组,像下面这样,会如何:
Point knots[10]={
Point( ),
Point(1.0,1.0,0.5),
-1.0
};
对于那些明显获得初值的元素,vec_new( )不再有必要。对于那些尚未被初始化的元素,vec_new( )的施行方式就像面对“由class elemetns组成的数组,而该数组没有explicit initialization list”一样。因此上一个定义很可能被转换为:
Point knots[10];
Point::Point(&knots[0]);
Point::Point(&knots[1], 1.0, 1.0, 0.5);
Point::Point(&knots[2], -1.0, 0.0, 0.0);
//以vec_new初始化后的7个元素
vec_new( &knots+3, sizeof(Point), 7, &Point::Point, 0);
DefaultConstructors和数组
如果你想在程序中取出一个constructor的地址,这是不可以的。当然啦,这是编译器在支持vec_new( )时该做的事情。然而,经由一个指针来激活constructor,将无法(不被允许)存取default argument values。
举个例子,在cfront2.0之前,声明一个由class objects所组成的数组,意味着这个class必须没有声明constructors或一个default constructor(没有参数那种)。一个constructor不可以去一个或一个以上的默认参数值。这是违反直觉的,会导致以下的大错。下面是在cfront1.0中对于复数库的声明,你能够看出其中的错误吗?
Class complex{
Complex(double=0.0,double=0.0);
...
}
在当时的语言规则下,此复数函数库的使用者没办法声明一个由complex class objects组成的数组。
再一次地,让我们花点时间考虑,如何支持以下句子:
Complex::complex(double=0.0,double=0.0);
但程序员写出:
Complex c_array[10];
时,而编译器最终需要调用:
Vec_new(&c_array,sizeof(complex),10,
&complex::complex,0);
默认的参数如何能够对vec_new( )而言有用?
很明显,有多种可能的实现方法。Cfrotn所采用的方法是产生一个内部的stub constructor,没有参数。在其函数内部调用由程序员提供的constructor,并将default参数值明确地指定过去(由于constructor的地址已被取得,所以它不能够成为一个inline):
//内部产生的stub constructor
//用以支持数组的构造
Complex::complex( )
{
Complex(0.0,0.0);
}
编译器自己又一次违反了一个明显的语言规则:class如今支持了两个没带参数的constructors。当然,只有在class objects数组真正被参生出来时,stub实体才会被产生以及被使用。
6.2 new和delete运算符
运算符new的使用,看起来似乎是个单一运算,像这样:
Int *pi=new int(5);
但事实上它是由以下两个步骤完成:
1、通过适当的new运算符函数实体,配置所需的内存:
//调用函数库中的new运算符
Int *pi=__new(sizeof(int));
2、给配置得来的对象设立初值:
*pi=5;
更进一步地,初始化操作应该在内存配置成功(经由new运算符)后才执行:
Int* pi;
If(pi=__new(sizeof(int)))
*pi=5;
Delete运算符的情况类似,当程序员写下:
Delete pi;
时,如果pi的值是0,C++语言会要求delete运算符不要有操作。因此编译器必须为此调用构造一层保护膜:
If(pi!=0)
__delete(pi);
请注意pi并不会因此被自动清除为0,因此像这样的后继行为:
Pi所指对象之生命会因delete而结束。所以后继任何对pi的参考操作就不再保证有良好的行为,并因此被视为是一种不好的程序风格。然而,把pi 继续当做一个指针来用,仍然是可以的,例如:
If(pi==sentinel)...
在这里,使用指针pi和使用pi所指之对象,其差别在于哪一个的生命已经结束了。虽然该地址上的对象不再合法,但地址本身却仍然代表一个合法的程序空间。因此pi能够继续被使用,当只能在受限制的情况下。
以constructor来配置一个class object,情况类似。例如:
Point3d* origin=new Point3d;
被转换为:
Point3d* origin;
//C++伪代码
If(origin=__new(sizeof(Point3d)))
Origin=Point3d::Point3d(origin);
如果实现出exception handling,那么转换结果可能会更复杂些:
//C++伪代码
If(origin=__new(sizeof(Point3d)))
{
Try{
Origin=Point3d::Point3d(origin);
}
Cathc(...)
{
//调用delete library function以
//释放因new而配置的内存
__delete(origin);
//将原来的exception上传
Throw;
}
}
Destructor的应用极为类似。下面的式子:
Delete origin;
会变成:
If(origin!=0)
{
//C++伪代码
Point3d:: ~Point3d(origin);
__delete(origin);
}
如果在exception handling的情况下,destructor应该被放在一个try区段中。Exception handler会调用delete运算符,然后再一次丢出该exception。
一般的library对于new运算符的实现操作都很直接了当,但有两个精巧之处值得斟酌:
Extern void*
Operator new(size_t size)
{
If(size==0)
Size=1;
Void *last_alloc;
While(!(last_alloc=malloc(size)))
{
If(__new_handler)
(*__new_handler)( );
Else
Return 0;
}
Return last_alloc;
}
虽然这样写是合法的:
New T[0];
但语言要求每一次对new的调用都必须传回一个独一无二的指针。解决该问题的传统方法是传回一个指针,指向一个默认为1 byte的内存区块(这就是为什么程序代码中的size被设为1的原因)。这个实现技术的另一个有趣之处是,它允许使用者提供一个属于自己的__new_handler( )函数。这正是为什么每一次循环都调用__new_handler( )之故。
New运算符实际上总是以标准的C malloc( )完成,虽然并没有规定一定得这么做不可。相同的情况,delete运算符也重视以标准的c free( )完成:
Extern void
Operator delete(void* ptr)
{
If(ptr)
Free((char*)ptr);
}
针对数组的new语意
当我们这么写:
Int* p_array=new int[5];
时,vec_new( )不会真正被调用,因为它的主要功能是把default constructor施行于class objects所组成的数组的每一个元素身上。倒是new运算符函数会被调用:
Int* p_array=(int*)__new(5*sizeof(int));
相同的情况,如果我们写:
//struct simple_aggr{float f1,f2;};
Simple_aggr* p_aggr=new simple_aggr[5];
Vec_new( )也不会被调用。为什么呢?因为simple_aggr并没有定义一个constructor或destructor,所以配置数组以及清除p_aggr数组的操作,只是单纯地获得内存和释放内存而已。这些操作由new和delete运算符来完成就绰绰有余了。
然而如果class定义有一个default constructor,某些版本的vec_new( )就会被调用,配置并构造class objects所组成的数组。例如这个算式:
Point3d* p_array=new Point3d[10];
通常会被编译为:
Point3d* p_array;
P_array=vec_new(0,sizeof(Point3d),10,
&Point3d::Point3d,
&Point3d::~Point3d);
还记得吗,在个别的数组元素构造过程中,如果发生exception,destructor就会被传递给vec_new( )。只是已经构造妥当的元素才需要destructor的施行,因为它们的内存已经被配置出来了,vec_new( )有责任在exception发生的时候把哪些内存释放掉。
程序员不需要在delete时指定数组元素的数目,寻找数组的大小由编译器来完成,因此我们现在可以这样写:
Delete[] p_array;
寻找数组维度给delete运算符的效率带来极大的影响,所以才导致这样的妥协:只有在中括号出现时,编译器才寻找数组的维度,否则它便假设只有单独一个objects要被删除。如果程序员没有提供必须的中括号,像这样:
Delete p_array;
那么就只有第一个元素会被解构。其它的元素仍然存在——虽然其相关的内存已经要求归还。
应该如何记录元素数目?一个明显的方法就是为vec_new( )所传回的每一个内存区块配置一个额外的word,然后把元素数目包藏在那个word之中。通常这种被包藏的数值称为所谓的cookie(小甜饼)。
如果我们配置一个数组,内带10个Point3d objects,我们会预期Point和Point3d的constructor被调用各10次,每次作用于数组中的一个元素:
//完全不是个好主意
Point* ptr=new Point3d[10];(现代版的编译器VC6.0可以正确的处理)
而当我们delete“由ptr所指向的10个Point3d元素”时,会发生什么事情呢?很明显,我们需要虚拟机制的帮助,以获得预期的Point destructor和Point3d destructor各10次的呼唤(每一次作用于数组中的一个元素):
//哦欧:这并不是我们所要的
//只有Point::~Point被调用......
Delete[] ptr;
施行于数组上的destructor,如我们所见,是根据交给vec_delete( )函数之“被删除的指针类型的destructor”——本例中正是Point destructor。
程序员应该怎样做才好?最好就是避免以一个base class指针指向一个derived class objects所组成的数组——如果derived class object比起base大的话。如果你真的一定得这样子写程序,解决之道在于程序员层面,而非语言层面:
For(int ix=0;ix<elem_count;++ix)
{
Point3d* p=&((Point3d*)ptr)[ix];
Delete p;
}
基本上,程序员必须迭代走过整个数组,把delete运算符实施于每一个元素身上。以此方式,调用操作将是virtual,因此,Point3d和Point的destructor都会施行于数组中的每一个objects身上。
Placement Operator new的语意
有一个预先定义好的重载的(overloaded)new运算符,称为placement operator new,它需要第二个参数,类型为void*。调用方式如下:
Point2w* ptw=new(arena) Point2w;
其中arena指向内存中的一个区块,用以放置新产生出来的Point2w object。这个预先定义好的placement operator new的实现方法简直是出乎意料的平凡。它只要将“获得的指针”所指的地址传回即可:
Void* operator new(size_t,void* p)
{
Return p;
}
哦,事实上这只是所发生的操作的一半而已。另外一半无法由程序员产生出来
Placement new operator所扩充的另一半是将Point2w constructor自动实施于arena所指的地址上:
Point2w* ptw=(Point2w*)arena;
If(ptw!=0)
Ptw->Point2w::Point2w( );
这正是使placement operator new威力如此强大的原因。这一份代码决定objects被放置在哪里:编译器系统保证object的constructor会施行于其上。
然而却有一个轻微的不良行为。下面是个有问题的程序片段:
//让arena称为全局性定义
Void fooBar( ){
Point2w* p2w=new(arena)Point2w;
//...do it ...
//...now manipulate a new object...
P2w=new (arena)Point2w;
}
如果placement operator在原已存在的一个object上构造新的object。而该像有的object有一个destructor,这个destructor并不会被调用。调用该destructor的方法之一是将那个指针delete掉。不过本例中你不能像下面这样做:
Delete p2w;
P2w=new(arena) Point2w;
是的,delete运算符会发生作用,这的确是我们所期望的。但是它也会释放由p2w所指的内存,这却不是我们所希望的,因为下一个指令就要用到p2w了。因此,我们应该明确地调用destructor并保留储存空间,以便在使用:
P2w->~Point2w( );
P2w=new(arena)Point2w;
剩下的唯一问题是一个设计上的问题:在我们的例子中对placement operator的第一次调用,会将新object构造与原已存在的object之上?还是会构造与全新地址上?也就是说,如果我们这样写:
Point2w* p2w=new(arena) Point2w;
我们如何知道arena所指向的这块区域是否需要先解构?这个问题在语言层面上并没有解答。一个合理的习俗是另执行new的这一端也要负责执行destructor的责任。
另一个问题关系到arena所表现的真正指针类型。C++ Standard说它必须指向相同类型的class,要不就是一块“新鲜”内存,足够容纳该类型的object。注意,derived class很明显并不在被支持之列。对于一个derived class,或是其它没有关联的类型,其行为虽然并非不合法,却也未经定义。
“新鲜”的存储空间可以这样配置而来:
Char* arena=new char[sizeof(Point2w)];
相同类型的object则可以这样获得:
Point2w* arena=new Point2w;
不论哪一种情况,新的Point2w的存储空间的确是覆盖了arena的位置,而此行为已在良好的控制之下。然而,一般而言,placement new operator并不支持多态。被交给new的指针,应该适当地指向一块原先配置好的内存。如果derived class比其base class大,例如:
Point2w* p2w=new(arena) Point3w;
Point3w的constructor将会导致严重的破坏。
Placement new operator被引入C++2.0时,最晦涩隐暗的问题就是下面这个由Jonathan Shopiro提出的问题:
Struct Base{int j;virtual void f( );}
Struct Derived:Base{void f( );};
Void fooBar( )
{
Base b;
B.f( ); //base::f( )被调用
B.~base( );
New(&b)Derived;//1
B.f( )//哪一个f( )被调用
}
由于上述两个classes有相同的大小,故把derived object放在base class而配置的内存中是安全的。然而,要支持这一点,或许必须放弃对于“经由objects静态调用所有virtual functions(比喻b.f( ))”通常都会有优化处理。结果,placement new operator的这种使用方式在Standard C++中未能获得支持。于是上述程序的行为没有明确定义:我们不能过斩钉截铁地说哪一个f( )函数实体会被调用。尽管大部分使用者可能以为调用的是Derived::f( ),但大部分编译器调用的却是Base::f( )。
6.3、临时性对象
如果我们有一个函数,形式如下:
T operator+(const T&,const T&);
以及两个T objects,a和b,那么:
a+b;
可能会导致一个临时性对象,以放置传回的对象。是否导致一个临时性对象,视编译器的进取性以及上述操作发生时的程序上下关系而定。例如下面这个片段:
T a,b;
T c=a+b;
编译器会产生一个临时性对象,放置a+b的结果,然后再使用T的copy constructor,把该临时性对象当作c的初始值。然而更可能的转换时直接以拷贝构造的方式,将a+b的值放到c中,于是就不需要临时性对象,以及对其constructor和destructor的调用了。
此外,视operator+( )的定义而定,name return value(NRV)优化也可能实施起来。这将导致直接在上述c对象中求表达式结果,避免执行copy constructor和具名对象的destructor。
三种方式所获得的c对象,结果都一样。其间的差异在于初始化的成本。一个编译器可能给我们任何保证吗?严格地说没有。C++ Standard允许编译器对于临时性对象的产生有完全的自由度。
理论上,C++ Standard允许编译器厂商有完全的自由度。但实际上,由于市场的竞争,几乎保证任何表达式如果有这种形式:
T c=a+b;//初始化,不会产生一个临时对象
而其中的加法运算符被定义为:
T operator+(const T&,const T&);
或
T T::operator+(const T&);
那么实现时根本不产生一个临时性对象。
然而请注意,意义相当的assignment叙述句:
c=a+b;//赋值,会产生一个临时对象
不能够忽略临时性对象。相反,它会导致下面的结果:
//C++伪代码
//T temp=a+b;
T temp;
temp.operator+(a,b);//(1)
//c=temp
C.operator=(temp);//(2)
temp.T::~T( );
标示为(1)的那一行,未构造的临时对象被赋值给operator+( )。这意思是要不是“表达式的结果被copy constructed至临时对象中”,就是“以临时对象取得NRV”。在后者中,原本要施行与NRV的constructor,现在将施行与该临时对象。
不管哪一种情况,直接传递c(上例赋值操作的目标对象)到赋值运算符函数中是有问题的,必须在此调用之前先调用destructor。然而,“转换”语意将被用来将下面的assignment操作:
c=a+b;//c.operator=(a+b);
取代为其copy assignment运算符的隐含调用操作,以及一系列的destructor和copy construction:
//C++伪代码
c.T::~T( );
c.T::T(a+b);
Copy constructor,destructor以及copy assignment operator都可以由使用者供应,所以不能够保证上述两个操作会导致相同的语意。因此,以一连串的destruction和copy construction来取代assignment,一般而言是不安全的,而且会产生临时对象,所以这样的初始化操作:
T c=a+b;
总是比下面的操作更有效率地被编译器转换:
c=a+b;
第三种运算形式是,没有出现目标对象:
a+b;//no target
这时候有必要产生一个临时对象,以放置运算后的结果。虽然看起来有点怪异,但这种情况实际上在子表达式中十分普遍,例如,如果我们这样写:
String s("hello"),t("world"),u("!");
那么不论:
String v;
v=s+t+u;
或
Printf("%s\n",s+t);
都会导致产生一个临时对象,与s+t相关联。
最后一个表达式带来一些秘教式的论题,那就是“临时对象的生命期”。这个论题颇值得深入探讨。
在C++ Standard标准规格上说:临时性对象的被摧毁,应该是对完整表达式求值过程中的最后一个步骤。该完整表达式造成临时性对象的产生。
什么是一个完整表达式?非正式地说,它是被涵括的表达式中最外围的那个。下面这个式子:
((objA>1024)&&(objB>1024))
?objA+objB:foo(objA,objB);
一共有五个子算式,内带在一个“?:完整表达式”中。任何一个子表达式所产生的任何一个临时对象,都应该在完整表达式被求值完成后,才可以毁去。
当临时性对象是根据程序的执行器语意有条件地被产生出来时,临时性对象的生命规则就显得有些复杂了。举例如下:
If(s+t||u+v)
其中的u+v子算式只有在s+t被评估为false时,才会开始被评估。与第二个子算式有关的临时性对象必须被摧毁。但是,不可以被无条件地摧毁。也就是说,我们希望只有在临时性对象被产生出来的情况下才去摧毁它。
但临时性对象的生命规则有两个例外。第一个例外发生在表达式被用来初始化一个object时,例如:
Bool verbose;
...
String progNameVersion=
!Verbose
?0
:progName+progVersion;
其中progName和progVersion都是String objects。这时候会生出一个临时对象,放置加法运算符的运算结果:
String operator+(const string &,const string&);
临时对象必须根据对verbose的测试结果有条件地解构。在临时对象的生命规则之下,它应该在完整的“?:表达式”结束之后尽快被摧毁。然而,如果progNameVersion的初始化需要调用一个copy constructor:
progNameVersion.String::String(temp);
那么临时性对象的解构(在“?:完整表达式”之后)当然就不是我们所期望的。C++ Standard要求说:凡含有表达式执行结果的临时性对象,应该存留到object的初始化操作完成为止。
甚至即使每一个都坚守C++ Standard中的临时对象生命规则,程序员还是有可能让一个临时对象在他们的控制中被摧毁。其间的主要差异在于这时候的行为有明确的定义。例如,在新的临时对象规则中,下面这个初始化操作保证失败:
Const char* progNameVersion=
progName+progVersion;
其中progName和progVersion都是String objects。产生出来的代码看起来像这样:
String temp;
Operator+(temp,progName,progVersion);
progNameVersion=temp.String::operator char*( );
temp.String::~String( );
此刻progNameVersion指向未定义的heap内存!
临时性对象的生命规则的第二个例外是“当一个临时性对象被一个reference绑定”时,例如:
Const String& space="";
产生出这样的代码:
String temp;
temp.String::String("");
Const string &space=temp;
很明显,如果临时性对象现在被摧毁,那个reference也就差不多没什么用了,。所以规则上说:
如果一个临时性对象被绑定于一个reference,对象将残留,直到被初始化之reference的生命结束,或知道临时对象的生命范畴结束——视哪一种情况先到达而定。