用栗子打倒C++多態

寫在前面:

題目不是標題黨哈,因爲我比較喜歡把我自己理解的東西,然後寫出栗子,並且畫出圖(我的畫圖水平還是可以的啦)來深入淺出得讓別人理解。

C++的多態啊,虛函數,繼承,虛表等等東西大家學過C++的都知道,但是如果要說出其中的所以然來,還真不一定說得明白和準確。

我自己也是看了很多博客,以及不少書籍,所以想總結一下,作爲知識的梳理,也希望給疑惑的人給予解惑。

參考資料:

http://www.jellythink.com/archives/162這是Jelly介紹接口的原理的,想讓大家瞭解COM,虛函數這部分圖示做得還是很不錯的。

http://www.jb51.net/article/41809.htm這一篇的例子挺好,不過有些地方沒有說明白。

http://zhidao.baidu.com/link?url=naBFR7YFi_R7fu5dX3pJz5UsK2CCXI0cXobjjP_g_SKD9Qv--e1j8vYC7Fwyrf5cgpF82o2kelXXzB0lI-ILaa這介紹了多態的概念(廣義、狹義)和應用場景。

《深入探索C++對象模型》(以下簡稱《Inside》)

大家可以提前去看一下,或者看完我的博客之後再去看,應該會有不少收穫。

一、Class Object

首先,我們應該問自己,類對象在實例化後,存儲結構究竟是怎麼樣的,類變量,類函數,靜態變量,靜態函數以及虛函數分別放在什麼地方?

《Inside》中提到:“Nonstatic data members被配置於每一個class object之內,static data members則被存放在所有的class object之外,static和nonstatic function members也被放在所有的class object之外。”

舉個書中的栗子:

對於:

class Point{
public:
	Point(float xval);//函數都在object外
	virtual ~Point();//虛函數在虛表vtbl中
	float x() const;//函數都在object外
	static int PointCount();//函數都在object外
protected:
	virtual ostream& print(ostream &os)const;//虛函數在虛表vtbl中
	float _x;//類變量在object內
	static int _point_count;//靜態變量在object外
};


對應的圖示如上所示,其中幾個函數(無論是靜態類函數還是普通類函數,不是virtual)的都在object外,靜態類變量也在object外,而普通類變量和指向虛表vtbl的虛指針vptr在object內,虛表中除了有指向兩個虛函數的指針外,還有一個指向type info的指針。

書中講到:

virutal functions以兩個步驟支撐:
1,每一個class產生出一堆指向virtual functions的指針,放在表格之中,這個表格被稱爲virtual table(vtbl)
2,每一個class object被添加了一個指針,指向相關的virtual table,通常這個指針被稱爲vptr,vptr的設定和重置都由每一個class的constructor、destructor和copy assignment運算符自動完成。每一個class所關聯的type_info object(用以支持runtime type identification:RTTI)也經由virtual table被指出來,通常是放在表格的第一個slot處。

那麼我們自己試驗一下:

以下代碼:

#include <iostream>
using namespace std;
class animal{
public:
	void sleep(){//普通類函數,不在object內
		cout << "animal sleep" << endl;
	}
	virtual void breathe(){//虛函數在虛表vtbl中
		cout << "animal breathe" << endl;
	}
	virtual void run(){//虛函數在虛表vtbl中
		cout << "animal run" << endl;
	}
	static string s_str;//靜態變量在object外
	string name;//類變量在object內
};
int main(){
	animal An1;
	return 0;
}



在return 0;處設置斷點查看變量An1的內部,可以看到object內只有name和__vfptr指向虛表的指針。

虛表中有兩個指針,一個指向animal::breathe,一個指向animal::run,大致符合書中描述,不過並沒有書中提到的本改在虛表中第一位置的type_info for Point,不知道是微軟實現的不同還是後來C++標準變化了。

一般而言表現一個class object需要以下內存:
(1)其nonstatic data members的總和大小
(2)加上任何由於alignment的需求而填補(padding)上去的空間,(可能存在於members之間,也可能存在於集合體邊界)
(3)加上爲了支持virtual而由內部產生的任何額外負擔(overhead)
上面的知識點其實和我們最熟知的sizeof有密切關係,因爲只有在class object內的大小纔會被計入sizeof中,如果對上述的Point類進行
cout << sizeof(Point) << endl;
我們會得到8,在class object之內只有一個float和一個__vfptr,剛好是4+4=8;
而如果cout << sizeof(animal) << endl;則會得到32,這是因爲一個size(string)在我的VS2013中得到的是28(相當於string類的class object佔28大小),再加上一個__vfptr,剛好是32。


二、Pointer type

接下來討論指針,先考慮一個類和指向它的指針

class ZooAnimal{
public:
	ZooAnimal();
	virtual ~ZooAnimal();
	//...
	virtual void rotate();
protected:
	int loc;
	string name;
};
ZooAnimal za("Zoey");
ZooAnimal *pza = &za;


它的內存大致是這樣的。

一個指向Animal類的指針是如何與一個指向整數的指針或一個指向template Array的指針有所不同的呢?

Animal *px;
int *pi;
Array<String>*ptra;
以內存需求的觀點來看,沒有什麼不同!它們三個都需要有足夠的內存來放置一個機器地址。“指向不同類型之各指針”間的差異,既不在其指針表示法不同,也不在其內容(代表一個地址)不同,而是在其鎖尋址出來的object類型不同,也就是說,“指針類型”會教導編譯器如何解釋某個特定地址中的內存內容及其大小。
那麼,一個指向地址1000而類型爲void*的指針,將涵蓋怎樣的地址空間呢?是的,我們不知道!這就是爲什麼一個類型爲void*的指針只能夠含有一個地址,而不能夠通過它操作所指的object的緣故。
所以,轉型(cast)其實是一種編譯器指令,大部分情況下它並不改變一個指針所含的真正地址,它隻影響“被指出之內存的大小和其內容”的解釋方式。

可以看到pza中只是存放了地址,而是ZooAnimal這一個指針類型告訴編譯器怎麼來解釋這個地址以及之後多大內存的內容。


再考慮以下類的繼承關係:Bear繼承自ZooAnimal

class Bear : public ZooAnimal{
public:
	Bear();
	~Bear();
	//...
	void ratate();
	virtual void dance();
	//...
protected:
	enum Dances{ ... };
	Dances dances_known;
	int cell_block;
};
Bear b("Yoqi");
Bear *pb = &b;
Bear &rb = *pb;

可以看到,對於:
Bear b;
ZooAnimal *pz = &b;
Bear *pb = &b;
Bear指針pb和ZooAnimal指針pz都指向Bear object的第一個byte,其間的差別是:pb所涵蓋的地址包含整個Bear object,而pz所涵蓋的地址只包含Bear object中的ZooAnimal subobject。
特別要注意的是:對於pz來說,它可以訪問到__vptr_ZooAnimal,但是它不能訪問到Bear自己部分的內容比如dances_known或cell_block(除非見標註1),而且,pz->__vptr_ZooAnimal中的虛表內容是Bear中的虛指針對應的虛表,裏面的函數已經被覆蓋了。(栗子中等下會提到,標註2)

標註1:

pz不能訪問cell_block,因爲後者屬於Bear自己的東西而不是ZooAnimal中繼承而來的。但是因爲pz是指針,所以在轉換類型後還是可以訪問到的(相當於我們告訴編譯器把pz當做Bear指針來看待,即把接下來的內容按照Bear的obejct的內容來分析)。

//不合法,cell_block不是ZooAnimal的一個member,
//雖然我們知道pz當前指向一個Bear object,但pz是一個ZooAnimal的指針
//它無法按照Bear那樣去解讀分析內容
pz->cell_block;//error

//ok,經過一個明確的downcast操作就沒有問題!
(( Bear* ) pz)->cell_block;

//下面這樣更好,不過它是一個run-time operation,運行時確定
if (Bear* pb2 = dynamic_cast<Bear*>(pz))
	pb2->cell_block;

其中,dynamic_cast運算符:用於將基類的指針或引用安全地轉換成派生類的指針或引用

它配合typeid運算符:用於返回表達式的類型

dynamic_cast和typeid共同完成了RTTI(run-time type identification)運行時類型識別

當我們將這兩個運算符用於某種類型的指針或引用,並且該類型含有虛函數時,運算符將使用指針或引用所綁定對象的動態類型。

(參考《C++primer》P730)

也就是說,當你用ZooAnimal *pz的指針去指向Bear對象時,你用dynamic_cast將pz綁定爲一個Bear對象,這樣變化後的pz就可以訪問Bear自己的特有變量或函數了。

舉個栗子:

我們還是用animal,不過加上了fish的繼承關係:

#include <iostream>
using namespace std;
class animal{
public:
	void sleep(){
		cout << "animal sleep" << endl;
	}
	virtual void breathe(){
		cout << "animal breathe" << endl;
	}
	virtual void run(){
		cout << "animal run" << endl;
	}
	static string s_str;
	string name;
};
class fish :public animal{
public:
	void breathe(){
		cout << "fish bubble" << endl;
	}
	void swim(){
		cout << "fish swim" << endl;
	};
	string fish_name;
};
int main(){
	animal An1;
	fish fh;
	animal *pAn = &fh; 
	pAn->breathe();
	//pAn->
	//dynamic_cast<fish*>(pAn)->
	animal An2 = fh;
	An2.breathe();
	return 0;
}
當寫到pAn->它能夠自動補全的只有animal中自己的變量、函數和虛函數(不過這個虛函數已經不是animal的虛函數了,標註2)


而當我們做了一個dynamic_cast的轉化之後,它能夠識別的還有fish種自己的特有方法和變量



三、多態

舉個栗子:

接上一個栗子,看一下31行中pAn的內存,給大家解釋一下虛函數是怎麼實現的

這裏有4個要關注的,Animal對象An,fish對象fh,指向對象fh的父類即animal類指針pAn,還有被子類進行對象賦值拷貝的父類An2

總的內存狀況是如下:


讓我們來庖丁解牛,首先,注意到animal類對象An和fish類對象fh,


可以看到,在子類對象fh中是有一個完整的父類結構的,裏面的__vfptr和name也都有,但是注意到,因爲fish中重新寫了(相當於Java中@override)breathe函數,所以fh中的__vfptr中已經變成了fish::breathe,而不是An1中的animal::breathe,並且地址已經不一樣了(標示2)。而fh中的animal::run的地址還是和An1的一樣(公用),它們都是0x00e7150a。

用以下圖示表示(圖中的地址都是當前指針指向的地址,而不是當前指針所在的地址):


也許讀者會疑惑,如果是兩個Fish對象,它們的內存是怎麼樣的呢?要知道,類是公用虛表的,所以如果有多個fish對象,它們的name指向的地址肯定是不同的,但是它們的__vfptr指向的地址是相同的(指向同一個虛表),那麼當然vbtl中各個項指向的地址也是相同的。(這個在接下來的An和An2中我們也可以看出來,標示3)

接着往下看,當ZooAnimal* pAn = &fh;後


可以看到pAn中和fh最大的區別就是少了黃框標註的fish獨有的那部分變量,而注意到pAn雖然是一個animal的指針,但是它裏面的虛表因爲是在fh中的,所以裏面的項還是和fh中一樣的,__vfptr[0]還是fish::breathe()而不是animal::breathe(),這就是多態的實現了!所謂的“運行時將會根據對象的實際類型來調用相應的函數。如果對象類型是派生類,就調用派生類的函數;如果對象類型是基類,就調用基類的函數”和“允許將子類類型的指針賦值給父類類型的指針。”

不過我個人還是疑惑爲什麼pAn中第一項有[fish]這個東西,裏面的結構和上面的fh一模一樣,除了fish和fh這兩個名字上的差別,我覺得這個量應該不是在pAn指針中,而是隻是指示說當前指向對象是fish對象用的。

咦,這會不會就是我們剛開始在《inside》當中看到在之前沒有找到的type-info for Point呢,不過《inside》裏面說應該是在vtbl中的。還不得而知。

因而,如果我們調用pAn->breathe(),將會有fish bubble,而不是animal breathe。

再在animal An2 = fh;之後,因爲An2聲明爲一個對象,而不是指針,所以在fh給An賦值的時候發生默認的拷貝構造函數,(這是對象切割,詳見標示4)這其中並不涉及虛表vtbl的拷貝,所以An2還是用的是animal類的虛表(如果fh有name,會把name複製給它,但也僅限於此)。


從內存中,我們也可以看出,An2和An1的虛表是一樣的。這也是印證了之前的標誌3。

所以對An2.breathe()的結果會是animal brethe而不是fish bubble。

程序的結果如下:


標示4:關於對象切割,可以參考:http://blog.csdn.net/xd1103121507/article/details/7266863

在我的栗子中,animal An2 = fh;發生了對象切割,造成An2用的是animal的虛表;

而animal pAn = &fh;不是對象切割,因爲pAn用的fish的虛表,因而是多態。

另外一個還要注意對象切割的地方就是以對象作爲參數時,用reference to const替換pass by value可避免對象切割。

總結:

C++的多態,是在每個類中維護一個虛表,只有在基類中用virtual聲明的函數纔會被加入虛表中,子類如果沒有重寫或覆蓋(override)基類的虛函數,則子類的虛表中指針還是指向父類的對應虛函數地址,如果子類中重寫了基類的虛函數,那麼子類的虛表中對應項會指向新的函數地址,通過給子類類型指針賦值給父類類型指針,後者作爲參數,在編譯期間並不知道自己到底會調用哪個子類虛表中的函數,只有當參數確定下來之後,它才進行編譯時綁定,根據參數的類型去尋找對應類中的虛表,才能確定對應的函數,這就是多態性和延遲綁定。

P.S:

爲什麼構造函數不能是虛函數,而析構函數一般都需要是虛函數呢?

因爲虛表的建立是在構造函數的過程中的,所以我們不能設置構造函數爲虛函數。因而構造函數的部分子類一般在自己的構造函數中再調用一下基類的構造函數;並且新建對象時你是明確對象的類型的,也不需要用多態的概念。

而析構函數設置爲虛函數是可行的也是必要的,可行是因爲析構函數也是類中的一個函數,並且在賦值過程後你可能有着一個基類卻不能明確它的對象類型,這個時候就可以用虛函數的多態來應對子類中新的成員的析構部分。


——Apie陳小旭




發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章