深入理解多態、虛函數與虛表)
前言在 C 面向對象中經常會提到三個核心思想封裝 繼承 多態前面學習繼承的時候我們解決的是不同類之間如何建立父子關系以及如何復用基類已有的成員。但是繼承本身還沒有解決一個更關鍵的問題如果不同派生類都擁有同一個行為怎么通過一個統一的接口讓程序自動執行各自不同的實現比如Animal ↓ ┌──────┬──────┐ Dog Cat它們都可以Speak();但是Dog::Speak() ↓ 汪汪 Cat::Speak() ↓ 喵喵如果調用者每次都需要先判斷if (類型是Dog) { ... } else if (類型是Cat) { ... }那么隨著派生類越來越多代碼會越來越復雜。多態解決的就是這個問題使用統一的接口根據對象真正的類型在運行時選擇對應的行為。一、多態的基本概念polymorphism翻譯成中文就是多態——多種形態。放到程序中可以簡單理解成同一個接口 不同對象 不同結果例如class Animal { public: virtual void Speak() { cout Animal::Speak() endl; } }; class Dog : public Animal { public: void Speak() override { cout 汪汪 endl; } }; class Cat : public Animal { public: void Speak() override { cout 喵喵 endl; } };我們寫一個統一接口void MakeSound(Animal animal) { animal.Speak(); }使用Dog dog; Cat cat; MakeSound(dog); MakeSound(cat);輸出汪汪 喵喵這里MakeSound()根本不需要知道傳進來的到底是Dog 還是 Cat它只需要按照Animal這個統一接口工作。真正執行哪一個Speak()運行時再根據對象決定。這就是多態最核心的價值調用者面向統一接口編程而不同對象負責提供自己的實現。靜態多態和動態多態C 中的多態可以從“什么時候決定調用哪個函數”這個角度分成兩類。第一類是靜態多態。比如函數重載void Print(int x) { cout int: x endl; } void Print(double x) { cout double: x endl; }調用Print(10); Print(3.14);編譯器在編譯階段就已經知道Print(10) ↓ Print(int) Print(3.14) ↓ Print(double)函數模板也是類似templateclass T void Print(const T x) { cout x endl; }實際需要哪個模板實例也是編譯時根據類型確定。所以函數重載 函數模板都屬于典型的編譯期多態 靜態多態另一類就是這篇真正的重點運行時多態也叫動態多態。例如Animal* ptr dog; ptr-Speak();ptr的類型明明是Animal*但是它真正指向的是Dog于是調用的是Dog::Speak()到底執行哪個版本并不是單純由Animal*這個靜態類型決定而與指針運行時真正指向的對象有關。可以先這樣記靜態多態 ↓ 編譯的時候已經決定 動態多態 ↓ 運行的時候根據真實對象決定二、多態的實現條件想讓 C 真正出現運行時多態核心條件可以概括成繼承 虛函數 重寫 基類指針或引用調用1. 必須存在繼承關系首先必須存在基類 ↓ 派生類例如class Vehicle { }; class Car : public Vehicle { };因為多態本質上依賴派生類對象可以通過基類接口進行統一管理。在通常的面向對象使用場景中一般采用public公有繼承來表達Car is a Vehicle這種is-a關系。2. 基類必須存在虛函數普通成員函數void Run();如果需要參與運行時多態要在基類中聲明成virtual void Run();例如class Vehicle { public: virtual void Run() { cout Vehicle running endl; } };這里virtual可以看作告訴編譯器以后調用這個函數時不一定只按照指針/引用表面的類型決定請保留運行時動態選擇的能力。虛函數只能是成員函數體系中的相應函數普通全局函數、靜態成員函數和構造函數不能成為用于這種動態派發的虛函數而析構函數可以且在多態基類中通常應該設計為虛函數。3. 派生類重寫虛函數例如class Vehicle { public: virtual void Run() { cout Vehicle running endl; } }; class Car : public Vehicle { public: void Run() override { cout Car running on road endl; } };這里Car::Run()對Vehicle::Run()進行了虛函數重寫override。一般情況下重寫要求函數名相同 參數列表相同 返回值類型相同也就是常說的三同。協變是返回值規則中的一個特殊例外后面再講。4. 通過基類指針或引用調用這是很多初學者最容易漏掉的地方。假設Vehicle v; Car c;直接v.Run(); c.Run();當然分別執行自己的函數。但是這個例子并沒有體現同一個基類接口根據不同運行時對象動態決定行為。真正典型的多態寫法是Vehicle* p1 v; Vehicle* p2 c; p1-Run(); p2-Run();得到Vehicle running Car running on road或者通過引用Vehicle r1 v; Vehicle r2 c; r1.Run(); r2.Run();最值得理解的是兩個指針類型都是 Vehicle* 但是 p1 真正指向 Vehicle p2 真正指向 Car所以最終行為不同。一個完整例子#include iostream #include string using namespace std; class Vehicle { public: Vehicle(const string name) : _name(name) {} virtual void Run() { cout _name 普通行駛 endl; } virtual void Stop() { cout _name 停止 endl; } protected: string _name; }; class Car : public Vehicle { public: Car(const string name) : Vehicle(name) {} void Run() override { cout _name 在公路上行駛 endl; } void Stop() override { cout _name 汽車剎車 endl; } }; class Ship : public Vehicle { public: Ship(const string name) : Vehicle(name) {} void Run() override { cout _name 在海面航行 endl; } void Stop() override { cout _name 拋錨停船 endl; } }; void Control(Vehicle v) { v.Run(); v.Stop(); } int main() { Car car(汽車); Ship ship(輪船); Control(car); Control(ship); return 0; }這里真正體現了多態設計的價值以后如果增加Plane Train Bike只需要新增派生類并實現相同接口。Control()本身不需要跟著寫一大堆if else if else if這就是對擴展開放對已有調用邏輯盡量少修改。三、虛函數重寫虛函數重寫的基本語法并不難真正容易混的是幾個特殊規則。協變派?類重寫基類虛函數時與基類虛函數返回值類型不同。即基類虛函數返回基類對象的指針或者引 ?派?類虛函數返回派?類對象的指針或者引?時稱為協變。協變的實際意義并不?所以我們 了解?下即可。class A {}; class B : public A {}; class Person { public: virtual A* BuyTicket() { cout 買票-全價 endl; return nullptr; } }; class Student : public Person { public: virtual B* BuyTicket() { cout 買票-打折 endl; return nullptr; } }; void Func(Person* ptr) { ptr-BuyTicket(); } int main() { Person ps; Student st; Func(ps); Func(st); return 0; }虛析構函數這是多態里面非常重要的一塊。假設class Base { public: ~Base() { cout ~Base() endl; } }; class Derived : public Base { public: ~Derived() { cout ~Derived() endl; } };然后Base* p new Derived; delete p;這里非常危險。我們的真實對象是Derived但刪除時使用的是Base*如果基類析構函數不是虛函數這種通過基類指針刪除派生類對象的行為無法正確完成完整的多態析構過程。正確設計應該是class Base { public: virtual ~Base() { cout ~Base() endl; } };于是delete p;會按照Derived 析構 ↓ Base 析構完整釋放對象。例如class Base { public: Base() { _data new int[100]; } virtual ~Base() { delete[] _data; cout ~Base() endl; } private: int* _data; }; class Derived : public Base { public: Derived() { _buffer new char[1024]; } ~Derived() override { delete[] _buffer; cout ~Derived() endl; } private: char* _buffer; };使用Base* p new Derived; delete p;這樣派生類自己的_buffer和基類的_data都能夠正確釋放。原文也把“通過基類指針刪除派生類對象時使用虛析構”作為多態的重要資源安全問題。所以可以記住如果一個類準備作為多態基類使用尤其可能通過基類指針delete派生對象應當提供虛析構函數。實際工程里非常常見virtual ~Base() default;overrideC11 提供了一個非常實用的關鍵字override它并不是讓函數“變成虛函數”的關鍵字。它真正的作用是告訴編譯器我認為這個函數是在重寫基類虛函數請幫我檢查。例如class Base { public: virtual void Print(int x) { } };派生類不小心寫成class Derived : public Base { public: void Print(double x) override { } };編譯器會直接報錯。因為Base Print(int) Derived Print(double)參數不同不構成重寫。如果沒有override這類錯誤有時候很難第一時間發現。更典型的是拼寫錯誤virtual void Drive();結果派生類寫成void Dirve() override;因為有override編譯器馬上就會提醒沒有重寫任何基類虛函數。所以我現在更推薦寫void Run() override而不是單純寫void Run()override能把很多原本潛伏到運行邏輯里的錯誤提前變成編譯錯誤。原文也專門用拼寫錯誤展示了這一點。finalfinal則代表到這里結束不允許繼續重寫。例如class Base { public: virtual void Run() { } }; class Derived : public Base { public: void Run() final { } };如果繼續class Child : public Derived { public: void Run() override { } };就會編譯失敗。因為Derived::Run()已經聲明final不能繼續重寫。final也可以直接修飾類class Config final { };意味著Config 不能繼續被繼承重載、重寫和隱藏這三個概念特別容易混。可以先從“發生在哪里”判斷。重載發生在同一個作用域例如void Func(int); void Func(double);函數名相同參數列表不同。重寫發生在基類 派生類而且基類函數必須是虛函數。例如class Base { public: virtual void Func(); }; class Derived : public Base { public: void Func() override; };它服務于運行時多態隱藏也是基類和派生類之間。例如class Base { public: void Func(int); }; class Derived : public Base { public: void Func(double); };因為不在同一個作用域所以Func(int) Func(double)不是重載。派生類的Func(double)會隱藏基類同名函數。如果要訪問基類d.Base::Func(10);四、純虛函數和抽象類有時候基類根本不應該給出某個行為的默認實現。例如Shape表示所有圖形。但是你問Shape 的面積是多少其實沒有意義。因為Rectangle Circle Triangle每一種圖形的面積公式不同。這時候基類真正需要做的不是替所有派生類計算面積而是規定所有具體圖形都必須提供“計算面積”這個接口。于是就可以使用virtual double Area() const 0;這就是純虛函數。例如class Shape { public: virtual double Area() const 0; virtual double Perimeter() const 0; virtual ~Shape() default; };只要一個類中存在純虛函數它就屬于抽象類。例如class Rectangle : public Shape { public: Rectangle(double width, double height) : _width(width) , _height(height) { } double Area() const override { return _width * _height; } double Perimeter() const override { return 2 * (_width _height); } private: double _width; double _height; };再來一個class Circle : public Shape { public: Circle(double radius) : _radius(radius) { } double Area() const override { return 3.14159 * _radius * _radius; } double Perimeter() const override { return 2 * 3.14159 * _radius; } private: double _radius; };統一處理void PrintShape(const Shape shape) { cout 面積 shape.Area() endl; cout 周長 shape.Perimeter() endl; }于是Rectangle rect(10, 5); Circle circle(3); PrintShape(rect); PrintShape(circle);調用者只面向Shape工作。具體公式交給Rectangle Circle自行完成。抽象類真正重要的地方不是 0這個語法而是設計思想基類 ↓ 規定“必須做什么” 派生類 ↓ 決定“具體怎么做”這種設計在框架接口 圖形系統 設備驅動 插件系統 策略模式 工廠模式里都非常常見。五、虛函數表到這里我們已經知道多態怎么用了。現在問題來了Base* p derived; p-Func();為什么一個Base*居然能在運行時找到Derived::Func()答案就涉及虛函數表 vtable 虛函數表指針 vptr虛表指針在主流 C ABI 的典型實現中一個具有虛函數的多態對象內部通常會包含一個隱藏的虛表指針用于找到該動態類型對應的虛函數表。原文使用 MSVC 的__vfptr名稱來解釋這一機制。例如class Base { public: virtual void Func1() { } virtual void Func2() { } private: int _data; };從概念上可以理解成Base對象 ┌────────────────┐ │ vptr │ ──────┐ ├────────────────┤ │ │ _data │ │ └────────────────┘ │ ↓ Base虛函數表 ┌─────────────┐ │ Base::Func1│ ├─────────────┤ │ Base::Func2│ └─────────────┘這里vptr負責告訴程序這個對象應該使用哪一張虛函數表。派生類重寫后發生什么假設class Base { public: virtual void Func1(); virtual void Func2(); }; class Derived : public Base { public: void Func1() override; };那么從典型實現角度可以理解成Base虛表 Func1 → Base::Func1 Func2 → Base::Func2而 Derived 的虛表Func1 → Derived::Func1 Func2 → Base::Func2因為Func1()被重寫了所以虛表中對應入口指向派生類版本。而Func2()沒有被重寫因此仍然使用基類實現。原文同樣以“派生類重寫后對應虛表入口指向派生版本”解釋其實現機制。需要注意一個嚴謹點vptr、vtable是主流編譯器實現 C 動態多態的典型機制但 C 語言標準本身并沒有規定編譯器必須使用某一種固定的虛表內存布局。所以如下理解對象 → vptr → vtable → 虛函數地址是非常有價值的。但是不要進一步認為所有編譯器的虛表一定長得完全一樣具體布局屬于 ABI / 編譯器實現細節。對象為什么會變大例如class A { public: void Func() { } private: int _a; };和class B { public: virtual void Func() { } private: int _a; };B的對象通常還需要保存vptr所以B對象大小往往比沒有虛函數的A對應對象多出至少一個指針相關的空間最終大小還會受到平臺位數、ABI 和內存對齊影響。原文也通過 32 位示例展示了帶虛函數對象因為額外虛表指針而增大。例如 64 位環境中一個指針通常是8 bytes但最終sizeof(B)不能只簡單寫成成員大小 8還需要考慮內存對齊 編譯器布局 繼承結構靜態綁定和動態綁定現在就可以理解為什么普通函數調用和虛函數多態調用不一樣。普通函數obj.Func();如果調用目標在編譯時能夠確定就是靜態綁定。可以粗略理解編譯階段 ↓ 函數地址確定 ↓ 直接調用而Base* p derived; p-Func();當滿足運行時多態條件時需要按照對象的動態類型尋找對應虛函數實現p ↓ 對象 ↓ vptr ↓ 對應vtable ↓ 找到Func入口 ↓ 調用Derived::Func這就是動態綁定。多態有沒有代價當然有。最明顯的兩部分空間 時間空間方面多態對象通常多一個vptr時間方面普通調用可能直接調用函數動態派發則通常需要對象 ↓ vptr ↓ 虛表 ↓ 函數地址 ↓ 調用多了一層或多層間接訪問。不過在絕大多數正常業務代碼里不應該為了省這點開銷就放棄一個本來非常適合多態的設計。是否需要避免虛函數應該結合調用頻率 性能要求 架構復雜度 可擴展性綜合判斷。六、高頻問題與實戰最后把這篇里幾個非常值得單獨記住的問題放在一起。虛函數的默認參數來看一個很經典的坑class Base { public: virtual void Func(int x 10) { cout Base: x endl; } }; class Derived : public Base { public: void Func(int x 20) override { cout Derived: x endl; } };然后Derived d; Base* p d; p-Func();你覺得輸出如下結果嗎Derived: 20實際上典型結果是Derived: 10原因在于這里有兩個東西需要分開理解調用哪個虛函數 ↓ 動態綁定 默認參數取哪個值 ↓ 根據調用表達式的靜態類型確定p的靜態類型是Base*所以默認參數來自Base::Func(int x 10)但是對象真正是Derived所以函數體動態派發到Derived::Func()于是最終形成Derived函數體 Base默認參數10得到Derived: 10所以實際設計虛函數時最好不要讓基類和派生類給同一個虛函數設置不同默認參數否則代碼非常容易產生誤解。利用多態實現計算器最后用一個稍微完整的例子把抽象類 純虛函數 重寫 基類引用 動態綁定 虛析構全部串起來。先設計一個抽象計算器#include iostream #include stdexcept using namespace std; class Calculator { public: Calculator(double left, double right) : _left(left) , _right(right) { } virtual double Calculate() const 0; virtual ~Calculator() default; protected: double _left; double _right; };這里Calculate()沒有默認實現。它只規定所有具體運算器必須提供計算能力。加法class AddCalculator : public Calculator { public: AddCalculator(double left, double right) : Calculator(left, right) { } double Calculate() const override { return _left _right; } };減法class SubCalculator : public Calculator { public: SubCalculator(double left, double right) : Calculator(left, right) { } double Calculate() const override { return _left - _right; } };乘法class MulCalculator : public Calculator { public: MulCalculator(double left, double right) : Calculator(left, right) { } double Calculate() const override { return _left * _right; } };除法class DivCalculator : public Calculator { public: DivCalculator(double left, double right) : Calculator(left, right) { } double Calculate() const override { if (_right 0) { throw runtime_error(除數不能為0); } return _left / _right; } };然后統一調用void PrintResult(const Calculator calculator) { cout calculator.Calculate() endl; }使用int main() { AddCalculator add(10, 5); SubCalculator sub(10, 5); MulCalculator mul(10, 5); DivCalculator div(10, 5); PrintResult(add); PrintResult(sub); PrintResult(mul); PrintResult(div); return 0; }輸出15 5 50 2這個例子真正體現的是PrintResult()完全不用關心 - * /具體是哪種運算。以后新增取模 乘方 開方只需要新增派生類 重寫 Calculate()統一調用接口本身不用跟著大改。這才是多態真正的價值。總結這一節內容很多但整個邏輯其實可以順著一條線理解多態 ↓ 一個接口多種行為 ↓ 靜態多態 / 動態多態 ↓ 運行時多態 ↓ 繼承 virtual 重寫 基類指針/引用 ↓ 虛函數重寫 ↓ 協變 / 虛析構 / override / final ↓ 純虛函數 ↓ 抽象類 ↓ vptr ↓ vtable ↓ 動態綁定 ↓ 運行時根據真實對象選擇函數如果讓我現在只用一句話理解 C 運行時多態我會寫基類負責規定統一接口派生類負責提供不同實現再通過基類指針或引用在運行時根據對象的真實類型選擇對應的虛函數。而底層可以進一步理解成基類指針 / 引用 ↓ 找到真實對象 ↓ 對象中的虛表指針 ↓ 對應類型的虛函數表 ↓ 找到需要調用的虛函數 ↓ 完成動態綁定學到這里以后前面繼承里很多看起來只是“語法規定”的東西也開始真正串起來了。比如為什么派生類可以交給基類指針 為什么必須重寫虛函數 為什么多態基類需要虛析構 為什么對象里多了一個指針 為什么同樣一個 Base* 能表現出不同的行為這些問題最終都指向同一個核心通過統一的基類接口把“調用什么”與“具體怎么實現”分離開。這也是多態比單純減少幾行if/else更重要的地方——它真正改變的是程序組織和擴展代碼的方式。