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

C++之多態

編輯:關於C++

眾所周知,在20世紀80年代早期,C++在貝爾實驗室誕生了,這是一門面向對象的語言,但它又不是全新的面向對象的語言,它是在傳統的語言(C語言)進行面向對象擴展而來,但是它有些地方與C語言又有很多區別,又添加了很多C語言原來沒有的內容與概念,所以有些地方是需要花時間去深入了解的。雖然這兩者有密切關系,但是即使你很熟悉C語言,想要熟悉C++(先不說熟練掌握或者精通C++),還是得花很大一番功夫,光光是C++之中的三大特性就夠研究好久的了。所以說學習C++的過程是一個漫長的過程,如果將來從事和C++(或者說其它任何一門優秀的語言)有關的工作,那麼對語言的學習可能會貫穿一生,語言是一直發展而來的,所以說要想成為一個優秀的程序員,那麼養成每天都學習一些新知識的習慣很重要,我從來都覺得一個人的習慣很重要,養成了每天學習的習慣,那麼有一天突然沒有學習,你會有一種今天有什麼重要任務沒有完成一樣。但凡那些優秀的人,從來都有一個好的習慣,對於這一點我堅信不疑。好了,其它的也不多說了,千裡之行,始於足下。

對於C++之中多態我准備分三個層次講(當然這是我我理解上的三個層次,實際上這裡面的內容遠比我說的要多得多,也更深得多,我是由淺入深,根據所需要的挑著看吧,當然這裡的深也只是相對的,如果說C++是一片海的話,那我也只能說我只是見識過淺灘上的風景,但是真正海底的神秘我還沒有去研究,希望將來有機會可以研究到),如果只是單純的想了解一下,就沒有必要整篇都看。前幾天寫了在C++的學習過程中關於繼承方面的一些知識,今天就寫一寫關於剩下的特性:多態(封裝性需要說的內容比較少,所以就沒有寫)

如果你只是想了解一下C++之中關於多態的知識,那麼第一部分有你要的答案。如果你原本就對C++之中多態有了解,但是對是想了解內層的實現,那麼最後一部分會滿足你的好奇心!
 

按照我的習慣總是先從定義入手,當然這第一層次肯定了解定義是必須的。首先來看一下多態的定義:多態性的英文單詞polymorphism來源於希臘詞根poly(意為“很多”)和morph(意為“形態”),意思是具有多種形式或形態的情形,在C++語言中多態有著更廣泛的含義。在C++之中多態的定義:多態性是指具有不同功能的函數可以用同一個函數名,這樣就可以用一個函數名調用不同內容的函數。在面向對象方法中一般是這樣表述多態性的:向不同的對象發送同一個消息, 不同的對象在接收時會產生不同的行為(即方法)。借用網上的一個牛人對於C++之中多態總結的較好的三句話:一、“相同函數名”,二:“依據上下文”,三:“實現卻不同”。個人感覺這三句話總結地很到位,在這裡我就盜用一下。
既然這是第一層次,所以在這裡有必要先說一下關於C++之中對象的類型,先再看一張圖:

\

\

在了解了對象的類型之後有助於你更好地理解之後的內容。

好了,說完定義之後,我們肯定要來說一說多態的分類了,還是以圖的形式給出,這樣比較直觀,也便於大家去理解:

\

接下來我們就來逐個分析一下:

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

其實我們在之前學C++過程之中,使用函數重載的時候就已經用到了多態,只不過這是靜態多態,如果當時不知道,可能你只是沒有注意或者說沒往多態這方面想而已。以下內容就是一個靜態多態。

int My_Add(int left, int right)
{
	return (left + right);
}

float My_Add(float left, float right)
{
	return (left + right);
}
int main()
{
	cout << My_Add(10, 20) << endl;
	cout << My_Add(12.34f, 24.68f) << endl;
	return 0;
}
看完靜態多態之後,我們就來看一看動態多態,在沒有特殊說明的情況之下,我們所說的多態一般都說的是動態多態。當然在說這個之前先知道什麼時動態綁定:在程序執行期間(非編譯期)判斷所引用對象的實際類型,根據其實際類型調用相應的方法。
我們用一個例子來說明吧!先看例子,下面再來解釋:

 

 

class Airport                             //機場類
{
public:           
	void GoToT1()          
	{ 
		cout << "You want to T1 air terminal--->Please Left" << endl;       //T1候機樓
	}			  
	void GotoT2()          
	{ 
		cout << "You want to T2 air terminal--->Please Right" << endl;      //T2候機樓
	}
};

class CPerson                            //人類
{ 
public:           
	virtual void GoToTerminal(Airport & _terminal) = 0; 
};

class Passage_A :public CPerson          //乘客A類(這類乘客需要去T1候機樓登機)
{ 
public:           
	virtual void GoToTerminal(Airport & _terminal)
	{
		_terminal.GoToT1();
	}
};

class Passage_B :public CPerson          //乘客B類(這類乘客需要去T2候機樓登機)
{
public:           
	virtual void GoToTerminal(Airport & _terminal)
	{
		_terminal.GotoT2();
	}
};

void FunTest() 
{
	Airport terminal;                                //創建機場類對象
	for (int iIdx = 1; iIdx <= 10; ++iIdx)           
	{
		CPerson* pPerson;                    
		int iPerson = rand() % iIdx;            //設置隨機值,這樣可以使下面生成不同的子類對象       
		if (iPerson & 0x01)                  
		{ 
			pPerson = new Passage_A; 
		}
		else                  
		{ 
			pPerson = new Passage_B;
		}
		pPerson->GoToTerminal(terminal);
		delete pPerson;                  
		pPerson = NULL;                  
		Sleep(1000);                            //每次休息1秒後繼續輸出
	}
}

int main()
{
	FunTest();
	return 0;
}

在這裡簡單說明一下,上面代碼首先有一個機場類,裡面有兩個方法,表示兩個候機樓在不同地方,你需要左拐還是右拐。接下來就是一個人類,裡面只有一個純虛函數,因此這個人類就是一個抽象類。把這個抽象類作為基類,又派生出兩個子類。在子類之中把父類之中的純虛函數重新定義了(這也是必須的),(之前我在關於繼承那一篇文章之中說過菱形繼承之中就是利用虛擬繼承解決數據二義性問題),也就是說這裡不存在二義性問題,但是這裡似乎更加具體,直接在子類中把父類之中的虛函數(帶有virtual關鍵字修飾的函數)重寫了(基類之中是一個純虛函數,派生類必須要重寫它才可以實例化對象)。

簡單說一下重寫(或者說是覆蓋,兩者意思一樣):如果兩個成員函數處在不同的作用域之中(上面的例子是一個在父類,一個在子類),父類之中有virtual關鍵字修飾(這一點是必須的),而且它們的的參數相同,返回值類型也相同(這裡有必要說明一中特殊情況就是協變,協變之中返回值類型可以不同),那麼這樣的兩個成員函數就構成了重載。(派生類中的這個函數有沒有virtual關鍵字修飾無所謂,可加可以不加,因為即使是派生類之中的這個函數,它本質上也還是一個虛函數)。

由於派生類之中對父類的純虛函數進行了重寫,因此我們可以說上述代碼實現了動態綁定。使用virtual關鍵字修飾函數時,指明該函數為虛函數(在上面例子中為純虛函數),派生類需要重新實現,編譯器將實現動態綁定。

 

在使用對象的指針的時候要千萬注意一下,如下面的例子,沒有實現動態綁定。

 

 int main()  
{  
    Person  *p;  
    Man  *pm;  
    Woman *pw;  
    p = &man;     //如果你去調用其中的Man類中方法(基類之中也有同名方法),那麼它會調用基類之中的方法
    p = &woman;   //這是因為p的類型是一個基類的指針類型,那麼在p看來,它指向的就是一個基類對象,
                   //所以調用了基類函數。
}
最後看一下動態綁定的條件:

 

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

到這裡簡單地總結一下動態多態:動態多態性是在程序運行過程中才動態地確定操作所針對的對象。它又稱運行時的多態性(動態多態性是通過虛函數(Virtual fiinction)實現的)。其實到了這裡,對於多態這一塊內容你已經有了初步的了解了,如果你只是想初步了解一下,已經足夠了。但是可能還會有一些問題,不過也影響不大,畢竟只是初步認識一下,至少你明白了多態的概念以及使用上的一些注意點。如果你想搞清楚更深一層的一些問題,你可以繼續閱讀。

 

 

在這一層次上我們對虛函數,純虛函數作進一步了解,動態綁定原理是什麼?然後進入一個大內容---虛表的概念。

在上一個內容上我們討論過了關於動態綁定的概念,這時候可能會有疑問,動態綁定是如何實現的呢?那麼接下來我們就來說一說裡面原理性的內容。

再解釋之前我們還是先來理一理一些概念,這樣可以更容易理解裡面的內容,先做好准備工作。

首先是關於重載、重寫(也可以叫做覆蓋)、隱藏(或者說是重定義)這三者的有關內容,如圖所示:

\

接下來我們再來說一說上面提到過的幾個概念,上面沒有展開講,這裡仔細講一講:

純虛函數

在成員函數的形參後面寫上=0,則成員函數為純虛函數。包含純虛函數的類叫做抽象類(也叫接口類),抽象類不能實例化出對象(雖然抽象類不能實例化對象,但是可以定義抽像類的指針)。純虛函數在派生類中重新定義以後,派生類才能實例化出對象。如下面代碼中:

 

class Person 
{     
     virtual void Display () = 0;   // 純虛函數 protected :     
     string _name ;                 // 姓名
};
class Student : public Person 
{
    //在這類裡面必須要對上面的Display ()方法重新定義以後Student才可以實力化對象
};

協 變

 

在C++中,只要原來的返回類型是指向類的指針或引用,新的返回類型是指向派生類的指針或引用,覆蓋的方法就可以改變返回類型。這樣的類型稱為協變返回類型(Covariant returns type)。如以下的兩個函數就構成協變。當然協變也算一種覆蓋。

 

class Base 
{     
    Base * FunTest()
    {
        //do something
    }
};
class Derived : public Base
{
    
Derived * FunTest() { //do something } }; 

 

有了上面這些鋪墊之後我們就可以開始一個比較重要的內容:關於虛表的相關知識。先來以下代碼:

class CTest
{ public:       
	CTest()
	{ 
		iTest = 10; 
	}       
	/*virtual */~CTest(){}; 
private:       
	int iTest; 
};
int main() 
{ 
	cout << sizeof(CTest) << endl;       
	return 0; 
}
很容易的得到結果是4,但是如果將裡面的注釋內容,也就是virtual關鍵字放開,那麼結果又會是多少呢?知道的人會覺得這是送分題,但是不知道的人卻一臉茫然,答案是8,所以我們猜想,一定是編譯器對有virtual成員函數的類做了特殊處理。

 

先說個大概吧,簡單地說就是:對於有虛函數的類,編譯器都會維護一張虛表,對象的前四個字節就是指向虛表的指針。這也就可以解釋為什麼上面那個例子的原因了。

當然我們的問題才剛剛開始,請仔細看下面一張圖,可能會解決你的一些疑惑!

\

我們來分析一下上面的內容。我們在在監視窗口之中對對象test取地址,發現雖然類中只有一個數據成員,但是發現另一個內容,其實它就是一個虛表指針,觀察它的類型,發現它裡面放著類似於地址的內容,但是我們可以在內存之中去查看一下它的內容。內存之中,虛表指針和數據成員是連著一起存放的,所以這個虛表指針一定是有什麼作用的。我們再打開一個內存窗口,觀察一下這個地址的所指向內容裡面到底是什麼。通過上面的圖我們可以發現,其實這裡面放的還是一個地址,突然間又有些疑惑了,我們可以轉到反匯編去看一看,我們用virtual修飾的析構函數的入口地址就是剛剛我們看到的地址。在根據反匯編我們似乎就明白了什麼。(細心的你會發現其實每個虛表的最下面總是放的是0x00000000,這也想相當於一個結束標志吧!)

這時候,我們可以再來梳理一下,其實_vfptr存放的內容就是存放函數地址的地址,即_vfptr指向函數地址所在的內存空間,我們可以用圖來表示:

\

到現在為止,你對虛表以及虛表指針這些概念應該已經不陌生了,來總結一下就是:

test對象中維護了一個虛表指針,虛表中存放著虛函數的地址。對test對象取地址可以看到的是虛表的指針以及它的其它成員變量,這個對象又是如何調用類中虛函數的呢?其實調用的虛函數是從虛表中查找的。如果基類中有多個虛函數的話,那麼虛表中也會依次按基類中虛函數定義順序存放虛函數的地址,並以0x 00 00 00 00 結尾。再如果子類中有自己定義的新的虛函數,那麼會排在虛函數表的後邊。在調用虛函數時由編譯器自動計算偏移取得相應的虛函數地址。

說到這裡,第二層次的內容主要也講完了,不過還有關於一些關於基類與派生類之間是如何利用這個虛表指針的,以及如果函數之中存在覆蓋或者說沒有覆蓋,那麼這虛函數表是否還是一樣的呢?我把放到下一個內容,感興趣的話可以接著往下看。

 

如果你堅持看了下來,那麼這一部分可能會有些復雜,在這一部分主要是對虛表作進一步剖析,也就是上面遺留下來的內容,以及復雜一點的帶有虛函數多繼承對象模型剖析。

一個一個來說明,首先是上面遺留的內容:就是基類與派生類之間是如何利用這個虛表指針的呢?其實這個內容還是需要分為兩部分來講:一、基類與派生類不存在函數的覆蓋;二、基類與派生類之間存在函數的覆蓋。

第一種情況比較簡單,在這裡我主要用文字說明一下,這樣我們可以將重點放在第二種上面。

第一種情況(不存在成員函數覆蓋):先調用基類構造函數,虛表指針先指向基類虛表,然後調用子類構造函數,子類之中也有虛表指針,而且不是同一個,其實子類在構建起來之前的時候,這個虛表指針指向的是基類的虛表,但是當子類構建出來的時候,虛表指針馬上發生變化,指向了一個新空間,這個新空間裡面存放了基類的虛函數以及派生類自己的虛函數,在最後放入0x00000000,子類自己的虛表也就構建完成了。(這裡說明一下:1、虛函數按照其聲明的順序存在於虛表之中。2、在派生類的虛表之中,前面是基類的虛函數,後面是派生類的虛函數)。

接著轉入第二種(存在成員函數覆蓋):以下面代碼分析(結合後面的圖)

 

class CBase 
{
public:           
	virtual void FunTest0()
	{ 
		cout << "CBase::FunTest0()" << endl;
	}           
	virtual void FunTest1()
	{
		cout << "CBase::FunTest1()" << endl; 
	}           
	virtual void FunTest2()
	{ 
		cout << "CBase::FunTest2()" << endl;
	}          
	virtual void FunTest3()
	{ 
		cout << "CBase::FunTest3()" << endl; 
	} 
};

class CDerived :public CBase 
{ 
public:           
	virtual void FunTest0()
	{ 
		cout << "CDerived::FunTest0()" << endl;
	}           
	virtual void FunTest1()
	{ 
		cout << "CDerived::FunTest1()" << endl;
	}          
	virtual void FunTest4()
	{ 
		cout << "CDerived::FunTest4()" << endl; 
	}           
	virtual void FunTest5()
	{ 
		cout << "CDerived::FunTest5()" << endl; 
	} 
};

typedef void(*_pFunTest)();
void FunTest() {
	CBase base;           
	for (int iIdx = 0; iIdx < 4; ++iIdx)          
	{
		_pFunTest  pFunTest = (_pFunTest)(*((int*)*(int *)&base + iIdx));                  
		pFunTest();
	}
	cout << endl;          
	CDerived derived;           
	for (int iIdx = 0; iIdx < 6; ++iIdx)         
	{ 
		_pFunTest  pFunTest = (_pFunTest)(*((int*)*(int *)&derived + iIdx));                   
		pFunTest(); 
	}
}
void TestVirtual() 
{ 
	CBase base0;         
	CDerived derived;          
	CBase& base1 = derived; 
}

int main() 
{
	FunTest();         
	TestVirtual();          
	return 0; 
}
\

 

說到這裡大部分內容都已經結束了,好了,也是時候總結一下了:

 

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

 

還有一個關於多重繼承之下的虛表指針的情況分析了,這種情況就比較復雜了,在這裡同樣也要分有無虛函數的覆蓋這兩種情況來分析。在這裡我只簡單地說一下有虛函數重載的情況,有興趣的下來可以自己去試一試。

 

class CBase0 
{ 
public:          
	CBase0()
	{
		m_iTest = 0xA0; 
	}          
	virtual void Print()
	{ 
		cout << "m_iTest = " << hex << m_iTest << "  CBase2::Print()" << endl; 
	}          
	int m_iTest; 
};

class CBase1 
{ 
public:         
	CBase1()
	{ 
		m_iTest = 0xB0;
	}          
	virtual void Print()
	{ 
		cout << "m_iTest = " << hex << m_iTest << "  CBase2::Print()" << endl; 
	}           
	int m_iTest; 
};

class CBase2
{
public:         
	CBase2()
	{ 
		m_iTest = 0xC0;
	}           
	virtual void Print()
	{ 
		cout << "m_iTest = " << hex << m_iTest << "  CBase2::Print()" << endl; 
	}          
	int m_iTest;
};

class CDerived :public CBase0, public CBase1, public CBase2 
{ 
public:          
	CDerived()
	{
		m_iTest = 0xD0; 
	}           
	virtual void Print()
	{ 
		cout << "m_iTest = " << hex << m_iTest << "  CDerived::Print()" << endl; 
	}           
	int m_iTest; 
};

void FunTest() 
{ 
	CDerived derived;          
	cout << sizeof(derived) << endl;          
	CBase0& base0 = derived;          
	base0.Print();         
	CBase1& base1 = derived;         
	base1.Print();          
	CBase2& base2 = derived;
	base2.Print();         
	derived.Print();
}

int main()
{
	FunTest();
	return 0;
}
大家可以分析一下FunTest()函數會打印什麼,以及Derived的內存布局是怎麼樣的呢?我給大家一張圖,大家下來自行分析理解一下:

 

\

其實如果你有興趣,還可以自己剖析下菱形虛擬繼承!!!不過肯定會有一些復雜。

寫到這裡,說實話真的挺累的,終於可以歇一歇了,O(∩_∩)O

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