1. 程式人生 > >深度探索C++物件模型-第二章

深度探索C++物件模型-第二章

建構函式語意學

一 預設ctor的構造操作

在4種情況下,編譯器會為未宣告ctor的類合成一個預設的ctor。

1 帶有預設ctor的成員類物件

如果一個類內含一個或一個以上的成員類物件,那麼類的每一個ctor必須呼叫每一個成員類的預設ctor,編譯器會擴張已經存在的ctor,在其中插入一些程式碼,使得使用者寫的程式碼被執行前,先呼叫必要的預設ctor。呼叫的順序是這些類物件在類中宣告的順序

2 帶有預設ctor的基類

如果一個沒有任何ctor的類派生自一個有預設建構函式的類,那麼這個派生類的建構函式會被視為非平凡的,在內部按照基類的宣告順序呼叫它們的預設ctor。

如果這個類聲明瞭除預設ctor的其他ctor,那麼在這些ctor的內部,編譯器仍然會擴充這些建構函式,但不會合成一個預設ctor了(因為聲明瞭顯式的ctor)

,內部按照基類宣告順序呼叫基類的ctor。

如果這個類還包括內部成員類物件,那麼編譯器會先呼叫基類的ctor,然後呼叫內部成員類物件的ctor。

3 帶有虛擬函式的類

當類宣告或繼承一個虛擬函式;或者類派生自一個繼承鏈,內部有虛基類,那麼編譯器會自動合成預設建構函式。編譯器會生成一個虛擬函式表,內部放類的虛擬函式地址;在每一個類物件中都有一個虛表指標,都會內含相關的類虛擬函式表的地址。

4 有虛基類的類

對於類定義的每一個基類,編譯器會安插“允許每一個虛基類的執行期存取操作”的程式碼,沒有宣告ctor的時候,編譯器會自動合成一個預設ctor。

對於不是以上4種情況,且沒有宣告ctor的類,這些類實際上不會構造隱式ctor。

二 拷貝ctor的構造操作

1 預設對每一個成員變數初始化

如果沒有顯式提供拷貝ctor,那麼在編譯器執行拷貝時會預設對每一個成員變數進行拷貝

class A{int a; int b};

A a1;
A a2;
a1 = a2;//內部實現為a1.a = a2.a; a1.b = a2.b;

如果類內有類物件(有預設拷貝ctor),那麼初始化到這個成員變數的時候,會遞迴執行拷貝ctor(執行此成員變數的ctor)。

C++拷貝ctor分為重要和不重要兩種,只有重要的例項才會被合成到程式中去,是否為重要的拷貝ctor取決於“位逐次拷貝”。

2 位逐次拷貝

在以下四種情況中:

  1. 類的成員包括一個類物件的時候(如string物件),這個物件有拷貝ctor(無論是顯示宣告還是編譯器預設生成)
  2. 類繼承自一個擁有拷貝ctor的基類(無論是顯示宣告還是編譯器預設生成)
  3. 類宣告虛擬函式
  4. 類有虛基類

編譯器會合成一個拷貝ctor,以便呼叫成員變數(類物件)的拷貝ctor,否則不需要生成拷貝ctor,直接對每一個成員變數進行拷貝賦值就行(包括整數,指標,陣列等)。

3 重新設定虛表指標

當擁有虛擬函式的類物件進行賦值(呼叫拷貝ctor)時,要生成拷貝ctor用以正確拷貝虛表指標,如果是以下這種拷貝:

class A{virtual fun();};
class B:public A{fun()};    //雖然fun()沒有寫明,但還是虛擬函式

A a1;
A a2 = a1;  //可以直接拷貝虛表指標的值

可以直接拷貝續表指標,這兩個是同一個類物件,指向的是同一個虛表。

如果是下面這種拷貝:

B b;    
A a1 = b;   //不能直接拷貝虛表

那就不能直接拷貝虛表指標,因為將派生類物件賦值給基類的物件不會有多型的行為,反而會發生切割,將派生類中的部分去掉,僅僅將基類的部分賦值給a1。這樣很明顯a1指向的虛表和b不是同一個,a1指向的是基類的虛表。所以是不能直接拷貝的,而要重新設定a1虛表指標的值。

4 處理虛基類的子物件

一個類物件以其派生類的摸個物件作為初值的時候,編譯器會生成拷貝ctor,以確保生成正確的虛基類指標和偏移。

三 程式轉化語意學

1 顯示初始化

X x1(x0);
X x2 = x0;
X x1 = X(x0);

程式內部會重寫每一個定義,去除初始化操作,並將拷貝ctor的呼叫放進去。程式碼轉化為以下:

X x1;
X x2;
X x1;
x1.X::X(x0);
x2.X::X(x0);
x3.X::X(x0);

2 引數初始化

void func(X x0){ ... }
X xx;
func(xx);

以上程式碼會把區域性例項x0把xx當作初始化的初值,呼叫ctor初始化x0,然後將其1以引用方式交給函式,這個臨時的x0會在函式呼叫結束後被類內的dtor所析構。

X temp; //創造一個臨時變數
temp.X::X(xx);  //以xx作為初值初始化
void func(X &x0){ ... } //函式被轉換為按引用傳值
func(temp); //進行引數傳遞

3 返回值初始化

X func(){
    X xx;
    //處理xx
    return xx;
}

解決方法是進行一個雙階段轉化:(被稱作NRV(named return value)優化)

這裡有一個重要的前提:X類內部要實現拷貝ctor,如果沒有顯式指定拷貝ctor的話,拷貝將無法進行,就沒法執行NRV優化了。

  1. 首先加上一個額外引數,型別是類物件的一個引用,這個引數將用來放拷貝構造出來的返回值,將返回值改成void型別
  2. 在return前返回一個拷貝ctor的呼叫,將傳回的物件的內容作為上述新增引數的初值。

編譯器對func()轉換出的程式碼如下:

void func(X &ret){  //返回值變為void,增加一個引用引數
    X xx;
    //編譯器產生的預設ctor
    xx.X::X();
    //處理xx
    ret.X::X(xx);
    //編譯器所產生的拷貝ctor呼叫
    return ;
}

相關的呼叫被轉換為:

X xx = func();  //原來的程式碼
func().memfunc();   //memfunc()是X類的成員函式
X (*pf)(); pf = func;

X xx; 
func(xx);   //轉換的程式碼
X temp;
(func(temp), temp).memfunc();
void (*pf)(X &);
pf = func();

4 使用者層面做優化

X func(const T &y, const T &z){
    X xx;
    //用y,z來處理xx得到相應的值
    return xx;
}

這裡在編譯器優化的時候,xx會被轉換為額外的一個引用引數,所以需要額外的一次拷貝,把xx複製到引數中,如果在X中有相應的建構函式的話,即X::X(const T &y, const T &z);,就可以減去這一次額外的拷貝。

X func(const T &y, const T &z){
    return X(y, z);
}

5 拷貝ctor

當類中沒有成員類物件(帶有拷貝ctor),沒有虛基類或虛擬函式,那麼可以不顯式指定拷貝ctor,因為編譯器合成的預設拷貝ctor效率很高。

如果需要進行類物件大量的拷貝,那麼可以顯式指定拷貝ctor,觸發NRV優化。

如果類物件中沒有隱式產生的內部成員(如虛擬函式表指標),此時拷貝建構函式可以直接通過memset()memcpy()來執行。

四 成員們的初始化隊伍

以下幾種情況必須使用成員初始化列表:

  1. 初始化引用成員變數
  2. 初始化const成員變數
  3. 呼叫基類的建構函式,並且擁有引數的時候
  4. 呼叫成員類物件的建構函式,並且擁有引數的時候

對於4來說,如果不使用成員初始化列表,效率比較低

class Word{
private:
    string name;
    int cnt;
public:
    Word(){
        name = 0;
        cnt = 0;
    }
};

這樣的程式碼,會導致Word的物件在初始化的時候,首先會生成string的一個臨時物件,並用0對其進行初始化,然後對name進行預設構造(無參),最後通過賦值運算子進行內容的賦值。

Word::Word(/*this pointer*/){   //隱式傳入的this指標
    //呼叫string預設建構函式
    name.string::string();
    //產生臨時物件
    string temp = string(0);
    //拷貝name
    name.string::operator=(temp);
    //摧毀臨時物件
    temp.string::~string();

    cnt = 0;
}

使用成員初始化列表可以顯著提升效率

Word::Word:name(0), cnt(0){ }

這個時候會減少不必要的構造和拷貝過程

Word::Word(/*this pointer*/){   //隱式傳入的this指標
    name.string::string(0);
    cnt = 0;
}

編譯器會一一操作初始化列表的值,按照一定的順序在建構函式之內安插初始化操作,並且在使用者程式碼之前處理。

初始化列表的處理順序是類中的成員變數宣告順序決定的,和初始化列表中的順序無關。並且基類的初始化在派生類之前完成。