?? 本期博客,我們來介紹C++中的虛函數(shù),并給出一些實際操作的建議。?
什么是虛函數(shù)?
虛函數(shù)是基類中聲明的成員函數(shù),且使用者期望在派生類中將其重新定義。那么,在?C++ 中,什么是虛函數(shù)呢?在?C++ 中,通常將虛函數(shù)用于實現(xiàn)運行時多態(tài),該特性由 C++ 提供,適用于面向對象編程。我們將在下文更為詳細地討論運行時多態(tài)。不論函數(shù)調用所使用的指針或引用類型如何,虛函數(shù)最為重要的工作是確保函數(shù)調用正確。
1虛函數(shù)的使用規(guī)則
C++?虛函數(shù)必須遵循幾個關鍵規(guī)則:
在基類中使用?virtual?關鍵詞來聲明函數(shù)
虛函數(shù)不能為靜態(tài)函數(shù)
為實現(xiàn)運行時多態(tài),應使用指針或引用來訪問虛擬函數(shù)
對于基類和派生類而言,此類函數(shù)的原型應該相同(允許使用協(xié)變式返回類型,我們將在下文進行討論)
如果基類中含有虛函數(shù),則應該使用虛擬析構函數(shù),防止析構函數(shù)調用錯誤
2 用 C++ 運行虛函數(shù)的示例
虛函數(shù)在?C++ 中的運行情況:
?
class Pet { public: virtual ~Pet() {} virtual void make_sound() const = 0; }; class Dog: public Pet { public: virtual void make_sound() const override { std::cout << "raf raf "; } }; class Cat: public Pet { public: virtual void make_sound() const override { std::cout << "mewo "; } }; int main() { Cat mitzi; Dog roxy; Pet *pets[] = {&mitzi, &roxy}; for(auto pPet: pets) { pPet->make_sound(); } }解釋一下上述示例。 Pet 這是一個通用基類。但是我們仍然希望存在一個 make_sound 函數(shù),這樣,我們就能在不知道 pet 類型的情況下,在 pet 上調用 make_sound。僅在進行編譯時,我們才能知道 pet 類型。因此,我們在基類中聲明虛函數(shù) make_sound,用 =0 來將其表示為由派生類實現(xiàn)的純虛函數(shù)。 然后,再由 Dog 和 Cat 來真正實現(xiàn)該函數(shù)。實現(xiàn)函數(shù)期間,我們添加關鍵詞 override,這樣,編譯器就能確保函數(shù)簽名與基類中的簽名相匹配。 在 main 中,我們可以在 Pet 指針上調用 make_sound,而無需在編譯時知道該指針指向哪種 pet。我們會在運行時,根據(jù)實際存在的對象,實現(xiàn)所需函數(shù)。 我們必須要強調,這是一個非常簡單的示例。我們也有其他解決方案應對這一簡單示例(例如,為 pet’s sound 持有數(shù)據(jù)成員,并避免使用虛函數(shù))。但我們想要展示虛函數(shù)的實現(xiàn)過程,因此不對其他解決方案進行額外展示。通常情況下,會使用虛函數(shù)為派生類中的不同行為建模,而相應行為不能用簡單數(shù)據(jù)成員來建模。 ?
3 協(xié)變式返回類型
我們提到過,若要實現(xiàn)虛函數(shù),派生類函數(shù)的簽名必須與基類中的簽名相匹配。唯一允許的區(qū)別是在返回類型上,只要派生類的返回類型是基類返回的派生類型即可。讓我們看看下面的示例:
class PetFactory { public: virtual ~PetFactory() {} virtual Pet* create() const = 0; } class DogFactory: public PetFactory { public: virtual Dog* create() const override { return new Dog(); } }; class CatFactory: public PetFactory { public: virtual Cat* create() const override { return new Cat(); } }; int main() { std::vector在上述示例中,PetFactory 創(chuàng)建函數(shù)僅能知道它可以返回 Pet*,但使用協(xié)變式返回類型,DogFactory 和 CatFactory 則能知道更為具體的內容,這種虛函數(shù)的實現(xiàn)方式仍然行之有效。??pets; DogFactory df; CatFactory cf; PetFactory* petFactory[] = {&df, &cf}; for(auto factory: petFactory) { pets.push_back(factory->create()); } for(auto pPet: pets) { pPet->make_sound(); } for(auto pPet: pets) { delete pPet; } }
在 C++ 中使用虛函數(shù)的優(yōu)點
現(xiàn)在,如果您已經(jīng)花費時間研究過 C++,可能會注意到,不需要由虛函數(shù)來重新定義派生類中的基函數(shù)。但存在這樣的巨大區(qū)別,使得虛函數(shù)不可或缺:虛函數(shù)覆寫基類函數(shù),從而實現(xiàn)運行時多態(tài)。從本質上講,多態(tài)指一個函數(shù)或對象以不同方式執(zhí)行的能力,具體情況視使用方式而定。這屬于面向對象編程的關鍵特性——結合其他眾多特性,使得 C++ 作為編程語言而有別于 C 語言。
1 代碼更為靈活、更為通用
這是貫穿所有多態(tài)程序的主要優(yōu)點:根據(jù)運行時已知的調用對象,通過允許以不同方式執(zhí)行函數(shù)調用,能使程序更為靈活而通用。如此一來,運行時多態(tài)便能從真正意義上使您的代碼反映現(xiàn)實——特別是各場景中的對象(或人、動物、形狀)并不總是以相同方式執(zhí)行。 ?
2 代碼可復用
通過使用虛函數(shù),我們可以將只應實現(xiàn)一次的通用操作和不同子類中可能有所不同的具體細節(jié)區(qū)分開來。試想以下示例:如果我們希望實現(xiàn) prism 類層次結構,則需要在各派生類中分別計算基面積,但可以使用派生類實現(xiàn)基面積計算,從而在基類中實現(xiàn)體積函數(shù)。實現(xiàn)代碼如下:
class Prism { double height; public: virtual ~Prism() {} virtual double baseArea() const = 0; double volume() const { return height * baseArea(); } // ... }; class Cylinder: public Prism { double radius; public: double baseArea() const override { return radius * radius * std::pi } // ... };3 契約式設計
術語“契約式設計”指如果代碼設置有執(zhí)行設計的契約,會比只通過文檔來執(zhí)行設計要好得多。虛函數(shù),特別是純虛函數(shù),因其決定了在派生類中以不同方式重新實現(xiàn)特定操作的設計決策,可將其視為契約式設計工具。 ?
虛函數(shù)的局限性
虛函數(shù)功能極為強大,但它們并非毫無缺點。開始使用虛函數(shù)前,您應該注意以下事項:
1 性能
無論是在運行時性能還是在內存方面,虛函數(shù)成本都要比普通函數(shù)高。
內存部分通常冗余,取決于實現(xiàn)方式,但最為常見的是每個對象都有一個額外內部指針。這并不是什么大問題,除非我們有數(shù)以百萬計的小對象,這些小對象的額外指針可能會引起內存問題。
函數(shù)的運行時性能成本不是一次跳轉而是兩次跳轉,或者如果可以內聯(lián)函數(shù),性能成本就是兩次跳轉而不是零次跳轉。虛函數(shù)需要跳轉到虛函數(shù)表,再跳轉到函數(shù)本身。這種額外跳轉增加了 CPU 指令緩存中指令未準備就緒的概率,因此,這兩次跳轉并非唯一成本。
最后,如果您需要實現(xiàn)多態(tài),與其他替代方案相比,性能方面的額外成本通常也在情理之中。然而,若要將第一個虛函數(shù)添加到類中,通常需要考慮額外成本。
2設計問題
繼承,特別是虛函數(shù),會引起設計問題。繼承層次結構設計糟糕可能會導致類膨脹和類之間關系異常。
從構造函數(shù)和析構函數(shù)調用虛函數(shù)的規(guī)則也會影響您的設計。從構造函數(shù)和析構函數(shù)調用的任何虛函數(shù)都不是多態(tài)函數(shù),這樣一來,有時需要將操作從構造函數(shù)轉移到 init 虛擬函數(shù)。
為避免糟糕設計,應切記繼承和多態(tài)并非是應對任何問題的最佳解決方案。請觀看 Sean Parent 的演講”Inheritance is the Base Class of Evil“,深入了解相關內容。
3 調試,容易出錯
諷刺的是,虛函數(shù)面臨的挑戰(zhàn)之一是缺乏彈性。
由于需要遵循調用流程,調試虛函數(shù)調用可能會變得稍顯混亂。一般來說,遵循函數(shù)調用并不十分困難,但根據(jù)對象類型,在遵循隱藏調度方面,仍然需要進行額外工作。調試器會自行糾正錯誤,但決定斷點位置可能會變得更加困難。
至于更容易出錯,在某些情況下,不應調用虛函數(shù)的基類實現(xiàn);而在某些情況下,應在開始時調用,有時也在結束時調用。由于忘記調用基類實現(xiàn),或是在錯誤的地方、不需要的時候調用,使用虛函數(shù)極其容易出錯。
可將其視為契約式設計工具。
虛函數(shù)的替代方案
1 僅使用數(shù)據(jù)成員
第一種替代方案是嘗試并對基于簡單數(shù)據(jù)成員的不同行為進行建模。如果不同類型的唯一區(qū)別是 sound,那就將其轉換為數(shù)據(jù)成員,在構造時進行初始化,這樣就沒有問題了。但在許多情況下,行為更加復雜,需要不同的實現(xiàn)方式。 ?
2 變體
另一種方案是使用 std::variant 和 std::visit,特別是待支持的不同類型已知,且列表不會太長時,二者可能相關。您可以點擊此處和此處閱讀更多關于該方案的信息。 ?
3 函數(shù)式編程
您可以傳遞待執(zhí)行的操作,將其作為函數(shù)對象的 lambda,或者作為舊有 C 樣式的函數(shù)指針,隨后對其進行建模,而無需在類層次結構中對不同操作進行建模。通過該方法,您能將數(shù)據(jù)模型和可能想要執(zhí)行的操作區(qū)分開來,這帶來了極高靈活性。 ?
4 靜態(tài)多態(tài)
靜態(tài)多態(tài)是一種基于模板的方法,用于獲取多態(tài)動態(tài),但基于編譯時已知的實際想要使用的類型。例如,您可能希望代碼同時支持 UDPConnection 和 TCPConnection,但在編譯時,您可能想要知道使用 UDPConnection 或 TCPConnection 的具體流程?;谀0宓撵o態(tài)多態(tài)可以實現(xiàn)更佳性能。 一些替代技術可能會導致項目編譯時間變長。我們認為,特別是當您使用 Incredibuild 來加速構建時,這不會影響您的決策設計。首先選擇合適的設計方案,然后使用正確工具來縮減編譯時間即可。
用 Incredibuild 加速您的 C++ 虛函數(shù)
如果您想在不嚴重拖累編譯速度和構建進程的情況下,從虛擬函數(shù)中受益,您就需要強大的計算能力作為后援。 Incredibuild 能夠做到這一點。通過在虛擬機在本地網(wǎng)絡上分配編譯任務,Incredibuild 從根本上加快了 C++ 的編譯速度。此外,Incredibuild 能與時下主流編譯器和構建系統(tǒng)無縫集成,包括 Visual Studio、Qt Creator 和 Clang。 如此一來,虛函數(shù)便能具備極高的靈活性和效率,而無需花費時間來等待代碼編譯。 首先選擇合適的設計方案,然后使用正確工具來縮減編譯時間即可。
總 結
?1. 什么是 C++ 的虛函數(shù)?
虛擬函數(shù)是基類中聲明的成員函數(shù),將在派生類中重新定義。在 C++ 中,使用虛函數(shù)來實現(xiàn)運行時多態(tài)。
2. 虛函數(shù)存在哪些問題?
在運行時性能和內存使用方面,相比于普通函數(shù),虛擬函數(shù)會造成更多影響。此外,虛擬函數(shù)會產(chǎn)生基于繼承層次結構的設計問題,導致類膨脹和關系異常。最后,虛擬函數(shù)由于存在函數(shù)調用問題,往往難以進行調試。由于調用順序的不可預測性,使用虛擬函數(shù)更容易引發(fā)錯誤。
3. 在 C++ 中,虛函數(shù)有何替代方案?
是的,為了實現(xiàn)更好的設計或者是獲得更佳的性能,您可能要考慮一些替代方案。但鑒于 C++ 程序員普遍使用虛函數(shù),您應將其視為工具包內的一項工具,必要時加以使用。 如果您選擇了另一種替代方案,比如基于模板的靜態(tài)多態(tài),切勿讓較長的編譯時間影響您的設計方案。確保選用合適的工具來加速構建進程,如果您沒有使用 Incredibuild,請了解我們的解決方案,看看 Incredibuild 在減少編譯時間方面可實現(xiàn)的驚人效果。
審核編輯:湯梓紅
評論