1. 程式人生 > >在建構函式/解構函式中呼叫虛擬函式

在建構函式/解構函式中呼叫虛擬函式

先看一段在建構函式中直接呼叫虛擬函式的程式碼:

複製程式碼
 1 #include <iostream>
 2 
 3 class Base
 4 {
 5 public:
 6     Base() { Foo(); }   ///< 列印 1
 7 
 8     virtual void Foo()
 9     {
10         std::cout << 1 << std::endl;
11     }
12 };
13 
14 class Derive : public Base
15 {
16 public:
17     Derive() : Base(), m_pData(new
int(2)) {} 18 ~Derive() { delete m_pData; } 19 20 virtual void Foo() 21 { 22 std::cout << *m_pData << std::endl; 23 } 24 private: 25 int* m_pData; 26 }; 27 28 int main() 29 { 30 Base* p = new Derive(); 31 delete p; 32 return 0; 33 }
複製程式碼

  這裡的結果將列印:1。

  這表明第6行執行的的是Base::Foo()而不是Derive::Foo(),也就是說:虛擬函式在建構函式中“不起作用”。為什麼?

  當例項化一個派生類物件時,首先進行基類部分的構造,然後再進行派生類部分的構造。即建立Derive物件時,會先呼叫Base的建構函式,再呼叫Derive的建構函式。

  當在構造基類部分時,派生類還沒被完全建立,從某種意義上講此時它只是個基類物件。即當Base::Base()執行時Derive物件還沒被完全建立,此時它被當成一個Base物件,而不是Derive物件,因此Foo繫結的是Base的Foo。

  C++之所以這樣設計是為了減少錯誤和Bug的出現。假設在建構函式中虛擬函式仍然“生效”,即Base::Base()中的Foo();所呼叫的是Derive::Foo()。當Base::Base()被呼叫時派生類中的資料m_pData還未被正確初始化,這時執行Derive::Foo()將導致程式對一個未初始化的地址解引用,得到的結果是不可預料的,甚至是程式崩潰(訪問非法記憶體)。

  總結來說:基類部分在派生類部分之前被構造,當基類建構函式執行時派生類中的資料成員還沒被初始化。如果基類建構函式中的虛擬函式呼叫被解析成呼叫派生類的虛擬函式,而派生類的虛擬函式中又訪問到未初始化的派生類資料,將導致程式出現一些未定義行為和bug。

  對於這一點,一般編譯器會給予一定的支援。如果將基類中的Foo宣告成純虛擬函式時(看下面程式碼),編譯器可能會:在編譯時給出警告、連結時給出符號未解析錯誤(unresolved external symbol)。如果能生成可執行檔案,執行時一定出錯。因為Base::Base()中的Foo總是呼叫Base::Foo,而此時Base::Foo只宣告沒定義。大部分編譯器在連結時就能識別出來。

複製程式碼
 1 #include <iostream>
 2 
 3 class Base
 4 {
 5 public:
 6     Base() { Foo(); }   ///< 可能的結果:編譯警告、連結出錯、執行時錯誤
 7 
 8     virtual void Foo() = 0;
 9 };
10 
11 class Derive : public Base
12 {
13 public:
14     Derive() : Base(), m_pData(new int(2)) {}
15     ~Derive() { delete m_pData; }
16 
17     virtual void Foo()
18     {
19         std::cout << *m_pData << std::endl;
20     }
21 private:
22     int* m_pData;
23 };
24 
25 int main()
26 {
27     Base* p = new Derive();
28     delete p;
29     return 0;
30 }
複製程式碼

  如果編譯器都能夠在編譯或連結時識別出這種錯誤呼叫,那麼我們犯錯的機會將大大減少。只是有一些比較不直觀的情況(看下面程式碼),編譯器是無法判斷出來的。這種情況下它可以生成可執行檔案,但是當程式執行時會出錯。

複製程式碼
 1 #include <iostream>
 2 
 3 class Base
 4 {
 5 public:
 6     Base() { Subtle(); }   ///< 執行時錯誤(pure virtual function call)
 7 
 8     virtual void Foo() = 0;
 9     void Subtle() { Foo(); }
10 };
11 
12 class Derive : public Base
13 {
14 public:
15     Derive() : Base(), m_pData(new int(2)) {}
16     ~Derive() { delete m_pData; }
17 
18     virtual void Foo()
19     {
20         std::cout << *m_pData << std::endl;
21     }
22 private:
23     int* m_pData;
24 };
25 
26 int main()
27 {
28     Base* p = new Derive();
29     delete p;
30     return 0;
31 }
複製程式碼

  從編譯器開發人員的角度上看,如何實現上述的“特性”呢?

  我的猜測是在虛擬函式表地址的繫結上做文章:在“當前類”(正在被構造的類)的建構函式被呼叫時,將“當前類”的虛擬函式表地址繫結到物件上。當基類部分被構造時,“當前類”是基類,這裡是Base,即當Base::Base()的函式體被呼叫時,Base的虛擬函式表地址會被繫結到物件上。而當Derive::Derive()的函式體被呼叫時,Derive的虛擬函式表地址被繫結到物件上,因此最終物件上繫結的是Derive的虛擬函式表。

  這樣編譯器在處理的時候就會變得很自然。因為每個類在被構造時不用去關心是否有其他類從自己派生,而不需要關心自己是否從其他類派生,而只要按照一個統一的流程,在自身的建構函式執行之前把自身的虛擬函式表地址繫結到當前物件上(一般是儲存在物件記憶體空間中的前4個位元組)。因為物件的構造是從最基類部分(比如A<-B<-C,A是最基類,C是最派生類)開始構造,一層一層往外構造中間類(B),最後構造的是最派生類(C),所以最終物件上繫結的就自然而然就是最派生類的虛擬函式表。

  也就是說物件的虛擬函式表在物件被構造的過程中是在不斷變化的,構造基類部分(Base)時被繫結一次,構造派生類部分(Derive)時,又重新繫結一次。基類建構函式中的虛擬函式呼叫,按正常的虛擬函式呼叫規則去呼叫函式,自然而然地就呼叫到了基類版本的虛擬函式,因為此時物件繫結的是基類的虛擬函式表。

  下面要給出在WIN7下的Visual Studio2010寫的一段程式,用以驗證“物件的虛擬函式表在物件被構造的過程中是在不斷變化的”這個觀點。

  這個程式在類的建構函式裡做了三件事:1.打印出this指標的地址;2.列印虛擬函式表的地址;3.直接通過虛擬函式表來呼叫虛擬函式。

  列印this指標,是為了表明建立Derive物件是,不管是執行Base::Base()還是執行Derive::Derive(),它們構造的是同一個物件,因此兩次打印出來的this指標必定相等。

  列印虛擬函式表的地址,是為了表明在建立Derive物件的過程中,虛擬函式表的地址是有變化的,因此兩次打印出來的虛擬函式表地址必定不相等。

  直接通過函式表來呼叫虛擬函式,只是為了表明前面所列印的確實是正確的虛擬函式表地址,因此Base::Base()的第19行將列印Base,而Derive::Derive()的第43行將列印Derive。

  注意:這段程式碼是編譯器相關的,因為虛擬函式表的地址在物件中儲存的位置不一定是前4個位元組,這是由編譯器的實現細節來決定的,因此這段程式碼在不同的編譯器未必能正常工作,這裡所使用的是Visual Studio2010。

複製程式碼
 1 #include <iostream>
 2 
 3 class Base
 4 {
 5 public:
 6     Base() { PrintBase(); }
 7 
 8     void PrintBase()
 9     {
10         std::cout << "Address of Base: " << this << std::endl;
11 
12         // 虛表的地址存在物件記憶體空間裡的頭4個位元組
13         int* vt = (int*)*((int*)this);
14         std::cout << "Address of Base Vtable: " << vt << std::endl;
15 
16         // 通過vt來呼叫Foo函式,以證明vt指向的確實是虛擬函式表
17         std::cout << "Call Foo by vt -> ";
18         void (*pFoo)(Base* const) = (void (*)(Base* const))vt[0];
19         (*pFoo)(this);
20 
21         std::cout << std::endl;
22     }
23 
24     virtual void  Foo() { std::cout << "Base" << std::endl; }
25 };
26 
27 class Derive : public Base
28 {
29 public:
30     Derive() : Base() { PrintDerive(); }
31 
32     void PrintDerive()
33     {
34         std::cout << "Address of Derive: " << this << std::endl;
35 
36         // 虛表的地址存在物件記憶體空間裡的頭4個位元組
37         int* vt = (int*)*((int*)this);
38         std::cout << "Address of Derive Vtable: " << vt << std::endl;
39 
40         // 通過vt來呼叫Foo函式,以證明vt指向的確實是虛擬函式表
41         std::cout << "Call Foo by vt -> ";
42         void (*pFoo)(Base* const) = (void (*)(Base* const))vt[0];
43         (*pFoo)(this);
44 
45         std::cout << std::endl;
46     }
47 
48     virtual void Foo() { std::cout << "Derive" << std::endl; }
49 };
50 
51 int main()
52 {
53     Base* p = new Derive();
54     delete p;
55     return 0;
56 }
複製程式碼

輸出的結果跟預料的一樣:

複製程式碼
1 Address of Base: 002E7F98
2 Address of Base Vtable: 01387840
3 Call Foo by vt -> Base
4 
5 Address of Derive: 002E7F98
6 Address of Derive Vtable: 01387834
7 Call Foo by vt -> Derive
複製程式碼

  在解構函式中呼叫虛擬函式,和在建構函式中呼叫虛擬函式一樣。

  解構函式的呼叫跟建構函式的呼叫順序是相反的,它從最派生類的解構函式開始的。也就是說當基類的解構函式執行時,派生類的解構函式已經執行過,派生類中的成員資料被認為已經無效。假設基類中虛擬函式呼叫能呼叫得到派生類的虛擬函式,那麼派生類的虛擬函式將訪問一些已經“無效”的資料,所帶來的問題和訪問一些未初始化的資料一樣。而同樣,我們可以認為在析構的過程中,虛擬函式表也是在不斷變化的。

  將上面的程式碼增加解構函式的呼叫,並稍微修改一下,就能驗證這一點:

複製程式碼
 1 #include <iostream>
 2 
 3 class Base
 4 {
 5 public:
 6     Base() { PrintBase(); }
 7     virtual ~Base() { PrintBase(); }
 8 
 9     void PrintBase()
10     {
11         std::cout << "Address of Base: " << this << std::endl;
12 
13         // 虛表的地址存在物件記憶體空間裡的頭4個位元組
14         int* vt = (int*)*((int*)this);
15         std::cout << "Address of Base Vtable: " << vt << std::endl;
16 
17         // 通過vt來呼叫Foo函式,以證明vt指向的確實是虛擬函式表
18         std::cout << "Call Foo by vt -> ";
19         void (*pFoo)(Base* const) = (void (*)(Base* const))vt[1];   ///< 注意這裡索引變成 1 了,因為解構函式定義在Foo之前
20         (*pFoo)(this);
21 
22         std::cout << std::endl;
23     }
24 
25     virtual void  Foo() { std::cout << "Base" << std::endl; }
26 };
27 
28 class Derive : public Base
29 {
30 public:
31     Derive() : Base() { PrintDerive(); }
32     virtual ~Derive() { PrintDerive(); }
33 
34     void PrintDerive()
35     {
36         std::cout << "Address of Derive: " << this << std::endl;
37 
38         // 虛表的地址存在物件記憶體空間裡的頭4個位元組
39         int* vt = (int*)*((int*)this);
40         std::cout << "Address of Derive Vtable: " << vt << std::endl;
41 
42         // 通過vt來呼叫Foo函式,以證明vt指向的確實是虛擬函式表
43         std::cout << "Call Foo by vt -> ";
44         void (*pFoo)(Base* const) = (void (*)(Base* const))vt[1];   ///< 注意這裡索引變成 1 了,因為解構函式定義在Foo之前
45         (*pFoo)(this);
46 
47         std::cout << std::endl;
48     }
49 
50     virtual void Foo() { std::cout << "Derive" << std::endl; }
51 };
52 
53 int main()
54 {
55     Base* p = new Derive();
56     delete p;
57     return 0;
58 }
複製程式碼

下面是列印結果,可以看到構造和析構是順序相反的兩個過程:

複製程式碼
 1 Address of Base: 001E7F98
 2 Address of Base Vtable: 01297844
 3 Call Foo by vt -> Base
 4 
 5 Address of Derive: 001E7F98
 6 Address of Derive Vtable: 01297834
 7 Call Foo by vt -> Derive
 8 
 9 Address of Derive: 001E7F98
10 Address of Derive Vtable: 01297834
11 Call Foo by vt -> Derive
12 
13 Address of Base: 001E7F98
14 Address of Base Vtable: 01297844
15 Call Foo by vt -> Base
複製程式碼

  最終結論:

    2. 物件的虛擬函式表地址在物件的構造和析構過程中會隨著部分類的構造和析構而發生變化,這一點應該是編譯器實現相關的。

注:以上的討論是基於簡單的單繼承,對於多重繼承或虛繼承會有一些細節上的差別。