想象一下我们有下面这个简单的式子:
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; // conversion运算符 // ...};
先看看开始那个表达式该如何处理。
首先,让我们决定equality(等号)运算符所参考到的真正实例。在这个例子中,它将被决议
为“被overloaded的Y成员实例”。下面是该式子的第一次转换:
// resolution of intended operatorif( yy.operator==( xx.getValue() ) )
Y的equality(等号)运算符需要一个类型为Y的参数,然而getValue()传回的却是一个类型
为X的object。若非有什么办法可以把一个X object转换为一个Y object,那么这个式子就算错!
本例中X提供一个conversion运算符,把一个X object转换为一个Y object。它必须施行于
getValue()的返回值上。下面是该式子的第二次转换:
// conversion of getValue()'s return valueif( yy.operator==( xx.getValue().operator Y() ) )
到目前为止所发生的一切都是编译器根据class的隐含语意,对我们的程序代码所做的增胖
操作。如果我们需要,我们也可以明确地写出那样的式子。不过不建议,这样做会使编译速度
稍微快一些。
虽然程序的语意是正确的,其教育性却尚不能说是正确的。接下来我们必须产生一个临时对
象,用来放置函数调用所传回的值:
1)产生一个临时的class X object,放置getValue()的返回值:
X temp1 = xx.getValue();
2)产生一个临时的class Y object,放置operator Y()的返回值:
Y temp2 = temp1.operator Y();
3)产生一个临时的int object,放置equality(等号)运算符的返回值:
int temp3 = yy.operator==( temp2 );
最后,适当的destructor将被施行于每一个临时性的class object身上。这导致我们的式子被
转换为以下形式:
// C++伪码// 以下是条件句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++的一件困难事情:不太容易从程序源码看出表达式的复杂度。下面就是执行期所发
生的一些转换。
一、对象的构造和析够(Object Construction and Destruction)
一般而言,constructor和destructor的安插都如你所预期的那样:
// C++伪码{ Point point; // point.Point::Point() 一般而言会安插在这里 ... // point.Point::~Point() 一般而言会被安插在这里}
如果一个区段({}括起来的区域)或函数中有一个以上的离开点,情况会稍微混乱一些。
Destructor必须被放在每一个离开点(当时object还存活)之前,例如:
{ Point point; // constructor 在这里行动 switch( int( Point.x() ) ) { case -1: // mumble; // destructor 在这里行动 return; case 0: // mumble; // destructor 在这里行动 return; case 1: // mumble; // destructor 在这里行动 return; default: // mumble; // destructor 在这里行动 return; } // destructor 在这里行动}
在这个例子中,point的destructor必须在switch指令4个出口的return操作前被生成出来。另
外也很有可能在这个区段的结束符号(右大括号)之前被生成出来——即使程序分析的结果发
现绝不会进行到那里。
同样的道理,goto指令也可能需要许多个destructor调用操作。例如下面的程序片段:
{ if( cache ) // 检查cache;如果吻合就传回1 return 1; Point xx; // xx的constructor 在这里行动 while( cvs.iter( xx ) ) if( xx == value ) goto found; // xx的destructor 在这里行动 return 0;found: // cache item // xx的destructor 在这里行动 return 1;}
Destructor调用操作必须被放在最后两个return指令之前,但是却不必被放在最初的return之
前,那当然是因为那时object尚未被定义出来!
一般而言我们会把object尽可能放置在使用它的那个程序区段附近,这么做可以节省非必要
的对象产生操作和摧毁操作。以本例而言,如果我们在检查cache之前就定义了Point object,
那就不够理想。这个道理似乎非常明显,但许多Pascal或C程序员使用C++的时候,仍然习惯把
所有的objects放在函数或某个区段的起始处。
1、全局对象(Global Objects)
如果我们有以下程序片段:
Matrix identity;main(){ // identity 必须在此被初始化 Matrix m1 = identity; ... return 0;}
C++保证,一定会在main()函数中第一次用到identity之前,把identity构造出来,而在main()
函数结束之前把identity摧毁掉。像identity这样的所谓global object如果有constructor和
destructor的话,我们就说它需要静态的初始化操作和内存释放操作。
C++程序中所有的global objects都被放置在程序的data segment中。如果显式指定给它一个
值,此object将以该值为初值。否则object所配置到的内存内容为0。因此在下面这段代码中:
int v1 = 1024;int v2;
v1和v2都被配置于程序的data segment,v1值为1024,v2值为0(这和C略有不同,C并不
自动设定初值)。在C语言中一个global object只能够被一个常量表达式(可在编译时期求其值
的那种)设定初值。当然,constructor并不是常量表达式。虽然class object在编译时期可以被
放置于data segment中并且内容为0,但constructor一直要到程序启动(startup)时才会实施。
必须对一个“放置于program data segment中的object的初始化表达式”做评估,这正式为什么一
个object需要静态初始化的原因。
当cfront还是唯一的C++编译器,而且跨平台移植性比效率的考虑更重要的时候,有一个可
移植但成本颇高的静态初始化(以及内存释放)方法,称为munch。cfront的束缚是,它的解决
方案必须在每一个UNIX平台上——从Cray到VAX,再从Sun到UNIX PC——都有效。因此不论
是相关的linker或object-file format,都不能预先做任何假设。由于这样的限制,下面这些munch
策略就浮现出来了:
1)为每一个需要静态初始化的文件产生一个_sti()函数,内含必要的constructor调用操作或
inline expansions。例如前面所说的identity对象会在matrix.c中产生出下面的_sti()函数(sti就是
static initialization的缩写):
_sti_matrix_c_identity(){ // C++代码 identity.Matrix::Matrix(); // 这就是static initialization}
其中matrix_c是文件名编码,_identity表示文件中所定义的一个static object。在_sti之后附
加上这两个名称,可以为可执行文件提供一个独一无二的标识符号。
2)类似情况,在每一个需要静态的内存释放操作(static deallocation)的文件中,产生一
个_std()函数(std就是static deallocation的缩写),内含必要的destructor调用操作,或是其
inline expansions。在我们的例子中会有一个_std()函数被产生出来,针对identity对象调用
Matrix destructor。
3)提供一组runtime library “munch”函数:一个_main函数(用以调用可执行文件中的所有
_sti()函数),以及一个exit()函数(用类似的方式调用所有的_std()函数)。
cfront在程序中安插一个_main()函数调用操作,作为main()函数的第一个指令。这里的exit
和C library的exit()不同,为了链接前者,在cfront的CC命令中必须先指定C++ standard
library。一般而言这样就可以了,但有些编译系统拒绝munch exit()函数。
最后一个需要解决的问题是,如何收集一个程序中各个object files的_sti()函数和_std()函
数。它必须是可移植的——虽然移植性限制在UNIX平台。
解决办法是使用nm命令。nm会倾印(dump)出object file的符号表格项目(symbol table
entries)。一个可执行文件是由.o文件产生出来的,nm将施行于可执行文件身上。其输出被导
入(“piped into”)munch程序中。munch程序会分析符号表格中的名称,搜寻以_sti或_std开头
的名称,然后把函数名称加到一个sti()函数和std()函数的跳离表格(jump table)中。接下来它
把这个表格写到一个小的program text文件中,然后,CC命令被重新激活,将这个内含表格的
文件加以编译。整个可执行文件然后被重新链接。_main()和exit于是在各个表格上走访一遍,
轮流调用每一个项目(代表一个函数地址)。
这个做法可以解决问题,但似乎离正统的计算机科学远了一些。其修补版(patch)假设
可执行文件是System V COFF(Common Object File Format)格式,于是它检验可执行文件并
找出那些“有着_link node”并内含一个指针,指向_sti()和_std()函数“的文件,将它们统统串链在
一起。接下来它把链表的根源设为一个全局性的_head object(定义于新的patch runtime library
中)。这个patch library内含另一个不同的_main()函数和exit()函数,将以_head为起始的链表走
访一遍。最后针对Sun、BSD以及ELF的其他patch libraries终于也有各个使用者团体捐赠出
来,用以和各式各样的cfront版本搭配。
当特定平台上的C++编译器开始出现,更有效率的方法也就有可能随之出现,因为各平台有
可能扩充链接器和目的文件格式(object file format),以求直接支持静态初始化和内存释放操
作。例如,System V的Executable and Linking Format(ELF)被扩充以增加支持.init和.fini两
个section,这两个sections内含对象所需要的信息,分别对应于静态初始化和释放操作。编译
器特定(Implementation-specific)的startup函数(通常名为crt0.o)会完成平台特定
(platform-specific)的支持(分别针对静态初始化和释放操作的支持)。
cfront 2.0版之前并不支持nonclass object的静态初始化操作:也就是说C语言的限制仍然残
留着。所以,像下面这样的例子,每一个初始化操作都被标示为不合法:
extern int i;// 全部都要求静态初始化(static initialization)// 在2.0版以前的C和C++中,这些都是不合法的int j = i;int *pi = new int( i );double sal = compute_sal( get_emplopyee( i ) );
支持“nonclass objects的静态初始化”,在某种程度上,是支持virtual base classes的一个副
产品。virtual base classes怎么会扯进这个主题呢?以一个derived class的pointer或reference
来存取virtual base class subobject,是一种nonconstant expression,必须在执行期才能加以
评估求值。例如,尽管下列程序片段在编译器时期可知:
// constant expressionVertex3d *pv = new PVertex;Point3d *p3d = pv;
其virtual base class Point的subobject在每一个derived class中位置却可能会变动,因此不
能够在编译时期设定下来。下面的初始化操作:
// Point是Point3d的一个virtual base class// pt的初始化操作需要// 某种形式的执行期评估(runtime evaluation)Point *pt = p3d;
需要编译器提供内部扩充,以支持class object的静态初始化(至少涵盖class objects的指针
和reference)。例如:
// Initial support of virtual base class conversion// requires non-constant initialization supportPoint *pt = p3d->vbcPoint;
提供必要的支持以涵盖所有的nonclass objects,并不需要走太远的路。
使用被静态初始化的objects,有一些缺点。例如,如果exception handling被支持,那些
objects将不能被放置于try区段之内。这对于被静态调用的constructors可能是特别无法接受的,
因为任何的throw操作将必然触发exception handling library默认的terminate()函数。另一个缺点
是为了控制“需要跨越模块做静态初始化”之objects的相依顺序,而扯出来的复杂度。建议根本
不要用那些需要静态初始化的global objects。
2、局部静态对象(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是否被构造起来,很简单,如果那个临时对象为true,
就表示构造好了。困难的是,由于cfront产生C码,mat_identity对函数而言仍然是local,因此我
没办法在静态的内存释放函数(static deallocation function)中存取它。解决办法优点诡异,结
构化语言避之唯恐不及:取出local object的地址。(由于object是static,其地址在downstream
component中将会被转换到程序用来放置global object的data segment中)。下面是cfront的输
出:
// 被产生出来的临时对象,作为戒护之用static struct Matrix *_0_F3 = 0;// C++的reference在C中是以pointer来代替的// identity()的名称会被mangledstruct Matrix*identity_Fv(){ // _1 反映出语汇层面的设计, // 使得C++得以支持这样的代码: // int val; // int f() { int val; // return val + ::val; } // 最后一行会变成: // ... return _lval + val; static struct Matrix _lmat_identity; // 如果临时性的保护对象已被设立,那就什么也别做,否则 // (a) 调用constructor:_ct_6MatrixFv // (b) 设定保护对象,使它指向目标对象 _0_F3 ? 0 : ( _ct_6MatrixFv( &_lmat_identity ), ( _0_F3 = ( &_lmat_identity ) ) ); ...}
最后,destructor必须在“与text program file(也就是本例中的stat_0.c)有关联的静态内存内
存释放函数 (static deallocation function)“中被有条件地调用:
char _std_stat_0_c_j(){ _0_F3 ? _dt_6MatrixFv( _0_F3, 2 ) : 0; ...}
请记住,指针的使用是cfront所特有的:然而条件式析够则是所有编译器都需要的。C++标
准委员会新的规则要求编译单位中的static local class objects必须被摧毁——以构造的的相反顺
序摧毁。由于这些objects是在需要时才被构造(例如每一个含有static local class objects的函
数第一次被进入时),所以编译时期无法预期其集合以及顺序。为了支持新的规则,可能需要
对被产生的static class objects保持一个执行期链表。
3、对象数组(Array of Objects)
假设我们有下列的数组定义:
Point knots[ 10 ];
如果Point既没有定义一个constructor也没有定义一个destructor,那么我们的工作不会比建
立一个”内建(build-in)类型所组成的数组“更多,也就是说我们只要配置足够内存以存储10个
连续的Point元素即可。
然而Point的确定义了一个default destructor,所以这个destructor必须轮流施行于每一个元
素之上。一般而言这是经由一个或多个runtime library函数达成的。在cfront中,我们使用一个被
命名为vec_new()的函数,产生出以class objects构造而成的数组。比较新近的编译器,包括
Borland、Microsoft和Sun,则是提供两个函数,一个用来处理”没有virtual base class“的
class,另一个用来处理”内含virtual base class“的class。后一个函数通常被称为vec_vnew()。
函数类型通常如下(当然在各平台上可能会有些许差异):
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持有的若不是具名数组(本例为knots)的地址,就是0。如果是0,那么数组
将经由应用程序的new运算符,被动态配置于heap中。Sun把”由class objects所组成的具名数
组“和”动态配置而来的数组“的处理操作分为两个library函数:_vector_new2和_vector_con,它
们各自拥有一个virtual base class函数实例。
参数elem_size表示数组中的元素个数。在vec_new()中,constructor施行于elem_count个元
素上,对于支持exception handling的编译器而言,destructor的提供是必要的。下面下面是编
译器可能针对我们的10个Point元素所做的vec_new()调用操作:
Point knots[ 10 ];vec_new( &knots, sizeof( Point ), 10, &Point::Point, 0 );
如果Point也定义了一个destructor,当knots的生命结束时,该destructor也必须施行于那10
个Point元素身上。这是经由一个类似的vec_delete()(或是一个vec_vdelete()——如果classes
拥有virtual base classes的话)的runtime library函数完成(Sun对于”具名数组“和”动态配置而
来的数组“,处理方式不同)的,其函数类型如下:
void*vec_delete( void *array, // 数组起始地址 size_t elem_size, // 每一个class object的大小 int elem_count, // 数组的元素个数 void ( *destuctor )( void*, char ))
有些编译器会另外增加一些参数,用以传递其他数值,以便能够有条件地导引vec_delete()的
逻辑。在vec_delete()中,destructor被施行于elem_count个元素身上。
如果程序员提供一个或多个明显初始值给一个由class objects组成的数组,像下面这样,会
如何:
Point knots[ 10 ] = { Point(), Point( 1.0, 1.0, 0.5 ), -1.0};
对于那些明显获得初值的元素,vec_new()不再有必要。对于那些尚未被初始化的元
素,vec_new()的施行方式就像面对”由class elements组成的数组,而该数组没有explicit
initialization list“一样。因此上一个定义很可能被转换为:
Point knots[ 10 ];// C++伪码// 显式地初始化前3个元素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 );
4、Default Constructors和数组
如果想要在程序中取出一个constructor的地址,是不可以的。当然,这是编译器在支持vec_new()时该做的事情。然而, 经由一个指针来启动constructor,将无法(不被允许)存取default argument values。
例如,在cfront2.0之前,声明一个由class objects所组成的数组,意味着这个class必须没有
声明constructors或一个default constructor(没有参数那种)。一个constructor不可以取一个或
一个以上的默认参数值。这是违反直觉的,会导致以下的大错。下面是cfront 1.0中对于负数函
数库(complex library)的声明,能看出其中的错误?
class complex{ complex( double = 0.0, double = 0.0 ); ...}
在当时的语言规则下,此复数函数库的使用者没有办法声明一个由complex class objects组
成的数组。显然我们在语言的一个陷阱上被绊倒了。在1.1版,修改的是class library;然而在2.0
版,修改是语言本身。
再一次,如何支持以下句子:
complex::complex( double = 0.0, double = 0.0 );
当程序员写出:
complex c_array[ 10 ];
时,而编译器最终需要调用:
vec_new( &c_array, sizeof( complex ), 10, &complex::complex, 0 );
默认参数如何能够对vec_new()而言有用?
很明显,有数种可能的实现方法。cfront所采用的方法是产生一个内部的stub constructor,
没有参数。在其函数内调用由程序员提供的constructor,并将default参数值显示地指定过去
(由于constructor的地址已被取得,所以它不能够成一个inline):
// 内部产生的stub constructor// 用以支持数组的构造complex::complex(){ complex( 0.0, 0.0 );}
编译器自己又一次违反了一个明显的语言规则:class如今支持了两个没有带参数的
constructors。当然当class object数组真正被产生出来时,stub实例才会被产生以及被调用。
二、new和delete运算符
运算符new的使用,看起来似乎时单一运算,像这样:
int *pi = new int( 5 );
但事实上是由两个步骤完成的:
1)通过适当的new运算符函数实例,配置所需的内存:
// 调用函数库中的new运算符int *pi = _new( sizeof( int ) );
2)将配置得来的对象设立初值:
*pi = 5;
更进一步地说,初始化操作应该在内存配置成功(经由new运算符)后才执行:
// new运算符的两个分离步骤// given: int *pi = new int( 5 );// 重写声明int *pi;if( pi = _new( sizeof( int ) ) ) *pi = 5; // 成功了才初始化
delete运算符的情况类似。当写下:
delete pi;
时,如果pi的值是0,C++语言会要求delete运算符不要有操作。因此编译器必须为此调用构
造一层保护膜:
if( pi != 0 ) _delete( pi );
请注意pi并不会因此被自动清除为0,因此像这样的后继行为:
// 没有良好的定义,但是合法if( pi && *pi == 5 ) ...
虽然没有良好的定义,但是可能(也可能不)被评为真。这是因为对于pi所指向之内存的变
更或再使用,可能(也可能不)会发生。
pi所指对象的生命会因delete而结束。所以后继任何对pi的参考操作就不再保证有良好的行
为,并因此被视为一种不好的程序风格。然而,把pi继续当做一个指针来用,仍然是可以的
(虽然其使用受到限制),例如:
// ok:pi仍然指向合法空间// 甚至即使存储于其中的object已经不再合法if( pi == sentinel ) ...
在这里,使用指针pi,和使用pi所指的对象,其差别在于哪一个的生命已经结束了。虽然该
地址上的对象不再合法,地址本身却仍然代表一个合法的程序空间。因此pi能够继续被使用,
但只能在受限制的情况下,很像一个void*指针的情况:
以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 ); } catch( ... ) { // 调用delete library function以释放new而配置内存 _delete( origin ); // 将原来的exception上传 throw; }}
在这里,如果new运算符配置object,而其constructor抛出一个exception,配置得来的内存
就会被释放掉。然后exception再被抛出去(上传)。
Destructor的应用极其类似。下面的式子:
delete origin;
会变成:
if( origin != 0 ){ // C++伪码 Point3d::~Point3d( origin ); _delete( origin );}
如果在exception handling的情况下,destructor应该被放在一个try区段中。exception
handler会调用delete运算符,然后再一次抛出该exception。
一般的library对于new运算符的实现都很直截了当,但是两个精巧之处值得斟酌(以下版本
并未考虑exception handling):
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_hander()函
数。这正是为什么每一次循环都调用_new_hanlder()之故。
new运算符实际上总是以标准的C malloc()完成,虽然并没有规定一定得这么做不可。相同
情况,delete运算符也总是以标准的C free()完成:
extern voidoperator delete( void *ptr ){ if( ptr ) free( ( char* )ptr );}
1、针对数组的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 = vew_new( 0, sizeof( Point3d ), 10, &Point3d::Point3d, &Point3d::~Point3d );
在个别的数组元素构造过程中,如果发生excpetion,destructor就会传递给vec_new()。只有
已经构造妥当的元素才需要destructor的施行,因为它们的内存已经被配置出来了,vec_new()
有责任在exception发生的时机把那些内存释放掉。
在C++2.0版本之前,将数组的真正大小提供给程序的delete运算符,是程序员的责任。因此
如果我们原先写下:
int array_size = 10;Point3d *p_array = new Point3d[ array_size ];
那么我们就必须对应地写下:
delete [ array_size ] p_array;
在2.1版中,这个语言有了一些函数,程序员不再需要在delete时指定数组元素的个数,因此
我们现在可以这样写:
然而为了回溯兼容,两种形式都可以接受。支持。支持此种新形式的第一个编译器当然就是
cfront。这项技术支持需要知道的首先是指针所指的内存空间,然后时其中的元素个数。
寻找数组维度,对于delete运算符的效率带来极大的冲击,所以才导致这样的妥协:只有在中
括号出现时,编译器才寻找数组的维度,否则它便假设只有单独一个objects要被删除。如果程
序员没有提供必须的中括号,像这样:
delete p_array;
那么就只有第一个元素会被析构。其他的元素仍然存在——虽然其相关的内存已经被要求归
还了。
各家编译器之间存在一个有趣的差异,那就是元素个数如果被显示指定,是否被拿去利用。
在Jonathan的原始版本中,优先采用使用者(程序员)显式指定的值。下面是他所写的原始码
的虚拟版本(pseudo-version),附带注释:
// 首先检查是狗最后一个被配置的项目(_cache_key)// 是目前要被delete的项目// // 如果是,就不需要做搜寻操作了// 如果不是,就寻找元素个数int elem_count = _cache_key == pointer ? ( ( _cache_key = 0 ), _cache_cout ) : // 取出元素个数// num_elem: 元素个数,将传递给vec_new()。// 对于配置于heap中的数组,只有面对以下形式,才会设定一个值:// delete [10] ptr;// 否则cfront会传-1以表示取出。if( num_elem == -1 ) // prefer explicit user size if choice! num_elem = ans;
然而几乎新近所有的C++编译器都不考虑程序员的显示指定(如果有的话)。
此一性质被导入的时候,没有任何程序代码会不“显示指定数组大小”。时代演化到
cfront4.0,我们会把此习惯贴上“落伍”的标记,并且产生一个类似的警告信息。
应该如何记录元素个数?一个明显的方法就是为vec_new()所传回的每一个内存区块配置
一个额外的word,然后把元素个数包藏在那个word之中。通常这种包藏的数值称为所谓的
cookie。然而,Jonathan和Sun编译器决定维护一个“联合数组(associative array)”,放置指
针及大小。Sun也把destructor的地址维护于数组之中。
cookie策略有一个普遍引起忧虑的话题就是,如果一个坏指针应该被交给delete_vec(),
取出来的cookie自然是不合法的。一个不合法的元素个数和一个坏的起始地址,会导致
destrcutor以非预期的次数被施行于一段非预期的区域。然而在“联合数组”的政策之下,坏指针
的可能结果就只是取出错误的元素个数而已。
在原始编译器中,有两个主要函数用来存储和取出所谓的cookie:
// array_key是新数组的地址// mustn't either be 0 or already entered// elem_count is the count;it may be 0typedef void *PV;extern int _insert_new_array( PV array_key, int elem_count );// 从表格中取出(并去除)array_key// 若不是传回elem_count,就是传回-1extern int _remove_old_array( PV array_key );
下面是cfront中的vec_new()原始内容经过修润后的一份呈现,并附加注释:
PV _vec_new( PV ptr_array, int elem_count, int size, PV construct ){ // 如果ptr_array是0,从heap之中配置数组。 // 如果ptr_array不是0,表示程序员写的是: // T array[ count ] // 或 // new ( ptr_array ) T[ 10 ]; int alloc = 0; // 我们要在vec_new中配置吗? int array_sz = elem_count * size; if( alloc = ptr_array == 0 ) // 全局运算符 new ... ptr_array = PV( new char[ array_sz ] ); // 在exception handling之下: // 将抛出exception bad_alloc if( ptr_array == 0 ) return 0; // 把数组元素个数放到cache中 int status = _insert_new_array( ptr_array, elem_count ); if( status == -1 ) { // 在exception handling之下将抛出exception // 将抛出exception bad_alloc if( alloc ) delete ptr_array; return 0; } if( construct ) { register char* elem = ( char* )ptr_array; register char* lim = elem + array_sz; // PF是一个typedef,代表一个函数指针 register PF fp = PF( constructor ); while( elem < lim ) { // 通过fp调用constructor作用于 // ‘this’元素上(由elem指出) ( *fp )( ( void* )elem ); // 前进到下一个元素 elem += size; } } return pV( ptr_array );}
vec_delete()操作差不多,但其行为不总是C++程序员所预期或需求的。例如,已知下面两
个处理class声明:
class Point{ public: Point(); virtual ~Point(); // ...};class Point3d : public Point{ public: Point3d(); virtual ~Point3d(); // ...};
如果我们配置一个数组,内含10个Point3d objects,我们会预期Point和Point3d的
constructor被调用各10次,每次作用于数组的一个元素:
// 完全不是个好主意Point *ptr = new Point3d[ 10 ];
而当我们delete“由ptr所指向10个Point3d元素”时,会发生什么事情?很明显,我们需要虚
拟机制的帮助,以获得预期的Point destructor和Point3d destructor各10次的调用(每一次作用
于数组中的一个元素):
// 这并不是我们所需要的// 只有Point::~Point被调用……delete [] ptr;
施行于数组上数组上的destructor,如我们所见,是根据交给vec_delete()函数的“被删除之
指针类型的destructor”——本例中正是Point destructor。这很明显并非我们所希望。此外,每一
个元素的大小也一并被传递过去。这就是vec_delete()如何迭代走过每一个数组元素的方式。本
例中被传递过去的是Point class object的大小而不是Point3d class object的大小。整个运作过程
非常不幸地失败了,不只是因为执行了错误的destructor,而且自从第一个元素之后,该
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身上。
2、Placement Operator new的语意
有一个预先定义好的重载的(overload)new运算符,称为placement operator new。它需
要第二个参数,类型为void *。调用方式如下:
Point2w *ptw = new( arena ) Point2w;
其中arena指向内存中的一个区块,用以放置新产生出来的Point2w object。这个预先定义好
的placement operator new的实现方法简直是出乎意料的平凡。它只要将“获得的指针(上例
arena)”所指的地址传回即可:
void*operator new( size_t, void* p ){ return p;}
如果它的作用只是传回第二个参数,那么它有什么价值呢?也就是说,为什么不简单地这么
写算了(这不就是实际所发生的操作吗):
Point2w *ptw = ( Point2w* ) arena;
事实上这只是所发生的操作的一半而已。另外一半无法由程序员产生出来。如下问题:
1)什么是使placement new operator能够有效运行的另一半扩充(而且是“arena的显式指定
操作(explicit assignment)”所没有提供的)?
2)什么是areana指针的真正类型?该类型暗示了什么?
Placement new operator所扩充的另一半操作是将Point2w constructor自动实施于areana所
指的地址上:
// C++伪码Point2w *ptw = ( Point2s* ) 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
掉。不过在此例中如果你像下面这样做,绝对是个错误:
// 以下并不是实施destructor的正确方法delete p2w;p2w = new ( arena ) Point2w;
是的,delete运算符会发生作用,这的确是我们所期待的。但是它会释放由p2w所指的内
存,它却不是我们所希望的,因为下一个指令就要用到p2w了。因此,我们应该显式调用
destructor并保留存储空间以便再使用:
// 施行destructor的正确方法p2w->~Point2w;p2w = new ( arena ) Point2w;
剩下的唯一问题是一个设计上的问题:在我们的例子中对placement operator的第一次调
用,会将新object构造于原已存在的objecct之上吗?还是会构造于全新地址上?也就是说,如
果我们这样写:
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并不支持多态(polymorphsim)。被叫
给new的指针,应该适当地指向一块预先配置好的内存。如果derived class比其base class大,
例如:
Point2w *p2w = new ( arena ) Point3w;
Point3d的constructor将会导致严重的破坏。
Placement new operator被引入C++2.0时,最晦涩隐暗的问题是下面这个:
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()。
三、临时性对象(Temprary Objects)
如果我们有一个函数,形式如下:
T operator+( const T&, const T& );
以及两个T objects,a和b,那么
a + b;
可能会导致一个临时性对象,以放置传回的对象。是否会导致一个临时性对象,视编译器
的进取性(aggressiveness)以及上述操作发生时的程序语境(program context)而定。例如
下面这个片段:
T a, b;T c = a + b;
编译器会产生一个临时性放置a+b的结果,然后再使用T的copy constructor,把该临时性对
象当做c的初始值。然而比较更可能的转换是直接以拷贝构造的方式将a+b的值放到c中,于是就
不需要临时对象,以及对其constructor和destructor的调用了。
此外,视operator+()的定义而言,named return value(NRV)优化也可能被实施起来。这
将导致直接在上述c对象中求表达式结果,避免执行copy constructor和具名对象(named
object)的destructor。
三种方式所获得的c对象,结果都一样。其间的差异在于初始化的成本。一个编译器可能给
我们任何保证吗?严格地说是没有。C++ Standard允许编译器对于临时性对象的产生有完全有
完全的自由度。
理论上,C++ Standard允许编译器厂商有完全的自由度。但实际上,由于市场的竞争,几
乎保证任何表达式(expression)如果有这种形式:
T c = a + b;
而其中的加法运算符被定义:
T operator+( const T&, const T& );
或
T T::operator+( const T& );
那么实现时根本不产生一个临时性对象。
然而请注意,意义相当的assignment叙述句(statement):
c= a + b;
不能够忽略临时性对象。反而,它会导致下面的结果:
// C++伪码// T temp = a + b;T temp;temp.operator+( a, b ); // (1)// c = tempc.operator=( temp ); // (2)temp.T::~T();
表示为(1)的那一行,未构造的临时对象被赋值给operator+()。这意思是不是“表达式的
结果比copy cnstructed至临时对象中”,就是“以临时对象取代NRV”。在后者中,原本要施行于
NRV的constructor,现在将施行于此临时对象。
不管是哪一种情况,直接传递c(上例赋值操作的目标对象)到运算符函数中都是有问题
的。由于运算符函数并不为其外加参数调用一个destructor(它期望一块“新鲜的”内存),所以
必须在此调用之前先调用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
这时候有必要产生一个临时对象以放置运算后的结果。虽然看起来有点怪异,但这种情况
实际上在子表达式(subexpression)中十分普遍,例如,如果我们这样写:
String s( "hello" ), t( "world" ), u( "!" );
那么不论:
String v;v = s + t + u;
或
printf( "%s\n", s + t );
最后一个表达式来一些密教式的论题,那就是“临时对象的生命期”。这个论题颇值得深入
探讨。在Standard C++之前,临时对象的生命(也就是说他destructor何时实施)并没有显式指
定,而是由编译厂商自行决定。换句话说,上述的printf(0并不保证安全,因为它的正确性和s+t
何时被摧毁有关。
(本例的一个可能性是,String class 定义了一个conversion运算符如下:
String::operator const char*() { return _str; }
其中_str是一个private member addressing storage,在String object构造时配置,在其
destructor中被释放。)
因此如果临时对象在调用printf()之前就被摧毁了,经由conbertion运算符交给它的地址就不
合法。真正的结果视底部的delete运算符在释放内存时的进取性而定。某些编译器可能会把这块
内存标示为free,不以任何方式改变其内容。在这块内存被其他地方宣称“主权”之前,只要它还
没有被deleted掉,它就可以被使用。虽然对于软件工程而言这不足以作为模范,但像这样,在
内存被释放之后又再被使用,并非罕见。事实上malloc()的许多编译器会提供一个特殊的调用操
作:
malloc( 0 );
它正是用来保证上述行为的。
例如,下面是对于该算式的一个可能的pre-Standard转化。虽然在pre-Standard语言定义中
是合法的,却可能造成重大灾难:
// C++伪码:pre-Standard的合法转换// 临时性对象被摧毁得太快了String temp1 = operator+( s, t );const char *temp2 = temp1.operator const char*();// 合法但是有欠考虑,太过轻率temp1.~String();// 这时候并未定义temp2指向何方printf( "%s\n", temp2 );
另一种转换方式是在调用printf()之后实施String destuctor。在C++ Standard之下,这正是
该表达式的标准转换方式。标准规格上这么说:
临时性对象的被摧毁,应该是对完整表达式(full-expression)求值过程中的最后一个步
骤。该完整表达式造成临时对象的产生。
什么是一个完整表达式(full-expression)?非正式地说,它是被涵括的表达式中最外围的
那个。下面这个式子:
// tertiary full expression with 5 sub-expressions( ( objA > 1024 ) && ( objB > 1024 ) ) ? objA + objB : foo( objA, objB );
一共有五个子算式(subexpressions),内含在一个“?:完整表达式”中。任何一个子表达
式所产生的任何一个临时对象,都应该在完整表达式被求值完成后,才能毁去。
当临时性对象是根据程序的执行期语意,有条件地被产生出来时,临时性对象的生命规则就
显得有些复杂了。举个例子,像这样的表达式:
if( s + t || u + v )
其中的u+v子算式只有在s+t被评估为false时,才会开始被评估。与第二个子算式有关的临
时对象必须被摧毁,但是,不可以被无条件地摧毁。也就是说,我们希望只有在临时性对象必
须被摧毁,但是,很明显,不可以被无条件地摧毁。也就是说,我们希望只有在临时性对象被
产生出来的情况下,才去摧毁它。
在讨论临时对象的生命规则之前,标准编译器将临时对象的构造和析构附着于第二个子算
式的评估程序。例如,对于以下的class声明:
class X{ public: X(); ~X(); operator int(); X foo(); private: int val;};
以及对于class X的两个objects的条件测试:
main(){ X xx; Y yy; if( xx.foo() || yy.foo() ) ; return 0;}
cfront对于main()产生出以下的转换结果:
int main( void ){ struct X _1xx; struct X _1yy; int _0_result; // name_mangled default constructor; // X:X( X *this ) _ct_1xFv( &_1xx ); _ct_1xFv( &_1yy ); { // 被产生出来的临时对象 struct X _0_Q1; struct X _0_Q2; int _0_Q3; /* 每一端变成一个附逗点的表达式, * 有着下列顺序: * * tempQ1 = xx.foo(); * tempQ3 = tempQ1.operator int(); * tempQ1.X::~X(); * tempQ3; */ // _opi_1xFv ==> X::operator int() if (((( _0_Q3 = _opi_1xFv((( _0_Q2 = foo_1xFv( &_1xx ) ), ( &_0_Q1 )))), _dt_1xFv( &_0_Q1, 2 )), _0_Q3 ) || ((( _0_Q3 = _opi_1xFv((( _0_Q2 = foo_1xFv( &_1yy ) ), ( &_0_Q2 )))), _dt_1xFv( &_0_Q2, 2 )), _0_Q3 )) { _0_result = 0; _dt_1xFv( &_1yy, 2 ); _dt_1xFv( &_1xx, 2 ); } return _0_result; }}
把临时性对象的destructor放在每一个子算式的求值过程中,可以免除“努力追踪第二个子算
式是否真的需要被评估”。然而在C++ Standard的临时对象生命规则中,这样的策略不再被允
许。临时性对象在完整表达式尚未评估完全之前,不得被摧毁。也就是说某些形式的条件测试
现在必须被安插进来,以决定是否要摧毁和第二算式有关的临时对象。
临时性对象的生命规则有两个例外。第一个例外发生在表达式被用来初始化一个object时,
例如:
bool verbose;...string progNameVersion = !verbose ? 0 : progName + progVersion;
其中progName和progVersion都是String objects。这时候会生出一个临时对象,放置加法
运算符的运算结果:
String operator+( const String&, const String& );
临时对象必须根据对verbose的测试结果,有条件地析构。在临时对象的生命规则之下,它
应该在完整的”?:表达式“结束评估之后尽快被摧毁。然而,如果progNameVersion的初始化需
要调用一个copy constructor:
// C++伪码proNameVersion.String::String( temp );
那么临时性对象的析构(在”?:完整表达式“之后)当然就不是我们所期望的。C++
Standard要求说:
......凡持有表达式执行结果的临时性对象,应该存留到object的初始化操作完成为止。
甚至即使每一个人都坚守C++ Standard中的临时对象生命规则,程序员还是可能对象在他
们的控制中被摧毁。其间的主要差异在于这时候的行为有明确的定义。例如,在新的临时对象
生命规则中,下面这个初始化操作保证失败:
// 不是个好主意const char *progNameVersion = progName + proVersion;
其中progName和progVersion都是String objects。产生出来的程序代码看起来像这样:
// C++ pseudp CodeString temp;operator+( temp, progName, progVersion );progNameVersion = temp.String::operator char*();temp.String::~String();
此刻progNameVersion指向未定义的heap内存!
临时性对象的生命规则的第二个例外是”当一个临时性对象被一个reference绑定“时,例如:
const String &space = " ";
产生出像这样的程序代码:
// C++ pseudo CodeString temp;temp.String::String( " " );const String &space = temp;
很明显,如果临时性对象现在被摧毁,那么reference也就差不多没什么用了。所以规则上
说:
如果一个临时性对象被绑定于一个reference,对象将残留,直到被初始化之reference的生命
结束,或直到临时对象的生命范畴(scope)结束——视哪一种情况先到达而定。
临时性对象的迷思(神话、传说)
临时对象的优化,反聚合(disaggregation)的优化。