1. 程式人生 > 其它 >(詳細)一篇認識C++面向物件特性 —— 多型

(詳細)一篇認識C++面向物件特性 —— 多型

技術標籤:C++語法/實現/相關

之前介紹了C++面向物件的一大特性 —— 繼承, 今天我們就來看看另外的一大特性 —— 多型. 話不多說, 直接進入正題.

文章目錄



多型的概念

簡單來說,多型就是一個行為、多種狀態。

舉個栗子:買火車票,都是買票這一行為,普通人只能買成人票,而我們大學生持有學生證就可以買學生票。

再比如:想必大家都玩過抽卡的遊戲吧,非酋和歐皇都懂吧,那我就不多說了,這也是多型。



多型的定義及實現

1.實現多型的條件

多型的前提是繼承

呼叫函式產生多型的行為 :
1. 函式必須為虛擬函式 virtual 函式
2. 虛擬函式需要在子類中重寫
3. 呼叫虛擬函式時, 必須用父類指標或者引用呼叫

上述是三個多型實現的條件,缺一不可


2. 虛擬函式及虛擬函式的重寫

虛擬函式:被virtual修飾的類成員函式稱為虛擬函式

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


3. 程式碼示例

父類和子類的定義:

//父類
class Person {
public:
	virtual void buyTicket() {
		cout << "普通票 -- 全價" << endl;
	}
};

//子類
class Student : public Person {
public:
	//子類進行虛擬函式的重寫
	virtual void buyTicket() {
		cout << "學生票 -- 半價" << endl;
	}
};

多型和非多型的對比. 父類引用也可,由於引用和普通物件作為引數時, 傳參會發生矛盾,故沒有給出引用型別

//父類指標呼叫虛擬函式, 構成多型
void func(Person* p) {
	p->buyTicket();
}

//父類物件呼叫虛擬函式, 不構成多型
void func(Person p) {
	p.buyTicket();
}

測試函式:

void test() {
	Person p;
	Student s;

	Person* pp = &p;
	Student* ps = &s;

	//這就是用指標調了一下對應的函式而已, 不是多型
	pp->buyTicket();
	ps->buyTicket();
	cout << endl;

	// 多型 : 看物件
	func(&p); 
	func(&s);
	cout << endl;

	// 非多型 : 看型別
	func(p);
	func(s);
	cout << endl;
}

執行結果如下:
在這裡插入圖片描述
可見三組結果, 第一組結果是用對應的指標呼叫了一下函式而已,與多型無關, 第二組結果是呼叫func(Person*)函式, 引數不同結果也不同, 明顯構成多型. 第三組呼叫func(Person)函式, 引數不同, 結果相同, 不構成多型.

多型 --- 看物件
func(Person*)函式構成多型
傳入父類物件, 就執行父類函式邏輯
傳入子類物件, 就執行子類函式邏輯

非多型 --- 看型別
func(Person)函式不構成多型
傳入父類物件, 引數匹配, 執行父類函式邏輯
傳入子類物件, 引數不匹配, 發生切片操作, 得到的結果還是父類物件, 所以還是執行父類函式邏輯

通過這個栗子想必大家對多型也有了一定的瞭解

這裡說一個不規範的例子:
父類加virtual, 子類不加, 子類構成多型
這是不規範的, 但是我們要知道它也構成多型, 題中人家可能這麼寫

還有一種情況:
父類不加virtual, 子類加了, 這時子類不構成多型
這裡應該沒有什麼疑問吧, 父類都沒有虛擬函式那必定不構成多型
這裡子類加上virtual是為了後面還有類繼承它的時候可以構成多型

所以我們自己寫程式碼的時候, 儘量都寫出來, 出題人給我們挖坑就算了, 我們自己不要給自己挖坑


4. 虛擬函式重寫的的特殊形式(協變)

上面我們說虛擬函式重寫要滿足 — 子類虛擬函式和父類介面完全相同的函式 (函式名, 引數, 返回值都相同)

但是這裡存在一種特殊情況 — 協變(返回值可以不同), 但返回值必須是構成父子繼承關係的指標/引用

光說概念不好理解, 我們直接上一段程式碼, 讓大家更好的理解

//先有一對父子繼承關係的類
class AA { };
class BB : public AA { };

class Person {
public:
	//返回值AA* , 是父類的指標
	virtual AA* buyTicket() {
		cout << "普通票 -- 全價" << endl;
		return &AA();
	}

};

class Student : public Person {
public:
	//返回值BB*, 是子類的指標
	virtual BB* buyTicket() {
		cout << "學生票 -- 半價" << endl;
		return &BB();
	
};

測試函式:

void func(Person* p) {
	p->buyTicket();
}

void func(Person p) {
	p.buyTicket();
}

void test() {
	Person p;
	Student s;

	Person* pp = &p;
	Student* ps = &s;

	// 多型 : 看物件
	func(&p); 
	func(&s);
	cout << endl;
}

測試結果如下 :
在這裡插入圖片描述
可以看到, 也是可以構成多型的

值得一提的是, 一般情況下也不使用協變來構成多型, 但是我們要知道這個知識點


5. 關鍵字 final & override

final關鍵字 : 修飾的虛擬函式, 在子類中不能再被重寫 (體現了實現繼承, 我繼承下來直接用, 不必重寫)

override : 強制子類的函式必須重寫父類的一個虛擬函式 (體現了介面繼承, 我只用你的介面, 不用你的邏輯)
一般用於重寫函式, 加一個以便自己區分是隱藏還是重寫

class A {
public:
	//final關鍵字: 修飾的虛擬函式,在子類中不能再被重寫(體現了實現繼承)
	virtual void func() final {
		cout << "A::func()" << endl;
	}

	virtual void func2() {
		cout << "A::func2()" << endl;
	}
};

class B : public A {
public:
	//override: 強制子類的函式必須重寫父類的一個虛擬函式(體現了介面繼承)
	virtual void func2() override {
		cout << "B::func2()" << endl;
	}
};



解構函式與虛擬函式

首先先來看一個概念

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

大概意思就是: 只要父類的解構函式定義為虛擬函式, 無論如何子類的析構一定是重寫了父類的析構, 雖然函式名不盡相同, 編譯器在底層會把他們的函式名處理成一樣的.
這麼做的目的就是為了實現多型.

那為什麼非要構成多型呢? 下面會告訴大家 !

這裡我們就是回顧一下繼承的知識了
首先: 呼叫子類的建構函式時, 會自動先呼叫父類的構造, 構造父類.
析構與構造正好相反, 呼叫子類的析構時, 先呼叫子類析構, 再呼叫父類析構, 釋放各自的資源

當然, 正常情況下的解構函式呼叫是沒有問題的.
問題就發生在切片的時候, 我們想用父類指標/引用指向子類
如果不把父類的解構函式定義為虛擬函式, 這時候只能呼叫父類的解構函式, 但凡子類中有資源, 就會造成記憶體洩漏.
反之, 如果定義成虛擬函式, 那麼構成多型, 我們知道多型看物件, 此時是父類指標指向子類物件, 實際還是子類物件, 這時就會呼叫子類的析構. (呼叫子類析構之後會自動呼叫父類析構, 保證不會出現記憶體洩漏)
多型: 看物件 --> 子類物件 --> 子類析構

下面通過程式碼理解

class A {
public:
	//析構儘量寫成虛擬函式
	virtual ~A() {
		cout << "~A()" << endl;
	}
};

class B : public A {
public:
	//重寫父類的解構函式 :  因為底層的函式名是相同的, 介面完全一致  (這麼設計就是為了實現多型)
	virtual ~B() {
		cout << "~B()" << endl;
	}
};

void test2() {
	//切片: 有記憶體洩漏的風險 (析構不是多型)
	//多型: 看物件  --> 子類物件  -->  子類析構
	//用父類指標/引用 指向繼承體系中的物件
	A* pc = new B;
	delete pc;   //只調用父類析構, 如果子類中有資源, 會造成記憶體洩漏, 子類的資源不會被釋放
				 // 如果父類析構寫成虛擬函式, 就解決了問題 ()
}

在這裡插入圖片描述
我們可以看到呼叫了子類析構之後又呼叫了父類的析構

如果不把解構函式寫成虛擬函式, 在進行測試

在這裡插入圖片描述
可以看到只調用了父類的析構, 這時如果子類中有資源, 就會造成記憶體洩漏



純虛擬函式與抽象類

純虛擬函式: 虛擬函式沒有函式體, 在引數列表後加 = 0

抽象類 : 包含純虛擬函式的類

  • 抽象類不能例項化 (因為成員不完整)
  • 子類繼承了抽象類, 必須實現純虛擬函式, 如果不實現, 也是一個抽象類, 不能例項化
class A {
public:
	//純虛擬函式   (沒有函式體)
	//抽象類:  包含純虛擬函式的類
	virtual void func4() = 0;
};

class B : public A {
public:
	//重寫父類中的純虛擬函式
	virtual void func4() {
		cout << "B::func4()" << endl;
	}
};


介面繼承與實現繼承

普通函式的繼承是一種實現繼承,派生類繼承了基類函式,可以使用函式,繼承的是函式的實現。虛擬函式的繼承是一種介面繼承,派生類繼承的是基類虛擬函式的介面,目的是為了重寫,達成多型,繼承的是介面。所以如果不實現多型,不要把函式定義成虛擬函式。



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

過載

1. 兩個函式在同一作用域
2. 函式名和引數相同

重寫(覆蓋)

1. 兩個函式分別在一個繼承體系(父類和子類)中
2. 都是虛擬函式
3. 函式名, 引數, 返回值都必須相同 (協變除外)

重定義(隱藏)

1. 兩個函式分別在一個繼承體系(父類和子類)中
2. 函式名相同

可以看到, 分別在父類和子類中的同名函式, 要麼構成覆蓋, 要麼構成隱藏