C++中多態的定義及實現詳解

1. 多態概念

1.1 概念

  • 多態的概念:通俗來說,就是多種形態,具體點就是去完成某個行為,當不同的對象去完成時會產生出不同的狀態。
  • 舉個栗子:比如買票,當普通人買票時,是全價買票;學生買票時,是半價買票;軍人買票時是優先買票。同一個事情針對不同的人或情況有不同的結果或形態。

2. 多態的定義及實現

2.1 多態的構成條件

多態是在不同繼承關系的類對象,去調用同一函數,產生瞭不同的行為。比如Student繼承瞭Person。

Person對象買票全價,Student對象買票半價。

註意:那麼在繼承中要構成多態還有兩個條件:

  • 必須通過基類的指針或者引用調用虛函數。
  • 被調用的函數必須是**虛函數,且派生類必須對基類的虛函數進行重寫。

2.2 虛函數

虛函數:即被virtual修飾的類成員函數稱為虛函數。

class Person {
public:
	virtual void BuyTicket() { cout << "買票-全價" << endl;}
};

2.3 虛函數的重寫

  • 虛函數的重寫(覆蓋):派生類中有一個跟基類完全相同的虛函數(即派生類虛函數與基類虛函數的返回值類型、函數名字、參數列表完全相同),稱子類的虛函數重寫瞭基類的虛函數。

註意:

  • 在重寫基類虛函數時,派生類的虛函數在不加virtual關鍵字時,雖然也可以構成重寫(因為繼承後基類的虛函數被繼承下來瞭在派生類依舊保持虛函數屬性),但是該種寫法不是很規范,不建議這樣使用。
class Person {
public:
	virtual void BuyTicket() { cout << "買票-全價" << endl; }
};
class Student : public Person {
public:
	void BuyTicket() { cout << "買票-半價" << endl; }
};

2.4 代碼示例

2.4.1 沒構成重寫

2.4.2 構成重寫

2.5 虛函數重寫的兩個例外

 2.5.1 協變

派生類重寫基類虛函數時,與基類虛函數返回值類型不同。即基類虛函數返回基類對象的指針或者引用,派生類虛函數返回派生類對象的指針或者引用時,稱為協變。

 2.5.2 析構函數的重寫

如果基類的析構函數為虛函數,此時派生類析構函數隻要定義,無論是否加virtual關鍵字,都與基類的析構函數構成重寫,即使基類與派生類析構函數名字不同。雖然函數名不相同,看起來違背瞭重寫的規則,其實不然,這裡可以理解為編譯器對析構函數的名稱做瞭特殊處理,編譯後析構函數的名稱統一處理成destructor

註意:析構函數在編譯以後函數名會統一成destructor。如果不加virtual則會造成重定義(隱藏),如上圖代碼如果不構成析構函數的重寫,則在析構p2時隻會析構Student,因為此時成瞭重定義所以不會自動調用Person裡面的析構,造成資源泄漏。

2.6 C++11 override 和 final

  • C++對函數重寫的要求比較嚴格,但是有些情況下由於疏忽,可能會導致函數名字母次序寫反而無法構成重載,而這種錯誤在編譯期間是不會報出的,隻有在程序運行時沒有得到預期結果才來debug會得不償失。
  • 因此:C++11提供瞭override和final兩個關鍵字,可以幫助用戶檢測是否重寫。

1. final:修飾虛函數,表示該虛函數不能再被繼承

2. override: 檢查派生類虛函數是否重寫瞭基類某個虛函數,如果沒有重寫編譯報錯。

2.7 重載、覆蓋(重寫)、隱藏(重定義)的對比

3. 抽象類

3.1 概念

在虛函數的後面寫上 =0 ,則這個函數為純虛函數。包含純虛函數的類叫做抽象類(也叫接口類),抽象類不能實例化出對象。派生類繼承後也不能實例化出對象,隻有重寫純虛函數,派生類才能實例化出對象純虛函數規范瞭派生類必須重寫,另外純虛函數更體現出瞭接口繼承。

 

3.2 接口繼承和實現繼承

  • 普通函數的繼承是一種實現繼承,派生類繼承瞭基類函數,可以使用函數,繼承的是函數的實現。
  • 虛函數的繼承是一種接口繼承,派生類繼承的是基類虛函數的接口,目的是為瞭重寫,達成多態,繼承的是接口。所以如果不實現多態,不要把函數定義成虛函數。

4.多態的原理

4.1虛函數表

通過觀察測試我們發現b對象是8bytes,除瞭_b成員,還多一個__vfptr放在對象的前面(註意有些平臺可能會放到對象的最後面,這個跟平臺有關),對象中的這個指針我們叫做虛函數表指針(v代表virtual,f代表function)。一個含有虛函數的類中都至少都有一個虛函數表指針,因為虛函數的地址要被放到虛函數表中,虛函數表也簡稱虛表。

虛函數表指針(簡稱虛表指針)

虛函數表本質是一個指針數組(指針是一個虛函數指針),(虛基表->菱形繼->存的偏移量)。

基類和派生類中的虛函數表。

總結:

  • 基類b對象和派生類d對象虛表是不一樣的,這裡我們發現Func1完成瞭重寫,所以d的虛表中存的是重寫的Derive::Func1,所以虛函數的重寫也叫作覆蓋,覆蓋就是指虛表中虛函數的覆蓋。重寫是語法的叫法,覆蓋是原理層的叫法。
  • 另外Func2繼承下來後是虛函數,所以放進瞭虛表,Func3也繼承下來瞭,但是不是虛函數,所以不會放進虛表。
  • 虛函數表本質是一個存虛函數指針的指針數組,這個數組最後面放瞭一個nullptr。
  • 派生類的虛表生成:
    a.先將基類中的虛表內容拷貝一份到派生類虛表中
    b.如果派生類重寫瞭基類中某個虛函數,用派生類自己的虛函數覆蓋虛表中基類的虛函數
    c.派生類自己新增加的虛函數按其在派生類中的聲明次序增加到派生類虛表的最後。
  • 虛函數存在哪的?虛表存在哪的?
  • 虛函數在代碼段,虛函數表也在代碼段。虛函數表中存放的是虛函數地址。對象裡面是虛函數表的指針。

4.2多態的原理

總結

  • 這樣就實現出瞭不同對象去完成同一行為時,展現出不同的形態。
  • 反過來思考我們要達到多態,有兩個條件,一個是虛函數覆蓋,一個是對象的指針或引用調用虛函數?
    虛函數覆蓋是為瞭構成多態時不同的對象通過調用對應類中的虛函數表時通過虛函數的地址去找到對應的虛函數。而指針或者引用是因為在找對應的虛函數時是在虛函數表中通過地址查找的。
  • 通過匯編代碼分析,看出滿足多態以後的函數調用,不是在編譯時確定的,是運行起來以後到對象中去找的。不滿足多態的函數調用時編譯時確認好的。即不滿足多態的函數地址編譯時已經確定,而構成多態的虛函數在運行時會通過地址去call。

4.3 動態綁定與靜態綁定

  1. 靜態綁定又稱為前期綁定(早綁定),在程序編譯期間確定瞭程序的行為,也稱為靜態多態,比如:函數重載。
  2. 動態綁定又稱後期綁定(晚綁定),是在程序運行期間,根據具體拿到的類型確定程序的具體行為,調用具體的函數,也稱為動態多態。

5.單繼承和多繼承關系的虛函數表

5.1 單繼承中的虛函數表

通過代碼打印出虛表中的函數:

#include<iostream>
using namespace std;

class Base {
public:
	virtual void func1() { cout << "Base::func1" << endl; }
	virtual void func2() { cout << "Base::func2" << endl; }
private:
	int a;
};
class Derive :public Base {
public:
	virtual void func1() { cout << "Derive::func1" << endl; }
	virtual void func3() { cout << "Derive::func3" << endl; }
	virtual void func4() { cout << "Derive::func4" << endl; }
private:
	int b;
};
typedef void(*VFPTR) ();
void PrintVTable(VFPTR vTable[])
{
	// 依次取虛表中的虛函數指針打印並調用。調用就可以看出存的是哪個函數
	cout << " 虛表地址>" << vTable << endl;
	for (int i = 0; vTable[i] != nullptr; ++i)
	{
		printf(" 第%d個虛函數地址 :0X%x,->", i, vTable[i]);
		VFPTR f = vTable[i];
		f();
	}
	cout << endl;
}

int main()
{
	Base b;
	Derive d;
	// 思路:取出b、d對象的頭4bytes,就是虛表的指針,前面我們說瞭虛函數表本質是一個存虛函數指針的指針數組,這個數組最後面放瞭一個nullptr
	// 1.先取b的地址,強轉成一個int*的指針
	// 2.再解引用取值,就取到瞭b對象頭4bytes的值,這個值就是指向虛表的指針
	// 3.再強轉成VFPTR*,因為虛表就是一個存VFPTR類型(虛函數指針類型)的數組。
	// 4.虛表指針傳遞給PrintVTable進行打印虛表
	// 5.需要說明的是這個打印虛表的代碼經常會崩潰,因為編譯器有時對虛表的處理不幹凈,虛表最後面沒有放nullptr,導致越界,這是編譯器的問題。我們隻需要點目錄欄的 - 生成 - 清理解決方案,再編譯就好瞭。
	VFPTR * vTableb = (VFPTR*)(*(int*)&b);
	PrintVTable(vTableb);
	VFPTR* vTabled = (VFPTR*)(*(int*)&d);
	PrintVTable(vTabled);
	return 0;
}

5.2 多繼承中的虛函數表

#include<iostream>
using namespace std;

class Base1 {
public:
	virtual void func1() { cout << "Base1::func1" << endl; }
	virtual void func2() { cout << "Base1::func2" << endl; }
private:
	int b1;
};
class Base2 {
public:
	virtual void func1() { cout << "Base2::func1" << endl; }
	virtual void func2() { cout << "Base2::func2" << endl; }
private:
	int b2;
};
class Derive : public Base1, public Base2 {
public:
	virtual void func1() { cout << "Derive::func1" << endl; }
	virtual void func3() { cout << "Derive::func3" << endl; }
private:
	int d1;
};
typedef void(*VFPTR) ();
void PrintVTable(VFPTR vTable[])
{
	cout << " 虛表地址>" << vTable << endl;
	for (int i = 0; vTable[i] != nullptr; ++i)
	{
		printf(" 第%d個虛函數地址 :0X%x,->", i, vTable[i]);
		VFPTR f = vTable[i];
		f();
	}
	cout << endl;
}
int main()
{
	Derive d;
	VFPTR* vTableb1 = (VFPTR*)(*(int*)&d);
	PrintVTable(vTableb1);
	VFPTR* vTableb2 = (VFPTR*)(*(int*)((char*)&d + sizeof(Base1)));
	PrintVTable(vTableb2);
	return 0;
}

6. 相關題目

1. inline函數可以是虛函數嗎?

答:能,virtual函數可以寫成inline函數,不會造成語法錯誤。虛函數是在運行的時候才決定調用基類或者子類的對應函數,inline函數是在編譯期間來決定展開與否。虛函數是在運行的時候才決定調用基類或者子類的對應函數,inline函數是在編譯期間來決定展開與否。虛函數是在運行的時候才決定調用基類或者子類的對應函數,inline函數是在編譯期間來決定展開與否。

2. 靜態成員可以是虛函數嗎?

答:不能,因為靜態成員函數沒有this指針,使用類型::成員函數的調用方式無法訪問虛函數表,所以靜態成員函數無法放進虛函數表。

3. 構造函數可以是虛函數嗎?

答:不能,因為對象中的虛函數表指針是在構造函數初始化列表階段才初始化的。

4. 析構函數可以是虛函數嗎?什麼場景下析構函數是虛函數?

答:可以,並且最好把基類的析構函數定義成虛函數。

5. 對象訪問普通函數快還是虛函數更快?

答:首先如果是普通對象,是一樣快的。如果是指針對象或者是引用對象,則調用的普通函數快,因為構成多態,運行時調用虛函數需要到虛函數表中去查找。

6. 虛函數表是在什麼階段生成的,存在哪的?

答:虛函數表是在編譯階段就生成的,一般情況下存在代碼段(常量區)的。

總結

到此這篇關於C++中多態的定義及實現詳解的文章就介紹到這瞭,更多相關C++多態詳解內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!

推薦閱讀: