程序師世界是廣大編程愛好者互助、分享、學習的平台,程序師世界有你更精彩!
首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫知識
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
 程式師世界 >> 編程語言 >> C語言 >> C++ >> 關於C++ >> C++多態深度剖析

C++多態深度剖析

編輯:關於C++

 

測試環境:

Target: x86_64-linux-gnu

gcc version 5.3.1 20160413 (Ubuntu 5.3.1-14ubuntu2.1)

 

什麼是多態?

多態一詞最初來源於希臘語,意思是具有多種形式或形態的情形,當然這只是字面意思,它在C++語言中多態有著更廣泛的含義。

這要先從對象的類型說起!對象的類型有兩種:

  

\

舉個栗子:Derived1類和Derived2類繼承Base類

 

class Base
{};

class Derived1 : public Base
{};

class Derived2 : public Base
{};

int main()
{
	Derived1 pd1 = new Derived1; //pd1的靜態類型為Derived1,動態類型為Derived1
	Base *pb = pd1; //pb的靜態類型為Base,動態類型現在為Derived1
	Derived2 pd2 = new Derived2; //pd2的靜態類型為Derived2,動態類型現在為Derived2
	pb = pd2; //pb的靜態類型為Base,動態類型現在為Derived2

	return 0;
}
對象有靜態類型,也有動態類型,這就是一種類型的多態。

 

 

多態分類

多態有靜態多態,也有動態多態,靜態多態,比如函數重載,能夠在編譯器確定應該調用哪個函數;動態多態,比如繼承加虛函數的方式(與對象的動態類型緊密聯系,後面詳解),通過對象調用的虛函數是哪個是在運行時才能確定的。

\

【靜態多態】

栗子:函數重載

 

long long Add(int left, int right)
{
	return left + right;
}

double Add(float left, float right)
{
	return left + right;
}

int main()
{
	cout<運行結果:

 

\

編譯器在編譯期間完成的,編譯器根據函數實參的類型(可能會進行隱式類型轉換),即可推斷出要調用哪個函數,如果有對應的函數就調用該函數,否則出現編譯錯誤。

【動態多態】

進入動態多態前,先看一個普通繼承的栗子:

 

class Person
{
public:
	void GoToWashRoom()
	{
		cout<<"Person-->?"<GoToWashRoom();	//調用基類Person類的函數
	man.GoToWashRoom();	//調用派生類Man類的函數 
	pm->GoToWashRoom();	//調用派生類Man類的函數 
	woman.GoToWashRoom();	//調用派生類Woman類的函數 
	pw->GoToWashRoom();	//調用派生類Woman類的函數 
	
	//第二組
	pp = &man;
	pp->GoToWashRoom();	//調用基類Person類的函數
	pp = &woman;
	pp->GoToWashRoom();	//調用基類Person類的函數
	
	return 0;
}
運行結果:

 

\

第一組毫無疑問,通過本類對象和本類對象的指針就是調用本類的函數;第二組中先讓基類指針指向子類對象,然後調用該函數,調用的是子類的,後讓基類指針指向另一個子類對象,調用的是子類的函數。這是因為p的類型是一個基類的指針類型,那麼在p看來,它指向的就是一個基類對象,所以調用了基類函數。就像一個int型的指針,不論它指向哪,讀出來的都是一個整型(在沒有崩潰的前提下),即使將它指向一個float。再來對比著看下一個栗子。

栗子:繼承+虛函數

 

class Person
{
public:
	virtual void GoToWashRoom() = 0;
};

class Man : public Person
{
public:
	virtual void GoToWashRoom()
	{
		cout<<"Man-->Please Left"<GoToWashRoom();
		delete p;
		sleep(1);
	}

	return 0;
}
運行結果:

 

\

就像上邊這個栗子所演示的那樣,通過重寫虛函數(不再是普通的成員函數,是虛函數!),實現了動態綁定,即在程序執行期間(非編譯期)判斷所引用對象的實際類型,根據其實際類型調用相應的方法。使用virtual關鍵字修飾函數時,指明該函數為虛函數(在栗子中為純虛函數),派生類需要重新實現,編譯器將實現動態綁定。在上邊栗子中,當指針p指向Man類的對象時,調用了Man類自己的函數,p指向Woman類對象時,調用了Woman類字幾的函數。

今天的重點來了,就是要分析這個動態綁定實現的原理(以下測試在VS2013環境下進行):

為了方便調試,我將程序修改如下:

 

class Person
{
public:
	void GoToWashRoom()
	{};
};

class Man : public Person
{
public:
	void GoToWashRoom()
	{
		cout << "Man-->Please Left" << endl;
	}
};

int main()
{
	cout << sizeof(Person) << endl;
	cout << sizeof(Person) << endl;
	return 0;
}
先求一下兩個普通的繼承類的大小,在這裡為空類,沒有包含成員變量,所以為1,表示占位:

 

\

假如基類中包含一個int型變量,那麼這裡的大小都會是4。這不是今天的重點,不再敘說。主要是想看看普通空類的大小。

再改一下程序,在基類的成員函數前加virtual關鍵字,將這個函數變為虛函數。

 

class Person
{
public:
	virtual void GoToWashRoom()
	{};
};
其它部分代碼不變,運行結果:

 

\
大小變成了4.所以這個類裡面肯定是有什麼東西的。

在main中加入以下代碼:

Man man;

Person *p = &man;
p->GoToWashRoom();

查看監視窗口:

\

man對象裡存在了一個指針,而且是void**類型的,那麼這個指針指向哪呢?可以在內存中查看一下這個地址所在內存中的內容。

\

是一個可能是地址的東西,然後下邊是一排的 00 00 00 00,貌似相當於NULL。繼續看一下這個類似於地址的東西裡面又是什麼:

\

嗯,看不懂,不要緊,把這個數字記下來:0x01 27 13 de

繼續運行程序,轉到反匯編:

\

這兩句取到man的首地址然後通過eax寄存器賦值給p,這樣p就指向了man對象。目前對象模型是這樣的:

\

同時,_vfptr的值為0x01 27 13 de。接下來就要准備調用函數了。

\

一句句分析:

01276036 mov eax,dword ptr [p] //從p所指位置取4個字節內容放到eax,其實就是取的man對象的地址:0x008BFC1C
01276039 mov edx,dword ptr [eax] //從eax所指位置取4個字節內容放到edx,就是我們之前看到的不明變量_vfptr的值:0x0127dc80
0127603B mov esi,esp //這句先不用管,esp是棧頂指針
0127603D mov ecx,dword ptr [p] //將對象地址給ecx也保存了一份,此時ecx和eax放的都是對象地址(其實這句就是調用函數之前通過ecx傳遞this指針)
01276040 mov eax,dword ptr [edx] //取對象前四個字節給eax,eax和edx都變成了_vfptr的值。
01276042 call eax //調用函數,函數地址在eax中,說明_vfptr指向的內容是函數的地址
01276044 cmp esi,esp
01276046 call __RTC_CheckEsp (01271334h)
到call這條語句跟進去看一下:

\

這下明白了吧,其實_vfptr存放的內容就是存放函數地址的地址,即_vfptr指向函數地址所在的內存空間,如圖:

\

分析暫告一段落。我們知道了man對象中維護了一個虛表指針,虛表中存放著虛函數的地址。基於這個虛表指針,實現動態綁定,才可以用基類指針調用了Man類中的虛函數。因為指針p看到的是虛表的指針,它調用的虛函數是從虛表中查找的。如果基類中有多個虛函數的話,那麼虛表中也會依次按基類中虛函數定義順序存放虛函數的地址,並以0x 00 00 00 00 結尾。再如果子類中有自己定義的新的虛函數,那麼會排在虛函數表的後邊。在調用虛函數時由編譯器自動計算偏移取得相應的虛函數地址。

看看是如何構造子類對象的:

 

class Person
{
public:
	Person()
	{
		cout << "Person()" << endl;
	}
	~Person()
	{
		cout << "~Person()" << endl;
	}

	virtual void GoToWashRoom()
	{};
};

class Man : public Person
{
public:
	Man()
	{
		cout << "Man()" << endl;
	}
	~Man()
	{
		cout << "~Man()" << endl;
	}

	void GoToWashRoom()
	{
		cout << "Man-->Please Left" << endl;
	}
};

int main()
{
	Man man;

	return 0;
}
運行結果:

\

先調用基類構造函數,虛表指針先指向基類虛表,然後調用子類構造函數,這時候子類對象的虛表指針就指向了子類自己的虛表。這才是動態綁定實現原理。詳細內容可以查看反匯編,這裡不再寫了。

再舉個栗子看看虛表指針在對象的首部還是尾部,又或者是在中間某個地方存放:

 

class Person
{
public:
	virtual void GoToWashRoom()
	{};
public:
	int i1;
	int i2;
	int i3;
};

class Man : public Person
{
	void GoToWashRoom()
	{
		cout << "Man-->Please Left" << endl;
	}
};

int main()
{
	Man man;
	man.i1 = 0x01;
	man.i2 = 0x02;
	man.i3 = 0x03;

	return 0;
}
查看內存中:&man

 

\

可以看出,虛表指針位於對象的首部。

【動態綁定條件】

必須是虛函數通過基類類型的引用或者指針調用

總結

派生類重寫基類的虛函數實現多態,要求函數名、參數列表、返回值完全相同。(協變除外)基類中定義了虛函數,在派生類中該函數始終保持虛函數的特性只有類的成員函數才能定義為虛函數,靜態成員函數不能定義為虛函數如果在類外定義虛函數,只能在聲明函數時加virtual關鍵字,定義時不用加構造函數不能定義為虛函數,雖然可以將operator=定義為虛函數,但最好不要這麼做,使用時容 易混淆不要在構造函數和析構函數中調用虛函數,在構造函數和析構函數中,對象是不完整的,可能會 出現未定義的行為最好將基類的析構函數聲明為虛函數。(析構函數比較特殊,因為派生類的析構函數跟基類的析構 函數名稱不一樣,但是構成覆蓋,這裡編譯器做了特殊處理)
虛表是所有類對象實例共用的

容易混淆的點:

\

 

  1. 上一頁:
  2. 下一頁:
Copyright © 程式師世界 All Rights Reserved