面向對象程序設計的三大特性(三):C++中的多態

前言

在我們系統的學習了封裝和繼承之後,那自然少不了面向對象程序設計的另一大特性——多態,C++中的多態又是怎樣的呢?下面帶大家來一起梳理C++中多態的相關知識。

1. 多態的概念

顧名思義,多態就是多種形態。比如,買票這個行爲,普通人買票時是全價買票,學生買票時是學生價買票,軍人買票時是優先買票。

多態:不同的對象去完成某個相同的行爲時會產生出不同的狀態。

  • 在繼承中要構成多態的兩個條件

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

2.被調用的函數必須是虛函數,且派生類必須對基類的虛函數進行重寫

#include<iostream>
using namespace std;

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

1. 虛函數的重寫
class Student : public Person {
public:
	virtual void BuyTicket() { cout << "買票-半價" << endl; }
};

2. 基類的指針或引用調用虛函數
void Func(Person& p){
	p.BuyTicket();
}

int main(){
	Person ps;
	Student st;
	Func(ps);
	Func(st);

	return 0;
}
運行結果:
	買票-全價
	買票-半價
  • 滿足多態的條件

跟調用對象的類型無關,和指向對象的類型有關。

指針或引用指向哪個類實例化出的對象,就調用這個類的虛函數。

  • 不滿足多態的條件

跟調用對象的類型有關。

哪個類的對象調用虛函數,就調用這個類的虛函數。

2. 虛函數

  • 虛函數的概念

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

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

注意:

內聯函數不能是虛函數,因爲內聯函數會直接在被調用的地方展開,沒有地址,不能放進虛表中。

static型函數不能是虛函數,因爲static型函數沒有this指針。虛函數是通過對象中的虛函數表指針去找到虛函數的地址。

  • 虛函數的重寫(覆蓋)

派生類中有一個跟基類完全相同的虛函數,即派生類虛函數與基類虛函數的返回值類型、函數名、參數列表完全相同。稱派生類的虛函數重寫了基類的虛函數。

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

注意:在重寫(覆蓋)基類的虛函數時,派生類的虛函數在不加virtual關鍵字時,雖然也能構成重寫(基類的虛函數被繼承到派生類中後仍然保持虛函數屬性),但這種寫法不是很規範,不建議使用。

  • 虛函數重寫的兩個例外

1. 協變(基類與派生類虛函數的返回值類型不同)

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

2. 析構函數的重寫

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

析構函數應當定義爲虛函數: 防止只析構基類而不析構派生類的情況發生。

基類指針指向派生類對象時,此時調用非虛析構函數,則會出現只會析構基類對象而不析構派生類對象的情況,造成內存泄漏的問題。

3. C++11中的override和final

C++對函數的重寫要求的比較嚴格,但是有些情況下由於疏忽,使函數無法構成重寫,這種錯誤在編譯期間是不會報錯的,在程序運行時沒有得到預期的結果從而Debug得不償失,所以,C++11提供了override和final兩個關鍵字來幫助用戶檢測是否重寫

  • final

final修飾虛函數表示該虛函數不能被重寫,final修飾類表示該類不能被繼承。

  • override

檢查派生類虛函數是否重寫了基類的某個虛函數,如果沒有重寫則編譯報錯。

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

  • 1. 重載

兩個函數在同一作用域,並且函數名相同,參數列表不同。

  • 2. 重定義(隱藏)

兩個函數分別在基類和派生類的作用域,並且函數名相同。

  • 3. 重寫(覆蓋)

兩個虛函數分別在基類和派生類的作用域,函數名、參數、返回值都必須相同(協變例外)。

5. 抽象類

在虛函數的後面寫上 =0 ,則這個函數爲純虛函數(不需要實現,沒有函數體)。

包含純虛函數的類叫做抽象類(也叫接口類),抽象類不能實例化出對象,派生類繼承後也不能實例化出對象。

重寫純虛函數,派生類才能實例化出對象。純虛函數規範了派生類必須重寫,另外純虛函數更體現出了接口繼承。

  • 實現繼承

普通函數的繼承是一種實現繼承,派生類繼承了基類函數,可以使用函數,繼承的是函數的實現。

  • 接口繼承

虛函數的繼承是一種接口繼承,派生類繼承了基類函數,目的是爲了重寫,實現多態,繼承的是函數的接口。

6. 虛函數表

1.基類的虛函數表:

class Base{
public:
	virtual void Func1(){
		cout << "Func1()" << endl;
	}
private:
	int _b = 1;
};

在32位平臺下測試,發現 sizeof(Base) = 8,其實Base類實例化的對象b中除了成員變量 _b外,還有一個 _vfptr放在成員變量的前面(前後關係和平臺有關),_vfptr其實是一個指針,我們叫做虛函數表指針,虛函數表底層是一個指針數組,其中存放着虛函數的地址。一個含有虛函數的類中都至少有一個虛函數表指針,因爲虛函數的地址需要存放到虛函數表中。

2.派生類的虛函數表:

Derive類繼承了Base類,Derive類中重寫了虛函數Func1。

class Base{
public:
	virtual void Func1(){
		cout << "Base::Func1()" << endl;
	}
	virtual void Func2(){
		cout << "Base::Func2()" << endl;
	}
	void Func3(){
		cout << "Base::Func3()" << endl;
	}
private:
	int _b = 1;
};

class Derive : public Base{
public:
	virtual void Func1(){
		cout << "Derive::Func1()" << endl;
	}
private:
	int _d = 2;
};

int main(){
	Base b;
	Derive d;
	return 0;
}

經過測試,派生類d對象中也有一個虛函數表指針(從基類繼承下來的),值得注意的一點是,派生類對象d和基類對象b的虛函數表是不同的,派生類中Func1完成了重寫,所以派生類的虛函數表中存放的是重寫的Derive :: Func1的地址。虛函數Func2繼承下來後沒有被重寫,Func2的地址直接放入派生類的虛函數表中,而Func3不是虛函數,雖然被繼承下來,但是它的地址不會被存入虛函數表。

在這裏插入圖片描述

總結一下派生類虛函數表的生成: 先將基類中的虛函數表內容拷貝一份到派生類虛函數表中 ,如果派生類重寫了基類中某個虛函數,則用派生類自己的虛函數覆蓋虛表中基類的虛函數,派生類自己新增加的虛函數按其在派生類中的聲明次序增加到派生類虛表的最後。

總結一下容易混淆的問題: 虛函數表中存的是虛函數的指針,而不是虛函數。虛函數和普通函數一樣,都存放在代碼段。對象中存的是虛函數表指針,而不是虛函數表。VS下虛函數表存在代碼段。虛函數表是在編譯階段生成的。

7. 多態的原理

在瞭解了虛函數表的相關知識之後,哪麼多態的原理是什麼呢?

舉個栗子:

上面我們說過,Func函數傳Person對象調用的是Person :: BuyTicket,Func函數傳Student對象調用的是Student :: BuyTicket

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

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

void Func(Person& p){
	p.BuyTicket();
}

int main(){
	Person Mike;
	Func(Mike);
	Student Johnson;
	Func(Johnson);
	return 0;
}

經過分析,程序運行時,如果p指向Mike對象時,p.BuyTicket通過Mike對象的虛函數表指針找到虛函數表,找到的虛函數爲Person :: BuyTicket 。如果p指向Johnson對象時,p.BuyTicket通過Johnson對象的虛函數表指針找到虛函數表,找到的虛函數爲Student :: BuyTicket 。

簡而言之就是指向誰就到誰的虛函數表中去找對應的虛函數,這樣就實現出了不同對象去完成同一行爲時,展現出不同的形態

我們再來看看彙編代碼:
在這裏插入圖片描述

  • 靜態綁定

靜態綁定又稱爲前期綁定,在程序編譯期間確定了程序的行爲,調用具體函數,也稱爲靜態多態,比如:函數重載。

  • 動態綁定

動態綁定又稱爲後期綁定,在程序運行期間,根據具體拿到的類型確定程序的具體行爲,調具體的函數,也稱爲動態多態。

8. 單繼承和多繼承關係的虛函數表

在單繼承和多繼承關係中,我們去研究的是派生類對象的虛函數表模型。

  • 1. 單繼承關係中的虛函數表
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;
};

我們通過監視窗口來看一下基類與派生類的虛函數表:
在這裏插入圖片描述
我們可以發現,監視窗口中我們發現看不見func3和func4。這裏其實是編譯器的監視窗口故意隱藏了這兩個函數。

那麼我們如何查看派生類的虛函數表呢?下面我們使用代碼打印出虛函數表中的函數。

// 傳參類型爲函數指針
typedef void(*VF_PTR)();
void PrintVFTable(VF_PTR* table){
	for (size_t i = 0; table[i] != 0; i++){
		printf("vfTable[%d]:%p->", i, table[i]);
		VF_PTR f = table[i];
		f();
	}
	cout << endl;
}

int main(){
	Base b;
	Derive d;
	// 取對象中前四個字節村的虛函數表指針
	PrintVFTable((VF_PTR*)*(int*)&b);
	PrintVFTable((VF_PTR*)*(int*)&d);
}

結果如下:
在這裏插入圖片描述

  • 2. 多繼承關係中的虛函數表

多繼承派生類的未重寫虛函數放在第一個繼承基類部分的虛函數表中
在這裏插入圖片描述

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