目錄
1. 多態的概念
2. 多態的定義及實現
2.1 多態的構成條件
2.2 虛函數
2.3 虛函數的重寫
2.4 虛函數重寫的兩個例外
(1) 協變 (Covariance)
(2) 析構函數的重寫
2.5 C++11 override 和 final
2.6 重載、覆蓋(重寫)、隱藏(重定義)的對比
3. 抽象類
3.1 概念
3.2 接口繼承和實現繼承
4. 多態的原理
4.1 虛函數表
4.2 多態的原理
4.3 動態綁定與靜態綁定
5. 單繼承和多繼承關係中的虛函數表
5.1 單繼承中的虛函數表
5.2 多繼承中的虛函數表
6. 繼承和多態常見的面試問題
7. 總結
前言
在 C++ 面向對象編程中,多態(Polymorphism) 是繼封裝和繼承之後的第三大核心特性。通俗來説,多態就是“多種形態”,即去完成某個行為,當不同的對象去完成時會產生出不同的狀態。
本文將嚴格按照定義、用法、原理及面試高頻考點的邏輯,帶你徹底搞懂 C++ 多態。
1. 多態的概念
多態分為靜態多態和動態多態:
- 靜態多態:在編譯期間確定,主要通過函數重載和模板實現。
- 動態多態:在運行期間確定,主要通過繼承和虛函數實現。
本文主要討論的是動態多態。
生活中的例子: 比如買票這個行為。普通成年人買票是全價,學生買票是半價,軍人買票是優先通道。同一個“買票”的動作,不同的人(對象)去執行,產生了不同的結果。
2. 多態的定義及實現
2.1 多態的構成條件
在繼承體系中,實現多態必須同時滿足以下兩個嚴格條件:
- 必須通過基類的指針或者引用調用虛函數。
- 被調用的函數必須是虛函數,且派生類必須對該虛函數進行重寫(覆蓋)
2.2 虛函數
被 virtual 關鍵字修飾的成員函數稱為虛函數。
class Person {
public:
virtual void BuyTicket() {
cout << "買票-全價" << endl;
}
};
2.3 虛函數的重寫
派生類中有一個跟基類完全相同的虛函數(即派生類虛函數與基類虛函數的返回值類型、函數名、參數列表完全相同),稱派生類的虛函數重寫了基類的虛函數。
#include <iostream>
using namespace std;
class Person {
public:
virtual void BuyTicket() { cout << "買票-全價" << endl; }
};
class Student : public Person {
public:
// 派生類重寫基類虛函數
virtual void BuyTicket() { cout << "買票-半價" << endl; }
};
class Soldier : public Person {
public:
virtual void BuyTicket() { cout << "買票-優先" << endl; }
};
// 多態調用:傳什麼對象,就調什麼對象的函數
void Pay(Person& p) {
p.BuyTicket();
}
int main() {
Person ps;
Student st;
Soldier so;
Pay(ps); // 輸出:買票-全價
Pay(st); // 輸出:買票-半價
Pay(so); // 輸出:買票-優先
return 0;
}
2.4 虛函數重寫的兩個例外
雖然重寫要求返回值、函數名、參數列表都相同,但有兩個特殊情況:
(1) 協變 (Covariance)
基類與派生類虛函數返回值類型不同,但必須是父子關係的指針或引用。 即:基類虛函數返回基類對象的指針/引用,派生類虛函數返回派生類對象的指針/引用。
class A {};
class B : public A {};
class Person {
public:
virtual A* f() { return new A; }
};
class Student : public Person {
public:
// 返回值類型不同(A* vs B*),但構成協變,依然是重寫
virtual B* f() { return new B; }
};
(2) 析構函數的重寫
如果基類的析構函數為虛函數,此時派生類只要定義析構函數,無論是否加 virtual 關鍵字,都與基類析構函數構成重寫。
原因:編譯器在編譯後會將析構函數的名稱統一處理成
destructor,目的是為了構成多態,正確釋放內存。
重要場景:
class Person {
public:
virtual ~Person() { cout << "~Person()" << endl; }
};
class Student : public Person {
public:
~Student() { cout << "~Student()" << endl; }
};
int main() {
// 如果析構函數不是虛函數,delete p 時只會調 Person 的析構,導致內存泄漏
Person* p = new Student;
delete p; // 多態調用:先調 ~Student(),再調 ~Person()
return 0;
}
2.5 C++11 override 和 final
C++11 提供了兩個關鍵字來幫助我們在編譯階段檢查多態的正確性:
- final:修飾虛函數,表示該虛函數不能再被重寫。
- override:檢查派生類虛函數是否重寫了基類的某個虛函數,如果沒有重寫(比如拼寫錯誤),編譯器報錯。
class Car {
public:
virtual void Drive() {}
};
class Benz : public Car {
public:
virtual void Drive() override { cout << "Benz" << endl; } // 檢查是否重寫成功
};
2.6 重載、覆蓋(重寫)、隱藏(重定義)的對比
這是一個非常容易混淆的概念,總結如下表:
|
特性
|
重載 (Overload)
|
覆蓋/重寫 (Override)
|
隱藏/重定義 (Hiding) |
|
作用域
|
在同一個作用域
|
分別在基類和派生類
|
分別在基類和派生類 |
|
函數名
|
相同
|
相同
|
相同
|
|
參數列表
|
必須不同
|
必須相同
|
只要函數名相同,非重寫即隱藏 |
|
返回值
|
無要求
|
要求相同(協變除外)
|
無要求
|
|
virtual
|
無要求
|
基類必須有 virtual
|
無要求
|
3. 抽象類
3.1 概念
在虛函數的後面寫上 =0 ,則這個函數為純虛函數。 包含純虛函數的類叫做抽象類(也叫接口類)。抽象類不能實例化出對象。派生類繼承後也不能實例化出對象,只有重寫純虛函數,派生類才能實例化
class Car {
public:
// 純虛函數
virtual void Drive() = 0;
};
class BMW : public Car {
public:
virtual void Drive() {
cout << "BMW-操控" << endl;
}
};
int main() {
// Car c; // 錯誤,抽象類不能實例化
Car* p = new BMW;
p->Drive();
return 0;
}
3.2 接口繼承和實現繼承
- 普通函數的繼承是一種實現繼承,派生類繼承了基類函數的實現,可以使用該函數。
- 虛函數的繼承是一種接口繼承,派生類繼承的是基類虛函數的接口,目的是為了重寫,達成多態。
- 如果不實現多態,不要把函數定義成虛函數。
4. 多態的原理
4.1 虛函數表
我們先看一個問題:sizeof(Base) 是多少?
class Base {
public:
virtual void Func1() { cout << "Func1" << endl; }
private:
int _b = 1;
};
在 32 位系統下,結果是 8 字節。除了 int _b 佔 4 字節外,還有一個 4 字節的指針,我們稱之為虛函數表指針 (vptr)。
- 一個含有虛函數的類中至少有一個虛函數表指針,該指針指向一個數組,數組中存放的是虛函數的地址,這個數組叫做虛函數表 (vftable)。
- 虛函數表本質是一個函數指針數組,一般以 nullptr 結尾。
4.2 多態的原理
多態是如何實現的?
- 編譯期間:編譯器檢查代碼,如果滿足多態的兩個條件(指針/引用調用 + 虛函數),則生成特殊的指令。
- 運行期間:
- 當
p->BuyTicket()被調用時,程序並不知道p指向的是Person對象還是Student對象。- 程序會通過
p指針找到對象內存中的 vptr。- 通過 vptr 找到對應的 虛函數表。
- 在虛函數表中找到
BuyTicket的實際地址並調用。
總結:如果對象是 Student,vptr 指向 Student 的虛表(其中包含重寫後的 Student::BuyTicket 地址);如果對象是 Person,vptr 指向 Person 的虛表。這就是“動態綁定”。
4.3 動態綁定與靜態綁定
- 靜態綁定:在編譯階段就確定了函數的地址(如普通函數調用、函數重載)。
- 動態綁定:在程序運行期間,根據具體拿到的對象類型確定程序的具體行為,調用具體的函數(多態)。
5. 單繼承和多繼承關係中的虛函數表
5.1 單繼承中的虛函數表
在單繼承中,派生類的虛函數表生成規則如下:
- 先將基類的虛表內容拷貝一份到派生類虛表中。
- 如果派生類重寫了基類的某個虛函數,則用派生類自己的虛函數地址覆蓋虛表中基類的虛函數地址。
- 派生類自己新增加的虛函數,按其在派生類中的聲明次序,增加到派生類虛表的最後。
5.2 多繼承中的虛函數表
如果一個類繼承了兩個帶有虛函數的基類:
Derive對象中會有兩個 vptr。Base1的 vptr 指向第一張虛表,Base2的 vptr 指向第二張虛表。- 派生類重寫了
func1,則第一張虛表中的func1被覆蓋。- 注意:派生類自己新增的虛函數(如
func3),通常會添加在第一個繼承基類(Base1)的虛表後面。
6. 繼承和多態常見的面試問題
Q1: inline 函數可以是虛函數嗎?
- 答:可以,但有前提。如果是普通調用,它依然可以被內聯;如果是多態調用,編譯器會忽略
inline屬性,因為多態需要在運行時去虛表中找地址,而內聯是在編譯時展開,兩者衝突。
Q2: 靜態成員函數可以是虛函數嗎?
- 答:不能。靜態成員函數沒有
this指針,而虛函數的調用依賴this指針找到 vptr。
Q3: 構造函數可以是虛函數嗎?
- 答:不能。虛函數表指針 (vptr) 是在構造函數初始化列表階段才初始化的。如果構造函數是虛函數,調用它需要查虛表,但此時虛表指針還沒初始化,形成悖論。
Q4: 析構函數建議設為虛函數嗎?
- 答:強烈建議。如果用基類指針指向派生類對象,且基類析構函數不是虛函數,delete 時只會調用基類的析構,導致派生類資源未釋放(內存泄漏)。
- 虛析構函數主要是為了解決 “通過基類指針刪除派生類對象” (即場景 C)這一特定情況下的內存泄漏問題。除此之外的其他正常對象創建和銷燬,C++ 的默認機制都能保證基類析構函數被自動調用。
Q5: 虛函數表存在哪裏?虛表指針存在哪裏?
- 答:
- 虛表指針 (vptr):存在於對象的內存空間頭部(通常)。
- 虛函數表 (vftable):存在於代碼段(常量區)。同類型的對象共享同一張虛表。
7. 總結
多態是 C++ 面向對象編程的靈魂。如果説封裝是讓代碼“模塊化”,繼承是讓代碼“複用”,那麼多態則是讓代碼變得“靈活”。
通過本文的學習,我們不僅掌握了 virtual、override 等語法細節,更理解了多態背後的設計哲學:“接口與實現分離”。它允許我們在不修改現有代碼的前提下,通過增加新的子類來擴展功能(符合開閉原則)。雖然虛函數表機制在底層帶來了一定的性能開銷(空間上的 vptr 和時間上的查表跳轉),但換來的是極高的代碼可維護性和擴展性。