C++對象模型學習——執行期語意學

      想象一下咱們有下面這個簡單的式子:node

if( yy == xx.getValue() ) ...

      其中xx和yy定義爲:程序員

X xx;
Y yy;

      class Y定義爲:express

class Y
{
  public:
    Y();
    ~Y();
    bool operator==( const Y& ) const;
    // ...
};

      class X定義爲:數組

class X
{
  public:
    X();
    ~X();
    operator Y() const;  // conversion運算符
    // ...
};

       先看看開始那個表達式該如何處理。安全

       首先,讓咱們決定equality(等號)運算符所參考到的真正實例。在這個例子中,它將被決議cookie

爲「被overloaded的Y成員實例」。下面是該式子的第一次轉換:ide

// resolution of intended operator
if( 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 value
if( 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++的一件困難事情:不太容易從程序源碼看出表達式的複雜度。下面就是執行期所發

生的一些轉換。

1、對象的構造和析夠(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放在函數或某個區段的起始處。

     一、全局對象(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 expression
Vertex3d *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 support
Point *pt = p3d->vbcPoint;

      提供必要的支持以涵蓋全部的nonclass objects,並不須要走太遠的路。

      使用被靜態初始化的objects,有一些缺點。例如,若是exception handling被支持,那些

objects將不能被放置於try區段以內。這對於被靜態調用的constructors多是特別沒法接受的,

由於任何的throw操做將必然觸發exception handling library默認的terminate()函數。另外一個缺點

是爲了控制「須要跨越模塊作靜態初始化」之objects的相依順序,而扯出來的複雜度。建議根本

不要用那些須要靜態初始化的global 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是否被構造起來,很簡單,若是那個臨時對象爲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()的名稱會被mangled

struct 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保持一個執行期鏈表。

     三、對象數組(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 );

     四、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實例纔會被產生以及被調用。

2、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 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 = 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 0

typedef void *PV;
extern int _insert_new_array( PV array_key, int elem_count );

// 從表格中取出(並去除)array_key
// 若不是傳回elem_count,就是傳回-1

extern 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身上。

      二、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()。

3、臨時性對象(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 = temp
c.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 Code
String temp;
operator+( temp, progName, progVersion );
progNameVersion = temp.String::operator char*();
temp.String::~String();

   此刻progNameVersion指向未定義的heap內存!

   臨時性對象的生命規則的第二個例外是」當一個臨時性對象被一個reference綁定「時,例如:

const String &space = " ";

    產生出像這樣的程序代碼:

// C++ pseudo Code
String temp;
temp.String::String( " " );
const String &space = temp;

    很明顯,若是臨時性對象如今被摧毀,那麼reference也就差很少沒什麼用了。因此規則上

說:

     若是一個臨時性對象被綁定於一個reference,對象將殘留,直到被初始化之reference的生命

結束,或直到臨時對象的生命範疇(scope)結束——視哪種狀況先到達而定。

    臨時性對象的迷思(神話、傳說)

     臨時對象的優化,反聚合(disaggregation)的優化。

相關文章
相關標籤/搜索