為什么不要在構造函數中調用虛函數


先看一段在構造函數中直接調用虛函數的代碼:

 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

  最終結論:

    1. 不要在構造函數和析構函數中調用虛函數,因為這種情況下的虛函數調用不會調用到外層派生類的虛函數(參考:http://www.artima.com/cppsource/nevercall.htmlhttp://www.parashift.com/c%2B%2B-faq-lite/strange-inheritance.html#faq-23.5)。

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

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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM