初學者剛接觸C++語言中的 virtual 函數(虛函數)時,常常會感覺到迷惑,比如,書上說虛函數定義在基類中,其他繼承此基類的派生類都可以重寫該虛函數,因此虛函數是C++語言多態特性中非常重要的概念。但是派生類也可以重寫基類中的其他的常規函數(非虛函數)呀,那為什麼還要引入虛函數這樣看起來很複雜的概念呢?
「貓吃老鼠」
本文不打算從理論上探討C++語言引入虛函數的原因,那樣太枯燥乏味了,我們先來看一個例子,直觀上感覺下常規(非虛)函數在面向對象編程中的局限性,請看:
上面這段C++語言代碼定義了Animal(動物)和Cat(貓)兩個類,其中Cat繼承了Animal(貓顯然是動物),我們可以在 main() 函數中使用這兩個類:
Animal *animal = new Animal;
Cat *cat = new Cat;animal->eat();
// 輸出: "I'm eating generic food."
cat->eat();
// 輸出: "I'm eating a rat."
到這裡一切都挺好的,動物吃食物,貓吃老鼠,即使不使用virtual關鍵字定義虛函數也完全沒有問題。現在稍稍修改下這段C++語言代碼,引入另外一個函數func()(暫時不考慮實用性,僅做示例),它的代碼如下,請看:
void func(Animal *xyz){
xyz->eat();
}
在 main() 函數中調用 func() 函數,相關的C++語言代碼示例如下,請看:
Animal *animal = new Animal;
Cat *cat = new Cat;func(animal);
// 輸出: "I'm eating generic food."
func(cat);
// 輸出: "I'm eating generic food."
出問題了~請注意第二個 func() 函數調用,我們傳遞了一個Cat對象指針給它,但是輸出的卻不是「I'm eating a rat.」!仔細觀察一下,發現 func() 函數的參數類型是Animal *xyz,那麼為了讓 func() 函數也能輸出「I'm eating a rat.」,只能重載 func() 函數了:
void func(Animal *xyz){
xyz->eat();
}
void func(Cat *xyz){
xyz->eat();
}
現在 func() 函數能夠根據輸入參數類型的不同,輸出不同的內容了。可是問題來了,Animal(動物)是一個基類,它能派生出的動物遠遠不止Cat(貓)一種,若是派生出許多派生類,每個派生類都重載一遍 func() 函數,是不是太麻煩了?
神奇的虛函數
在C++語言中,重寫基類中的常規(非虛)函數當然是可以的,但是從上面的例子可以看出,重寫常規函數實現多態有時會帶來非常麻煩的問題,要避免這樣的問題可以使用 virtual function(虛函數)。現在,我們在 Animal 基類的 eat() 成員函數前加上virtual關鍵字:
此時無需重載 func() 函數,僅保留一份func() 函數:
void func(Animal *xyz){
xyz->eat();
}
再執行下面的C++語言代碼,輸出就不同了:
func(animal);
// 輸出: "I'm eating generic food."
func(cat);
// 輸出: "I'm eating a rat."
看來,C++語言中的虛函數的確有些過人之處,有必要好好了解一下它。
事實上,每一個C++程式設計師都不可能避開虛函數的,就像每一個C語言程式設計師都不可能避開指針一樣。
再總結下「什麼是虛函數」
理論是少不了的,它可以加深和儘可能全面的幫助我們理解概念。基類中的虛函數允許派生類重寫功能,編譯器會保證派生類對象使用的是自己重寫的功能,即使對象是通過基類指針訪問的,例如前文中的 func(Animal *xyz) 函數,func(cat) 輸出的實際上是 Cat 類重寫的功能。這是一個非常有用的特性,調用者甚至都不需要知道 Cat 等派生類的實現,因為只需使用基類 Animal 指針就能夠輕易的調用所有派生類的重寫功能。
基類的虛函數可以完全被重寫,也可以部分的被重寫,所謂的「部分被重寫」,其實就是派生類在重寫基類虛函數時,也可以調用基類虛函數的功能。
虛函數和常規函數被調用時有什麼不同?
常規的非虛函數是靜態解析的,即在編譯時即可根據指針指向的對象確定是否被調用,例如文章開頭的例子,如果 eat() 函數是非虛函數:
// eat() 函數不是虛函數
Animal *animal = new Animal;
Cat *cat = new Cat;
animal->eat();
// 輸出: "I'm eating generic food."
cat->eat();
// 輸出: "I'm eating a rat."
此時編譯器在編譯時就能確定 animal->eat() 調用的是 Animal::eat() 函數,cat->eat() 調用的是 Cat::eat() 函數。在 func(Animal *xyz) 函數中,因為其形參是 Animal *指針類型,所以即使傳入的是 cat 對象指針,在 func() 函數內部也會被強制轉換為Animal *指針,因此 func(cat) 調用的仍然是 Animal::eat() 函數。
而虛函數就不同了,它是動態解析的,也即在程序被編譯後,運行時才根據對象的類型,而不是指向對象的指針類型決定其是否被調用,這就是說為的「動態綁定」。
關於「動態綁定」,我們在下一節中再做詳細討論,這裡先留個印象。
在C++語言中,如果某個類有虛函數,那麼大多數編譯器都會自動的為其對象維護一個隱藏的「虛指針(virtul-pointer)」,虛指針指向一個全局「虛表(virtual-table)」,虛表中存放若干函數指針,這些函數指針指向類中的虛函數。請看下面這段C++語言代碼:
class A {
public:
virtual void vfoo1();
virtual void vfoo2();
void foo1();
void foo2();
private:
int prv_i1, prv_i2;
};
顯然,類 A 有兩個常規函數以及兩個 int 型的成員變量,此外,它還有兩個虛函數,因此編譯器會創建一個虛表,虛表中存放的是函數指針,它們分別指向類 A 的虛函數,如下圖所示:
這裡應注意,虛表是屬於類的,而不是對象的,也就是說,即使有成千上萬個 A 對象,虛表也僅有一個,這些對象共用一個類虛表。編譯器會自動的為每個對象創建一個隱藏的「虛指針」__vptr,它指向類 A 的虛表,如下圖所示:
C++語言這樣實現虛函數機制的空間開銷是微乎其微的,事實上,每一個對象只需要一個額外的「虛指針」__vptr就能夠調用類的虛函數。同樣的,時間開銷也很小:相比於常規函數的調用,虛函數的調用只不過多出了額外的兩個步驟:
獲取虛表指針,得到虛表從虛表中取出虛函數的地址讀者應注意,這裡的討論為了突出主題,理想化了一些情況。
為什麼類成員函數默認都不是虛函數?
既然虛函數這麼好用,那為什麼C++語言不把類的成員函數默認定義為虛函數呢?
其實仔細考慮一下,虛函數的「好用」主要體現在定義在基類中實現多態上,但並不是所有的類都需要被設計為基類,一昧的使用虛函數可能會造成語義上的歧義,隱藏程式設計師的設計。僅將需要被繼承的基類中需要被重寫的函數定義為虛函數,要比將所有函數定義為虛函數清晰多了。
此外,經過前面的討論,我們也知道虛函數的效率實際上是沒有常規函數高的,同樣的功能中,僅從被調用過程來看,它的時間開銷和空間開銷都比常規函數高,每個對象還需要額外的虛指針索引虛表。
小結
歡迎在評論區一起討論,質疑。文章都是手打原創,每天最淺顯的介紹C語言、linux等嵌入式開發,喜歡我的文章就關注一波吧,可以看到最新更新和之前的文章哦。
未經許可,禁止轉載。