深度探索C++物件模型-第二章
建構函式語意學
一 預設ctor的構造操作
在4種情況下,編譯器會為未宣告ctor的類合成一個預設的ctor。
1 帶有預設ctor的成員類物件
如果一個類內含一個或一個以上的成員類物件,那麼類的每一個ctor必須呼叫每一個成員類的預設ctor,編譯器會擴張已經存在的ctor,在其中插入一些程式碼,使得使用者寫的程式碼被執行前,先呼叫必要的預設ctor。呼叫的順序是這些類物件在類中宣告的順序。
2 帶有預設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 位逐次拷貝
在以下四種情況中:
- 類的成員包括一個類物件的時候(如string物件),這個物件有拷貝ctor(無論是顯示宣告還是編譯器預設生成)
- 類繼承自一個擁有拷貝ctor的基類(無論是顯示宣告還是編譯器預設生成)
- 類宣告虛擬函式
- 類有虛基類
編譯器會合成一個拷貝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優化了。
- 首先加上一個額外引數,型別是類物件的一個引用,這個引數將用來放拷貝構造出來的返回值,將返回值改成void型別
- 在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()
來執行。
四 成員們的初始化隊伍
以下幾種情況必須使用成員初始化列表:
- 初始化引用成員變數
- 初始化const成員變數
- 呼叫基類的建構函式,並且擁有引數的時候
- 呼叫成員類物件的建構函式,並且擁有引數的時候
對於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;
}
編譯器會一一操作初始化列表的值,按照一定的順序在建構函式之內安插初始化操作,並且在使用者程式碼之前處理。
初始化列表的處理順序是類中的成員變數宣告順序決定的,和初始化列表中的順序無關。並且基類的初始化在派生類之前完成。