博客
关于我
强烈建议你试试无所不能的chatGPT,快点击我
C++对象模型学习——执行期语意学
阅读量:5757 次
发布时间:2019-06-18

本文共 27344 字,大约阅读时间需要 91 分钟。

hot3.png

      想象一下我们有下面这个简单的式子:

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)的优化。

转载于:https://my.oschina.net/u/2537915/blog/711779

你可能感兴趣的文章
你不知道的CSS
查看>>
node学习之路(二)—— Node.js 连接 MongoDB
查看>>
Goroutine是如何工作的?
查看>>
学习数据结构与算法之字典和散列表
查看>>
《深入理解java虚拟机》学习笔记系列——垃圾收集器&内存分配策略
查看>>
用grunt搭建自动化的web前端开发环境-完整教程
查看>>
研究人员发现:基于文本的AI模型容易受到改述攻击
查看>>
京东AI研究院何晓冬:将先进的技术和模型落地到产业
查看>>
TriggerMesh开源用于多云环境的Knative Event Sources
查看>>
对Julia社区不熟悉?创始人来告诉你
查看>>
图数据库并非要取代区块链,而是让区块链如虎添翼
查看>>
GitLab联合DigitalOcean为开源社区提供GitLab CI免费托管
查看>>
通过XAML Islands使Windows桌面应用程序现代化
查看>>
社区OpenJDK代码构建平台投入使用
查看>>
区块链现状:从谨慎和批判性思维看待它(第二部分)
查看>>
Netflix 混沌工程手册 Part 3:实践方法
查看>>
苹果公司透露Siri新发音引擎的内部原理
查看>>
GCM 3.0采用类似方式向Android、iOS和Chrome发送消息
查看>>
如何成为一家敏捷银行
查看>>
MongoDB主动撤回SSPL的开源许可申请
查看>>