Performanced C++ 經驗規則(2):你不知道的構造函數(中)

jopen 8年前發布 | 10K 次閱讀 C/C++開發

4、虛表初始化

上一篇曾提到,如果一個類有虛函數,那么虛表的初始化工作,無論構造函數是你定義的還是由編譯器產生的,這部分工作都將由編譯器隱式“合成”到構造函數中,以表示其良苦用心。上一篇還提到,這部分工作,在“剛”進入構造函數的時候,就開始了,之后,編譯器才會理會,你構造函數體的第一行代碼。這一點,通過反匯編,我們已經看的非常清楚。

虛表初始化的主要內容是:將虛表指針置于對象的首4字節;用該類的虛函數實際地址替換虛表中該同特征標(同名、同參數)函數的地址,以便在調用的時候實現多態,如果有新的虛函數(派生類中新聲明的),則依次添加至虛表的后面位置。

5、構造函數中有虛特性(即多態、即動態綁定、晚綁定)產生嗎?

這個問題,看似簡單,答案卻比較復雜,正確答案是:對于構造函數,構造函數中沒有虛特性產生(在C++中答案是NO,但在Java中,答案是YES,非常的奇葩)。

先從基類構造函數說起,為什么要提基類構造函數呢,因為,派生類總是要調用一個基類的構造函數(無論是顯式調用還是由編譯器隱式地調用默認構造函數,因為這里討論的是有虛函數的情況,所以一定會有基類構造函數產生并調用),而此時,在基類構造函數中,派生類對象根本沒有創建,也就是說,基類根本不知道派生類中產生了override,即多態,故沒有虛特性產生。

這一段非常讓人疑惑。讓我們再看一小段代碼,事實勝于雄辯。

#include <iostream>
using namespace std;

class Base
{
public:
        Base() { foo(); }
        virtual void foo(void) { cout << "Base::foo(void)" << endl; }
        virtual void callFoo(void) { foo(); }
};

class Derived : public Base
{
public:
        Derived() { foo(); }
        void foo(void) { cout << "Derived::foo(void)" << endl; }
};

int main(int argc, char** argv)
{
        Base* pB = new Derived;
        pB->callFoo();
        if(pB)
                delete pB;
        return 0;
}

在Ubuntu 12.04 + gcc 4.6.3輸出結果如下:

Base::foo(void)
Derived::foo(void)
Derived::foo(void)

這個結果可以很好的解釋上述問題,第一行,由于在Base構造函數中,看不到Derived的存在,所以根本不會產生虛特性;而第二行,雖然輸出了Derived::foo(void),但因為在派生類直接調用方法名,調用的就是本類的方法,(當然,也可認為在Derived構造函數中,執行foo()前,虛表已經OK,故產生多態,輸出的是派生類的行為)。再看第三行,也產生多態,因為,此時,派生類對象已經構建完成,虛表同樣也已經OK,所以產生多態是必然。

這個問題其實是C++比較詬病的陷阱問題之一,但我們只要記住結論:不要在構造函數內調用其它的虛成員函數,否則,當這個類被繼承后,在構造函數內調用的這些虛成員函數就沒有了虛特性(喪失多態性)。(非虛成員函數本來就沒有多態性,不在此討論范圍)

解決此類問題的方法,是使用“工廠模式”,在后續篇幅中筆者會繼續提到,這也是《Effective C++》中闡述的精神:盡可能以工廠方法替換公有構造函數。

另外,有興趣的同學,可以將上述代碼稍加修改成Java跑一跑,你會驚喜的發現,三個輸出都是Derived::foo(void),也就是說,JVM為你提供了一種未卜先知的超自然能力。

6、構造函數中調用構造函數、析構函數

上面已經提到,不要在構造函數內調用其它成員函數,那么調用一些“特殊”的函數,情況又如何呢?我知道,有同學想到了,在構造函數中調用本類的析構函數,情況如何?如下面的代碼

#include <iostream>
using namespace std;

class A
{
public:
        ~A() { cout << hex << (int)this <<"destructed!" << endl; }
        A() { cout << hex << (int)this << "constructed!" << endl;
                ~A();  }

};

int main(int argc, char** argv)
{
        A a;
        return 0;
}

雖然我對有這種想法的同學有強拖之去精神病院的沖動,但還是本著研究精神,把上述“瘋子”代碼跑一遍,還特地把析構函數的定義提到構造函數之前以防構造函數不認識它。結論是:構造函數中調用析構函數,編譯器拒絕接受~A()是析構函數,從而拒絕這一不講理行為。此時編譯器認為,你是在重載~操作符,并給出沒有找到operator ~()聲明的錯誤提示。其實,無論是在構造函數A()里面調用~A()不行,在成員函數里,也是不行的(編譯器仍認為你要調用operator ~(),而你并沒有聲明這個函數)。但是,有個小詭計,卻可以編譯通過,就是通過this->~A()來調用析構函數,這將導致對象a被析構多次,隱藏著巨大的安全隱患。

總之,在構造函數中調用析構函數,是十分不道德的行為,應嚴格禁止。

好了,接下來是,構造函數中,調用構造函數,情況又如何呢?

(1)首先,如果構造函數中遞歸調用本構造函數,產生無限遞歸調用,很快就棧溢出(棧上分配)或其它crash,應嚴格禁止;

(2)如果構造函數中,調用另一個構造函數,情況如何?

#include <iostream>
using namespace std;

class ConAndCon
{
public:
    int _i;
    ConAndCon( int i ) : _i(i){}
    ConAndCon()
    {
        ConAndCon(0);
    }
};

int main(int argc, char** argv)
{
    ConAndCon cac;
    cout << cac._i << endl;
    return 0;
}

上面代碼,輸出為0嗎?

答案是:不一定。輸出結果是不確定的。根據C++類非靜態成員是沒有默認值的規則,可以推定,上述代碼里,在無參構造函數中調用另一個構造函數,并沒有成功完成對成員的初始化工作,也就是說,這個調用,是不正確的。

那么,由ConAndCon產生的對象哪里去了?如果用gdb跟蹤調試或在上述類的構造、析構函數中打印出對象信息就會發現,在構造函數中調用另一個構造函數,會產生一個匿名的臨時對象,然后這個對象又被銷毀,而調用它的cac對象,仍未得到本意的初始化(設置_i為0)。這也是應嚴格禁止的。

通常解決此問題的三個方案是:

方案一,我們稱為一根筋方案,即,我仍要繼續在構造函數中調用另一個構造函數,還要讓它正確工作,即“一根筋”,解決思路:不要產生新分配的對象,即在第一個構造函數產生了對象的內存分配之后,仍在此內存上調用另一個構造函數,通過布局new操作符(replacement new)可以做到:

//標準庫中replacement new操作符的定義:
//需要#include <new>

inline void *__cdecl operator new(size_t, void *_P)
{
    return (_P); 
}

//那么修改ConAndCon()為:

    ConAndCon()
    {
        new (this)ConAndCon(0);
    }

即在第一次分配好的內存上再次分配。

某次在Ubuntu 12.04 + gcc 4.6.3運行結果如下(修改后的代碼):

#include <iostream>
#include <new>
using namespace std;

class ConAndCon
{
public:
    int _i;
    ConAndCon( int i ) : _i(i){cout << hex << (int)this <<"constructed!" << endl;}
    ConAndCon()
    {
        cout << hex << (int)this <<"constructed!" << endl;
        new (this)ConAndCon(0);
    }
        ~ConAndCon() { cout << hex << (int)this <<"destructed!" << endl; }
};

int main(int argc, char** argv)
{
    ConAndCon cac;
    cout << cac._i << endl;
    return 0;
}

//運行結果:
bfd1ae9cconstructed!
bfd1ae9cconstructed!
0
bfd1ae9cdestructed!

可以看到,成功在第一次分配的內存上調用了另一個構造函數,且無需手動為replacement new調用析構函數(此處不同于在申請的buffer上應用replacement new,需要手動調用對象析構函數后,再釋放申請的buffer)

方案二,我們稱為“AllocAndCall”方案,即構造函數只完成對象的內存分配和調用初始化方法的功能,即把在多個構造函數中都要初始化的部分“提取”出來,通常做為一個private和非虛方法(為什么不能是虛的參見上面第5點),然后在每個構造函數中調用此方法完成初始化。通常,這樣的方法取名為init,initialize之類。

class AllocAndCall
{
private:
    void initial(...) {...} //初始化集中這里
public:
    AllocAndCall() { initial(); ...}
    AllocAndCall(int x) { initail(); ...}
};

這個方案和后面要詳述的“工廠模式”,在一些思想上類似。

這個方案最大的不足,是在于,initial()初始化方法不是構造函數而不能使用初始化列表,對于非靜態const成員的初始化將無能為力。也就是說,如果該類包含非靜態的const成員(靜態的成員初始化參看上一篇中的第2點),則對這些非靜態const成員的初始化,必須要在每個構造函數的初始化列表完成,無法“抽取“到初始化方法中。

方案三,我們稱為“C++ 0x“方案,這是C++ 0x中的新特性,叫做“委托構造函數”,通過在構造函數的初始化列表(注意不是構造函數體內)中調用其它構造函數,來得到相應目的。感謝C++ 0x!

class CPerson
{
public:
 CPerson() : CPerson(0, "") { NULL; }
 CPerson(int nAge) : CPerson(nAge, "") { NULL; }
 CPerson(int nAge, const string &strName)
 {
  stringstream ss;
  ss << strName << "is " << nAge << "years old.";
  m_strInfo = ss.str();
 }

private:
 string m_strInfo;
};

其實,對于這樣的問題,筆者認為,最好的解決方式,沒有在這幾種方案中討論,仍是——使用“工廠模式”,替換公有構造函數。

中篇到此結束,下一篇將會有更多精彩內容——in C++ Constructor!。謝謝大家!

 本文由用戶 jopen 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!