目錄

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 多態的構成條件

在繼承體系中,實現多態必須同時滿足以下兩個嚴格條件:

  1. 必須通過基類的指針或者引用調用虛函數。
  2. 被調用的函數必須是虛函數,且派生類必須對該虛函數進行重寫(覆蓋)

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 提供了兩個關鍵字來幫助我們在編譯階段檢查多態的正確性:

  1. final:修飾虛函數,表示該虛函數不能再被重寫。
  2. 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 結尾。

c++多態:從原理到應用_多態

4.2 多態的原理

多態是如何實現的?

  1. 編譯期間:編譯器檢查代碼,如果滿足多態的兩個條件(指針/引用調用 + 虛函數),則生成特殊的指令。
  2. 運行期間
  • p->BuyTicket() 被調用時,程序並不知道 p 指向的是 Person 對象還是 Student 對象。
  • 程序會通過 p 指針找到對象內存中的 vptr
  • 通過 vptr 找到對應的 虛函數表
  • 在虛函數表中找到 BuyTicket 的實際地址並調用。

總結:如果對象是 Student,vptr 指向 Student 的虛表(其中包含重寫後的 Student::BuyTicket 地址);如果對象是 Person,vptr 指向 Person 的虛表。這就是“動態綁定”。

4.3 動態綁定與靜態綁定

  • 靜態綁定:在編譯階段就確定了函數的地址(如普通函數調用、函數重載)。
  • 動態綁定:在程序運行期間,根據具體拿到的對象類型確定程序的具體行為,調用具體的函數(多態)。

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

5.1 單繼承中的虛函數表

在單繼承中,派生類的虛函數表生成規則如下:

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

c++多態:從原理到應用_多態_02

5.2 多繼承中的虛函數表

如果一個類繼承了兩個帶有虛函數的基類:

c++多態:從原理到應用_派生類_03

  • 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++ 面向對象編程的靈魂。如果説封裝是讓代碼“模塊化”,繼承是讓代碼“複用”,那麼多態則是讓代碼變得“靈活”。

通過本文的學習,我們不僅掌握了 virtualoverride 等語法細節,更理解了多態背後的設計哲學:“接口與實現分離”。它允許我們在不修改現有代碼的前提下,通過增加新的子類來擴展功能(符合開閉原則)。雖然虛函數表機制在底層帶來了一定的性能開銷(空間上的 vptr 和時間上的查表跳轉),但換來的是極高的代碼可維護性和擴展性。