:用Animal類家族拆解虛函數表與對象切片)
Animal這個例子在C面向對象編程里幾乎快被講爛了但它確實是把繼承和多態(tài)這兩個概念講明白的最短路徑。畢竟狗會叫、貓會叫、動物都會吃東西這種天然的父子關系和同名不同行為的現實映射幾乎就是為繼承和多態(tài)量身定做的場景。這篇文章我打算用一份完整的C代碼把Animal類家族從設計到落地拆開揉碎了講包括繼承怎么設計、多態(tài)怎么觸發(fā)、虛函數表到底是什么以及我在實際測試中踩過的幾個經典坑。不管你是剛學OOP的新手還是寫了一陣子C但總對多態(tài)有點含糊這篇都適合當一份帶注釋的參考。1. 面向對象編程的繼承為什么非用Animal開頭不可1.1 先看一段沒有繼承的笨辦法假設現在我讓你寫一個程序管理一個動物園。第一版需求很簡單有狗有貓每種動物都有名字、年齡都會吃東西、睡覺、發(fā)出叫聲。大部分人第一反應是寫兩個獨立的類class Dog { public: Dog(std::string name, int age) : name_(name), age_(age) {} void eat() { std::cout name_ is eating. std::endl; } void sleep() { std::cout name_ is sleeping. std::endl; } void speak() { std::cout name_ says: Wang! std::endl; } private: std::string name_; int age_; }; class Cat { public: Cat(std::string name, int age) : name_(name), age_(age) {} void eat() { std::cout name_ is eating. std::endl; } void sleep() { std::cout name_ is sleeping. std::endl; } void speak() { std::cout name_ says: Miao! std::endl; } private: std::string name_; int age_; };這段代碼能跑但你看一眼就會發(fā)現兩個問題。第一name_、age_、eat()、sleep()幾乎是完全重復的只不過類名換了一下。第二假如后天動物園里要加一只鴨子你得把這整段復制-改名-改叫聲再來一遍。代碼量隨著動物種類線性膨脹而且一旦要加一個公共行為比如所有動物都要增加一個play()方法你得跑到每個類里各改一遍漏改一個就是噩夢。這還只是兩個類現實項目里如果有幾十個類共享某些字段和行為這種復制粘貼式寫法會讓維護成本直接爆表。繼承要解決的問題從最簡單的層面上說就是把共性抽出來把差異留給子類。1.2 繼承是什么is-a 關系繼承表達的是is-a關系。狗是一只動物貓也是一只動物所以 Dog 和 Cat 是 Animal 的派生類Animal 是它們的基類。用C的術語說叫派生類繼承基類。class Animal { public: Animal(std::string name, int age) : name_(name), age_(age) {} void eat() { std::cout name_ is eating. std::endl; } void sleep() { std::cout name_ is sleeping. std::endl; } void speak() { std::cout name_ makes a sound. std::endl; } protected: std::string name_; int age_; };Dog和Cat現在只需要寫自己特有的部分class Dog : public Animal { public: Dog(std::string name, int age) : Animal(name, age) {} void speak() { std::cout name_ says: Wang! std::endl; } }; class Cat : public Animal { public: Cat(std::string name, int age) : Animal(name, age) {} void speak() { std::cout name_ says: Miao! std::endl; } };注意我在這里把name_和age_放到了protected區(qū)域。這是繼承設計里一個很關鍵的取舍如果它們是private派生類無法直接訪問如果它們是public外部任何人都能改破壞了封裝。protected的含義是對子類可見對外部不可見正好卡在中間。不過要額外提醒一句protected成員本質上仍然是類接口的一部分用得太多會讓類變得難維護。更現代的做法是提供getName()、getAge()這樣的訪問器。但是在教學例子里直接protected更容易看清繼承機制的運作所以先用它后面寫工程代碼時再逐步收緊。1.3 公有繼承、保護繼承、私有繼承到底該選哪個繼承方式有三種public、protected、private初學者通常會忽略它們的區(qū)別。簡單記一個結論日常寫業(yè)務代碼90% 以上用public繼承因為子類就是基類的一種這個語義成立。protected繼承和private繼承更多是表達實現復用而不是類型從屬實際工程項目里用得很少而且往往可以用組合替代。繼承方式基類 public 成員基類 protected 成員基類 private 成員典型語義public 繼承變成子類的 public變成子類的 protected不可訪問is-aprotected 繼承變成子類的 protected變成子類的 protected不可訪問子類內部復用private 繼承變成子類的 private變成子類的 private不可訪問has-a 的變體讓我把表格里最容易被誤解的一點拆開說。很多人以為private繼承下基類的private成員會被繼承下來只是子類不能用——這個理解不準確。C 標準里private成員始終是存在于對象中但子類不可見。也就是說一個Dog對象里確實有一塊內存存放Animal的私有數據只是你從Dog的成員函數里訪問不到它。這一點和 Java 有明顯的差異也是C的繼承模型比較底層的體現。2. 繼承實戰(zhàn)Animal類家族是怎樣設計出來的2.1 基類成員設計的先后順序設計基類時我習慣按照屬性 - 構造 - 行為的順序來推敲。屬性方面Animal 至少要有名字和年齡所以定義std::string name_和int age_。這里有個很多人剛開始學時會困惑的點成員變量命名結尾為什么加下劃線沒有硬性規(guī)定但下劃線后綴是我比較推薦的一種命名習慣它能把成員變量和局部變量、函數參數一目了然地分開避免在構造函數里出現name name這種自賦值歧義。構造方面Animal 需要一個構造函數來初始化這兩個成員Animal(std::string name, int age) : name_(std::move(name)), age_(age) {}這里我用了std::move(name)把字符串的拷貝變成移動省掉一次不必要的復制。如果你還沒學到移動語義先寫成const std::string name然后name_(name)也完全沒問題核心是理解初始化列表的語法name_(name)是在對象構造階段直接初始化成員而不是先默認構造再賦值。這兩者的區(qū)別對內置類型比如int不明顯但對std::string這種帶資源管理的類型直接初始化可以避免一次無謂的默認構造開銷。行為方面eat()和sleep()這類所有動物都通用的函數直接放在基類里讓子類繼承即可。speak()則比較特殊因為每種動物叫聲不同這就為后面的多態(tài)埋下了伏筆。在第一版里我暫時讓它輸出一句籠統(tǒng)的 makes a sound后面再改成純虛函數。2.2 派生類構造函數初始化列表為何必須調用基類構造寫Dog的構造函數時初始化列表里先調用了Animal(name, age)。這個調用的順序是C強制規(guī)定的先構造基類部分再構造派生類自己的成員最后執(zhí)行派生類構造函數體。Dog(std::string name, int age) : Animal(name, age) {}如果你想偷懶不寫: Animal(name, age)那么編譯器會自動調用基類的默認構造函數也就是無參的那種。如果基類沒有定義默認構造函數而只定義了帶兩個參數的構造函數編譯就會直接報錯。這個報錯信息可能有點繞比如no matching function for call to Animal::Animal()——看到這種報錯第一反應就應該是派生類沒有正確調用基類的帶參構造。另外還有一個細節(jié)派生類構造函數體內的代碼這時候基類部分已經構造完成。所以如果你想在函數體里訪問name_做某些操作是安全的。2.3 同名函數的遮蔽問題把speak()寫進Dog類之后實際上發(fā)生了一件初學者容易忽略的事Dog::speak()把Animal::speak()遮蔽了。注意我用的是遮蔽hiding而不是覆蓋overriding。這是兩個完全不同的概念后面講多態(tài)時會重新提到。Dog dog(Wangcai, 3); dog.speak(); // 調用 Dog::speak() dog.Animal::speak(); // 顯式調用基類版本第一種調用大家都能理解第二種顯式調用基類版本就不太常用了。但在某些場景下它確實有用比如子類的speak()想先執(zhí)行基類speak()里的公共邏輯再補充自己的邏輯。這時候你可以這么寫void speak() override { Animal::speak(); // 先讓基類做基礎輸出 std::cout and then barks. std::endl; }遮蔽的另一個麻煩在于如果派生類里定義了一個和基類同名但是參數列表不同的函數基類的所有同名重載都會被遮蔽而不僅僅是那個參數匹配的版本。我用個例子說明class Example { public: void func(int) {} void func(double) {} }; class Derived : public Example { public: void func(std::string) {} }; Derived d; d.func(1); // 編譯錯誤基類的 func(int) 被遮蔽了新手經常在這里踩坑以為func(int)還能用結果報錯找不到匹配函數。解決辦法是顯式引入基類的重載using Example::func;寫在使用之前即可。3. 多態(tài)性基礎概念與C實現機制3.1 什么是多態(tài)性多態(tài)這個詞聽起來抽象翻譯成大白話就是同一個函數調用作用在不同對象上表現出不同的行為。C里的多態(tài)主要分兩類編譯期多態(tài)和運行期多態(tài)。編譯期多態(tài)包括函數重載、運算符重載、模板。它們的特點是編譯時就已經確定了到底調用哪個函數速度快但沒有靈活性。運行期多態(tài)才是 OOP 教材里講的多態(tài)通過虛函數來實現。它的核心奧義是調用哪個函數在運行期根據對象的實際類型決定而不是根據指針或引用的靜態(tài)類型決定。這句話值得反復讀三遍它是理解多態(tài)的鑰匙。我們來看一個具體場景。假設我寫一個函數void makeSound(Animal animal) { animal.speak(); }如果speak()不是虛函數那么makeSound(dog)調用的永遠是Animal::speak()哪怕傳入的是 Dog 對象。但如果把speak()聲明為 virtual那傳入 Dog 就調 Dog 的版本傳入 Cat 就調 Cat 的版本函數本身一行都不用改。這就是多態(tài)的價值寫一遍通用邏輯就能處理一個類族里所有類型未來加新類型也不需要修改現有代碼。3.2 虛函數與 override 的正確寫法把 Animal 的speak()聲明成虛函數class Animal { public: virtual void speak() const { std::cout name_ makes a sound. std::endl; } // ... }; class Dog : public Animal { public: void speak() const override { std::cout name_ says: Wang! std::endl; } };兩個細節(jié)要解釋。第一override關鍵字是 C11 引入的它本身不是必需的但沒有它容易出問題。假設派生類里把函數簽名寫錯了比如少寫一個constvoid speak() override; // 如果基類是 void speak() const這里就編譯失敗如果寫了override編譯器會立刻提醒你沒有匹配的基類虛函數。如果不寫編譯器不會報錯只是默默地把這個函數變成一個普通的新函數導致本來期望的多態(tài)失效。這種 bug 非常隱蔽所以我個人的建議是凡是意圖覆蓋基類虛函數的地方一律加上 override它是你的安全網。第二虛函數簽名必須完全匹配包括參數列表、const 限定符、引用限定符。返回類型可以協(xié)變但那是另一個話題初學階段可以先不深究。3.3 虛函數表多態(tài)背后的一點真相講了這么多虛函數到底是怎么實現多態(tài)的標準里沒有規(guī)定具體機制但幾乎所有主流編譯器用的都是虛函數表方案也就是 vtable。簡單說每個包含虛函數的類編譯器都會為它生成一張表表里存的是這個類所有虛函數的實際地址。每個對象內部會隱藏一個指針叫 vptr指向自己所屬類的虛函數表。調用虛函數時程序先通過 vptr 找到虛函數表再從表里取出對應的函數指針來調用。我用 Dog 和 Cat 的繼承關系畫個文字版說明Animal 對象: [vptr] - Animal::speak() Dog 對象: [vptr] - Dog::speak() Cat 對象: [vptr] - Cat::speak()所以當你寫animal.speak()時實際執(zhí)行的是(*(animal.vptr[i]))()那個 i 是 speak 在虛函數表里的槽位。Dog 對象里存的數據是 Dog 自己的但它的 vptr 也是 Dog 自己的所以即使你用一個Animal引用去調用最終仍然跳到 Dog 的 speak 上。這就是運行期多態(tài)的底層邏輯。代價也很直白每個帶虛函數的對象都要多占用 8 字節(jié)64 位平臺下來存 vptr。一個 Animal 對象如果原本只有 40 字節(jié)有了虛函數可能會變成 48 字節(jié)。對一個對象數量巨大的系統(tǒng)來說這個開銷需要考慮而虛函數調用本身也比普通函數調用多一次間接跳轉性能上略有損耗。絕大多數業(yè)務場景可以忽略但做底層庫時就要掂量了。4. 完整實操把 Animal 家族升級成可直接運行的動物園4.1 用純虛函數升華抽象層次上一版 Animal 的speak()雖然寫了句 makes a sound但你仔細想想現實里不存在一種泛泛的動物會發(fā)出泛泛的聲音。每一個具體動物的叫聲都是獨特的。這給我們一個啟示Animal 這個類的speak()根本不該有實現因為它在概念上就是未定義的。C 專門為這種情況設計了純虛函數寫法是在虛函數聲明后面加 0class Animal { public: virtual void speak() const 0; // 純虛函數 virtual ~Animal() default; // 虛析構函數后面講 };一旦類里有了純虛函數這個類就成了抽象類不能直接實例化。你不能再寫Animal animal(some, 1)編譯會直接報錯。只能創(chuàng)建 Dog、Cat 這種實現了speak()的具體派生類。這個語法其實和 Java 里的 abstract method 是同一個思想定義接口具體行為交給子類。但從設計角度說把基類做成抽象類不是一個選擇題而是一個合理性判斷題。如果你發(fā)現基類里某個虛函數你自己都編不出一個合理的默認實現那就說明它應該是個純虛函數。4.2 一份可以跑起來的完整代碼下面是我整理的一版完整程序你復制到編譯器里可以直接運行。為了展示多態(tài)我加了一個全局函數makeSound接收Animal類型。#include iostream #include string #include vector #include memory class Animal { public: Animal(std::string name, int age) : name_(std::move(name)), age_(age) {} virtual void speak() const 0; void eat() const { std::cout name_ is eating. std::endl; } void sleep() const { std::cout name_ is sleeping. std::endl; } std::string getName() const { return name_; } int getAge() const { return age_; } virtual ~Animal() default; private: std::string name_; int age_; }; class Dog : public Animal { public: Dog(std::string name, int age) : Animal(std::move(name), age) {} void speak() const override { std::cout getName() says: Wang! Wang! std::endl; } }; class Cat : public Animal { public: Cat(std::string name, int age) : Animal(std::move(name), age) {} void speak() const override { std::cout getName() says: Miao~ std::endl; } }; class Duck : public Animal { public: Duck(std::string name, int age) : Animal(std::move(name), age) {} void speak() const override { std::cout getName() says: Quack! std::endl; } }; void makeSound(Animal animal) { animal.speak(); } int main() { Dog dog(Wangcai, 2); Cat cat(Kitty, 3); Duck duck(Donald, 1); std::vectorstd::unique_ptrAnimal zoo; zoo.push_back(std::make_uniqueDog(Xiaohei, 2)); zoo.push_back(std::make_uniqueCat(Xiaobai, 3)); zoo.push_back(std::make_uniqueDuck(Xiaohuang, 1)); for (const auto animal : zoo) { animal-speak(); } makeSound(dog); makeSound(cat); makeSound(duck); return 0; }運行結果Xiaohei says: Wang! Wang! Xiaobai says: Miao~ Xiaohuang says: Quack! Wangcai says: Wang! Wang! Kitty says: Miao~ Donald says: Quack!4.3 為什么這里必須用指針或引用注意我在main里用了std::unique_ptrAnimal來存對象而不是std::vectorAnimal。這是有講究的。std::vectorAnimal要求 Animal 是可以實例化的對象數組但 Animal 是抽象類本身就不能這樣用。退一步說就算 Animal 不是抽象類std::vectorAnimal存 Dog 也會觸發(fā)對象切片問題Dog 對象被拷貝進一個 Animal 大小的空間Animal 部分被保留Dog 特有的數據被切掉虛函數表指針也被替換成 Animal 的。丟進去的狗取出來就只能當普通動物用了多態(tài)徹底失效。對象切片是C新手最容易踩的隱形坑之一。來看一個最小復現場景void processByValue(Animal animal) { animal.speak(); } Dog dog(Wangcai, 2); processByValue(dog); // 永遠調用 Animal::speak()而不是 Dog::speak()所以規(guī)則可以記成一句話只要你想用多態(tài)基類參數就必須寫成引用或指針。傳值必然切片無一例外。4.4 虛析構函數多態(tài)的收尾動作我在 Animal 類里寫了virtual ~Animal() default;這一行看著不起眼但少寫它會造成真實的內存泄漏。假設你寫的是class Animal { public: virtual void speak() const 0; // 注意沒有虛析構函數 }; class Dog : public Animal { public: Dog() default; private: std::unique_ptrint data_ std::make_uniqueint(42); }; Animal* ptr new Dog(); delete ptr; // 危險delete ptr時因為基類析構函數不是 virtual編譯器會調用Animal::~Animal()而不會調用Dog::~Dog()。結果data_這個成員永遠沒有機會被釋放內存泄漏就發(fā)生了。規(guī)則是只要基類里有虛函數就應當在基類里提供虛析構函數。哪怕是空實現 default都行。5. 常見問題與排查技巧實錄5.1 派生類里忘了寫 override 會發(fā)生什么我在前面提過簽名不匹配的問題這里用完整例子展示后果class Dog : public Animal { public: void speak() const override { ... } // 正確 // void speak() override { ... } // 錯誤少了 const編譯失敗 };如果不寫 override第二個版本會靜默通過編譯但同時它不會覆蓋基類虛函數而是引入一個新的Dog::speak()。這時候多態(tài)失效而你根本不知道問題出在哪。排查這類問題的第一手段就是全局搜一下代碼里哪些函數意圖覆蓋基類函數然后逐個補上override讓編譯器來幫你驗證。5.2 構造函數里調用虛函數多態(tài)為什么失效這是個很經典的問題class Animal { public: Animal() { speak(); // 想在這里調用子類的 speak() } virtual void speak() const 0; };你期望創(chuàng)建 Dog 時構造函數調用的是 Dog::speak()但實際調用的是 Animal 版本。原因在于對象構造時先構造基類部分此時派生類部分還不存在動態(tài)類型就是 Animal 本身。在基類構造函數執(zhí)行期間虛函數分派只作用于當前正在構造的這個類的級別。如果你在構造函數里依賴多態(tài)行為幾乎可以肯定設計有誤。更合理的做法是把初始化工作移到子類構造函數里或者在構造完成之后再調用一個單獨方法。5.3 虛函數表指針是否會被繼承很多初學者會問vptr 是繼承下來的還是每個類都有自己的準確的說法是每個包含虛函數的類都有自己的虛函數表和自己的 vptr但派生類對象的 vptr 在內存布局上和基類的是同一個位置繼承相當于占用了基類 vptr 的位置只不過指向了派生類自己的虛函數表。這也解釋了為什么對象切片會破壞多態(tài)切完之后的臨時 Animal 對象vptr 已經被重置為 Animal 虛函數表的地址。5.4 常見問題速查表問題現象根本原因解決方案派生類函數沒被調用調用的是基類版本忘了加 virtual 或 override簽名不匹配基類加 virtual子類加 override用 vectorAnimal 存 Dog多態(tài)失效對象切片改用 vectorunique_ptrAnimal 或指針數組delete 基類指針后內存泄漏基類沒有虛析構函數給基類加 virtual ~Animal()派生類構造時調用的虛函數不對構造期間動態(tài)類型未完成不在構造函數中依賴多態(tài)派生類里定義了同名不同參函數基類重載不可用名字遮蔽在派生類里用 using Base::func; 引入基類重載5.5 幾個我實測后想額外叮囑的細節(jié)代碼寫多之后你會發(fā)現虛函數和默認參數搭配會有奇怪行為。默認參數是靜態(tài)綁定的意思是即便調用的是派生類的虛函數默認參數卻取自靜態(tài)類型基類的定義。舉例class Animal { public: virtual void speak(int times 3) const { std::cout times std::endl; } }; class Dog : public Animal { public: void speak(int times 5) const override { std::cout times std::endl; } }; Animal animal dog; animal.speak(); // 輸出 3不是 5原因就是默認參數在編譯期就寫死在調用點上了。這個坑很難從代碼表面看出來我的建議是虛函數不寫默認參數或者保證基類和派生類的默認參數完全一致避免產生視覺混淆。另一個細節(jié)是關于final關鍵字。如果某個類不想再被繼承可以直接class Dog final : public Animal或者某個虛函數不想再被子類覆蓋寫成virtual void speak() const override final。這相當于把設計意圖明確地告訴編譯器也能在犯錯時提早暴露問題。最后說一句關于這個例子本身的體會。Animal 、Dog、Cat 這個教學案例從語法層面看很短但它覆蓋了繼承訪問控制、構造順序、名字遮蔽、虛函數、純虛函數、虛析構、對象切片這么一堆東西。真正把它跑通并且把每一個為什么都解釋清楚之后你對C的整個對象模型都會有一個質的認識。我個人的建議是不要只看代碼就覺得自己會了把你見到的每個類都手動加一個派生類多試試不同的繼承方式和調用方式哪怕故意寫錯把編譯器報錯讀明白才是把知識變成手藝的過程。