(詳細)一篇認識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. 函式名相同
可以看到, 分別在父類和子類中的同名函式, 要麼構成覆蓋, 要麼構成隱藏