C++的多態性實現機制剖析
C++
的多態性實現機制剖析
――即
VC++
視頻第三課
this
指針詳細說明
作者:孫鑫
時間:
2006
年
1
月
12
日
星期四
1.?? 多態性和虛函數
我們先看一個例子:
例 1- 1
#include <iostream.h>
class animal
{
public :
?????? void sleep()
?????? {
????????????? cout <<"animal sleep"<<endl;
?????? }
??????
void
breathe()
?????? {
?????????????
cout
<<"animal breathe"<<endl;
?????? }
};
class fish:public animal
{
public :
??????
void
breathe()
?????? {
?????????????
cout
<<"fish bubble"<<endl;
?????? }
};
void main()
{
??????
fish
fh;
??????
animal
*pAn=&fh;
??????
pAn
->breathe();
}
?????? 注意,在例 1-1 的程序中沒有定義虛函數。考慮一下例 1-1 的程序執行的結果是什么?
?????? 答案是輸出: animal breathe
?????? 我們在 main() 函數中首先定義了一個 fish 類的對象 fh ,接著定義了一個指向 animal 類的指針變量 pAn ,將 fh 的地址賦給了指針變量 pAn ,然后利用該變量調用 pAn ->breathe() 。許多學員往往將這種情況和 C++ 的多態性搞混淆,認為 fh 實際上是 fish 類的對象,應該是調用 fish 類的 breathe() ,輸出“ fish bubble ”,然后結果卻不是這樣。下面我們從兩個方面來講述原因。
1、? 編譯的角度
C++ 編譯器在編譯的時候,要確定每個對象調用的函數的地址,這稱為早期綁定( early binding ),當我們將 fish 類的對象 fh 的地址賦給 pAn 時, C++ 編譯器進行了類型轉換,此時 C++ 編譯器認為變量 pAn 保存的就是 animal 對象的地址。當在 main() 函數中執行 pAn ->breathe() 時,調用的當然就是 animal 對象的 breathe 函數。
2、? 內存模型的角度
我們給出了 fish 對象內存模型,如下圖所示:
animal
的對象所占內存
fish
的對象自身增加的部分
fish
類的對象所占內存
圖 1- 1 fish 類對象 的內存模型
我們構造 fish 類的對象時,首先要調用 animal 類的構造函數去構造 animal 類的對象,然后才調用 fish 類的構造函數完成自身部分的構造,從而拼接出一個完整的 fish 對象。當我們將 fish 類的對象轉換為 animal 類型時,該對象就被認為是原對象整個內存模型的上半部分,也就是圖 1-1 中的“ animal 的對象所占內存”。那么當我們利用類型轉換后的對象指針去調用它的方法時,當然也就是調用它所在的內存中的方法。因此,輸出 animal breathe ,也就順理成章了。
正如很多學員所想,在例 1-1 的程序中,我們知道 pAn 實際指向的是 fish 類的對象,我們希望輸出的結果是魚的呼吸方法,即調用 fish 類的 breathe 方法。這個時候,就該輪到虛函數登場了。
前面輸出的結果是因為編譯器在編譯的時候,就已經確定了對象調用的函數的地址,要解決這個問題就要使用遲綁定( late binding )技術。當編譯器使用遲綁定時,就會在運行時再去確定對象的類型以及正確的調用函數。而要讓編譯器采用遲綁定,就要在基類中聲明函數時使用 virtual 關鍵字(注意,這是必須的,很多學員就是因為沒有使用虛函數而寫出很多錯誤的例子),這樣的函數我們稱為虛函數。一旦某個函數在基類中聲明為 virtual ,那么在所有的派生類中該函數都是 virtual ,而不需要再顯式地聲明為 virtual 。
下面修改例 1-1 的代碼,將 animal 類中的 breathe() 函數聲明為 virtual ,如下:
例 1- 2
#include <iostream.h>
class animal
{
public :
?????? void sleep()
?????? {
????????????? cout <<"animal sleep"<<endl;
?????? }
?????? virtual void breathe()
?????? {
????????????? cout <<"animal breathe"<<endl;
?????? }
};
class fish:public animal
{
public :
?????? void breathe()
?????? {
????????????? cout <<"fish bubble"<<endl;
?????? }
};
void main()
{
?????? fish fh;
?????? animal *pAn=&fh;
?????? pAn ->breathe();
}
大家可以再次運行這個程序,你會發現結果是“ fish bubble ”,也就是根據對象的類型調用了正確的函數。
那么當我們將 breathe() 聲明為 virtual 時,在背后發生了什么呢?
編譯器在編譯的時候,發現 animal 類中有虛函數,此時編譯器會為每個包含虛函數的類創建一個虛表(即 vtable ),該表是一個一維數組,在這個數組中存放每個虛函數的地址。對于例 1-2 的程序, animal 和 fish 類都包含 了一個虛函數 breathe() ,因此編譯器會為這兩個類都建立一個虛表,如下圖所示:
&animal::breathe()
animal
類的
vtable
animal::breathe
()
&fish::breathe()
fish
類的
vtable
fish::breathe
()
圖 1- 2 animal 類和 fish 類的虛表
?????? 那么如何定位虛表呢?編譯器另外還為每個類的對象提供了一個虛表指針(即 vptr ),這個指針指向了對象所屬類的虛表。在程序運行時,根據對象的類型去初始化 vptr ,從而讓 vptr 正確的指向所屬類的虛表,從而在調用虛函數時,就能夠找到正確的函數。對于例 1-2 的程序,由于 pAn 實際指向的對象類型是 fish ,因此 vptr 指向的 fish 類的 vtable ,當調用 pAn ->breathe() 時,根據虛表中的函數地址找到的就是 fish 類的 breathe() 函數。
正是由于每個對象調用的虛函數都是通過虛表指針來索引的,也就決定了虛表指針的正確初始化是非常重要的。換句話說,在虛表指針沒有正確初始化之前,我們不能夠去調用虛函數。那么虛表指針在什么時候,或者說在什么地方初始化呢?
答案是在構造函數中進行虛表的創建和虛表指針的初始化。還記得構造函數的調用順序嗎,在構造子類對象時,要先調用父類的構造函數,此時編譯器只“看到了”父類,并不知道后面是否后還有繼承者,它初始化父類對象的虛表指針,該虛表指針指向父類的虛表。當執行子類的構造函數時,子類對象的虛表指針被初始化,指向自身的虛表。對于例 2-2 的程序來說,當 fish 類的 fh 對象構造完畢后,其內部的虛表指針也就被初始化為指向 fish 類的虛表。在類型轉換后,調用 pAn ->breathe() ,由于 pAn 實際指向的是 fish 類的對象,該對象內部的虛表指針指向的是 fish 類的虛表,因此最終調用的是 fish 類的 breathe() 函數。
要注意:對于虛函數調用來說,每一個對象內部都有一個虛表指針,該虛表指針被初始化為本類的虛表。所以在程序中,不管你的對象類型如何轉換,但該對象內部的虛表指針是固定的,所以呢,才能實現動態的對象函數調用,這就是 C++ 多態性實現的原理。
總結(基類有虛函數):
1、? 每一個類都有虛表。
2、? 虛表可以繼承,如果子類沒有重寫虛函數,那么子類虛表中仍然會有該函數的地址,只不過這個地址指向的是基類的虛函數實現。如果基類3個虛函數,那么基類的虛表中就有三項(虛函數地址),派生類也會有虛表,至少有三項,如果重寫了相應的虛函數,那么虛表中的地址就會改變,指向自身的虛函數實現。如果派生類有自己的虛函數,那么虛表中就會添加該項。
3、? 派生類的虛表中虛函數地址的排列順序和基類的虛表中虛函數地址排列順序相同。
2.?? VC 視頻第三課 this 指針說明
我在論壇的 VC 教學視頻版面發了帖子,是模擬 MFC 類庫的例子寫的,主要是說明在基類的構造函數中保存的 this 指針是指向子類的,我們在看一下這個例子:
例 1- 3
#include <iostream.h>
class base;
base * pbase;
class base
{
public :
?????? base()
?????? {
????????????? pbase =this;
?????????????
?????? }
?????? virtual void fn()
?????? {
????????????? cout <<"base"<<endl;
?????? }
};
class derived:public base
{
?????? void fn()
?????? {
????????????? cout <<"derived"<<endl;
?????? }
};
derived aa;
void main()
{
?????? pbase ->fn();
}
我在 base 類的構造函數中將 this 指針保存到 pbase 全局變量中。在定義全局對象 aa ,即調用 derived aa; 時,要調用基類的構造函數,先構造基類的部分,然后是子類的部分,由這兩部分拼接出完整的對象 aa 。這個 this 指針指向的當然也就是 aa 對象,那么我們在 main() 函數中利用 pbase 調用 fn() ,因為 pbase 實際指向的是 aa 對象,而 aa 對象內部的虛表指針指向的是自身的虛表,最終調用的當然是 derived 類中的 fn() 函數。
在這個例子中,由于我的疏忽,在 derived 類中聲明 fn() 函數時,忘了加 public 關鍵字,導致聲明為了 private (默認為 private ),但通過前面我們所講述的虛函數調用機制,我們也就明白了這個地方并不影響它輸出正確的結果。不知道這算不算 C++ 的一個 Bug ,因為虛函數的調用是在運行時確定調用哪一個函數,所以編譯器在編譯時,并不知道 pbase 指向的是 aa 對象,所以導致這個奇怪現象的發生。如果你直接用 aa 對象去調用,由于對象類型是確定的(注意 aa 是對象變量,不是指針變量),編譯器往往會采用早期綁定,在編譯時確定調用的函數,于是就會發現 fn() 是私有的,不能直接調用。:)
許多學員在寫這個例子時,直接在基類的構造函數中調用虛函數,前面已經說了,在調用基類的構造函數時,編譯器只“看到了”父類,并不知道后面是否后還有繼承者,它只是初始化父類對象的虛表指針,讓該虛表指針指向父類的虛表,所以你看到結果當然不正確。只有在子類的構造函數調用完畢后,整個虛表才構建完畢,此時才能真正應用
C++
的多態性。換句話說,我們不要在構造函數中去調用虛函數,當然如果你只是想調用本類的函數,也無所謂。
3.?? 參考資料:
1 、文章《在 VC6.0 中虛函數的實現方法》,作者: backer ,網址:
http://www.mybole.com.cn/bbs/dispbbs.asp?boardid=4&id=1012&star=1
2 、書《 C++ 編程思想》 機械工業出版社
4.?? 后記
本想再寫詳細些,發現時間不夠,總是有很多事情,在加上水平也有限,想想還是以后再說吧。不過我相信,這些內容也能夠幫助大家很好的理解了。也歡迎網友能夠繼續補充,大家可以鼓動鼓動 backer ,讓他從匯編的角度再給一個說明,哈哈,別說我說的。
posted on 2006-10-09 22:02 weidagang2046 閱讀(553) 評論(0) 編輯 收藏 所屬分類: C/C++