虛擬函數

虛擬函數

虛擬函數是C++語言引入的一個很重要的特性,它提供了“動態綁定”機制,正是這一機制使得繼承的語義變得相對明晰。對繼承體系的使用者而言,此繼承體系內部的多樣性是“透明的”。它不必關心其繼承細節,處理的就是一組對它而言整體行為一致的“對象”。

虛擬函數的特性


(1)基類抽象了通用的數據及操作,就數據而言,如果該數據成員在各派生類中都需要用到,那麼就需要將其聲明在基類中;就操作而言,如果該操作對各派生類都有意義,無論其語義是否會被修改或擴展,那麼就需要將其聲明在基類中。
(2)有些操作,如果對於各個派生類而言,語義保持完全一致,而無需修改或擴展,那麼這些操作聲明為基類的非虛擬成員函數。各派生類在聲明為基類的派生類時,默認繼承了這些非虛擬成員函數的聲明/實現,如同默認繼承基類的數據成員一樣,而不必另外做任何聲明,這就是繼承帶來的代碼重用的優點。
(3)另外還有一些操作,雖然對於各派生類而言都有意義,但是其語義並不相同。這時,這些操作應該聲明為基類的虛擬成員函數。各派生類雖然也默認繼承了這些虛擬成員函數的聲明/實現,但是語義上它們應該對這些虛擬成員函數的實現進行修改或者擴展。另外在實現這些修改或擴展過程中,需要用到額外的該派生類獨有的數據時,將這些數據聲明為此派生類自己的數據成員。
再考慮更大背景下的繼承體系,當更高層次的程序框架(繼承體系的使用者)使用此繼承體系時,它處理的是一個抽象層次的對象集合(即基類)。雖然這個對象集合的成員實質上可能是各種派生類對象,但在處理這個對象集合中的對象時,它用的是抽象層次的操作。並不區分在這些操作中,哪些操作對各派生類來說是保持不變的,而哪些操作對各派生類來說有所不同。這是因為,當運行時實際執行到各操作時,運行時系統能夠識別哪些操作需要用到“動態綁定”,從而找到對應此派生類的修改或擴展的該操作版本。
也就是說,即只需關心它自己問題域的業務邏輯,只要保證正確,其任務就算完成了。即使繼承體系內部增加了某種派生類,或者刪除了某種派生類,或者某某派生類的某個虛擬函數的實現發生了改變,它的代碼不必任何修改。這也意味著,程序的模塊化程度得到了極大的提高。而模塊化的提高也就意味著可擴展性、可維護性,以及代碼的可讀性的提高,這也是“面向對象”編程的一個很大的優點。

實例展示


下面通過一個簡單的實例來展示這一優點。
假設有一個繪圖程序允許用戶在一個畫布上繪製各種圖形,如三角形、矩形和圓等,很自然地抽象圖形的繼承體系,如圖2-2所示。
圖 2-2
圖 2-2
這個圖形繼承體系的設計大致如下:
為簡單起見,讓每個Shape對象都支持“繪製”和“旋轉”操作,每個Shape的派生類對這兩個操作都有自己的實現:
再來考慮這個圖形繼承體系的使用,這裡很自然的一個使用者是畫布,設計其類名為“Canvas”:
Canvas類中維護一個包含所有圖形的shapes,Canvas類在處理自己的業務邏輯時並不關心shapes實際上都是哪些具體的圖形;相反,如①處和②處所示,它只將這些圖形作為一個抽象,即Shape。在處理每個Shape時,調用每個Shape的某個操作即可。
這樣做的一個好處是當圖形繼承體系發生變化時,作為圖形繼承體系的使用者Canvas而言,它的改變幾乎沒有,或者很小。
比如說,在程序的演變過程中發現需要支持多邊型(Polygon)和貝塞爾曲線(Bezier)類型,只需要在圖形繼承體系中增加這兩個新類型即可:
而不必修改Canvas的任何代碼,程序即可像以前那樣正常運行。同理,如果以後發現不再支持某種類型,也只需要將其從圖形繼承體系中刪除,而不必修改Canvas的任何代碼。可以看到,從對象繼承體系的使用者(Canvas)的角度來看,它只看到Shape對象,而不必關心到底是哪一種特定的Shape,這是面向對象設計的一個重要特點和優點。

虛擬函數的“動態綁定”


虛擬函數的“動態綁定”特性雖然很好,但也有其內在的空間以及時間開銷,每個支持虛擬函數的類(基類或派生類)都會有一個包含其所有支持的虛擬函數指針的“虛擬函數表”(virtual table)。另外每個該類生成的對象都會隱含一個“虛擬函數指針”(virtual pointer),此指針指向其所屬類的“虛擬函數表”。當通過基類的指針或者引用調用某個虛擬函數時,系統需要首先定位這個指針或引用真正對應的“對象”所隱含的虛擬函數指針。“虛擬函數指針”,然後根據這個虛擬函數的名稱,對這個虛擬函數指針所指向的虛擬函數表進行一個偏移定位,再調用這個偏移定位處的函數指針對應的虛擬函數,這就是“動態綁定”的解析過程(當然C++規範只需要編譯器能夠保證動態綁定的語義即可,但是目前絕大多數的C++編譯器都是用這種方式實現虛擬函數的),通過分析,不難發現虛擬函數的開銷:
—空間:每個支持虛擬函數的類,都有一個虛擬函數表,這個虛擬函數表的大小跟該類擁有的虛擬函數的多少成正比,此虛擬函數表對一個類來說,整個程序只有一個,而無論該類生成的對象在程序運行時會生成多少個。
— 空間:通過支持虛擬函數的類生成的每個對象都有一個指向該類對應的虛擬函數表的虛擬函數指針,無論該類的虛擬函數有多少個,都只有一個函數指針,但是因為與對象綁定,因此程序運行時因為虛擬函數指針引起空間開銷跟生成的對象個數成正比。
— 時間:通過支持虛擬函數的類生成的每個對象,當其生成時,在構造函數中會調用編譯器在構造函數內部插入的初始化代碼,來初始化其虛擬函數指針,使其指向正確的虛擬函數表。
— 時間:當通過指針或者引用調用虛擬函數時,跟普通函數調用相比,會多一個根據虛擬函數指針找到虛擬函數表的操作。

內聯函數


因為內聯函數常常可以提高代碼執行的速度,因此很多普通函數會根據情況進行內聯化,但是虛擬函數無法利用內聯化的優勢,這是因為內聯函數是在“編譯期”編譯器將調用內聯函數的地方用內聯函數體的代碼代替(內聯展開),但是虛擬函數本質上是“運行期”行為,本質上在“編譯期”編譯器無法知道某處的虛擬函數調用在真正執行的時候會調用到那個具體的實現(即在“編譯期”無法確定其綁定),因此在“編譯期”編譯器不會對通過指針或者引用調用的虛擬函數進行內聯化。也就是說,如果想利用虛擬函數的“動態綁定”帶來的設計優勢,那麼必須放棄“內聯函數”帶來的速度優勢。
根據上面的分析,似乎在採用虛擬函數時帶來和很多的負面影響,但是這些負面影響是否一定是虛擬函數所必須帶來的?或者說,如果不採用虛擬函數,是否一定能避免這些缺陷?
還是分析以上圖形繼承體系的例子,假設不採用虛擬函數,但同時還要實現與上面一樣的功能(維持程序的設計語義不變),那麼對於基類Shape必須增加一個類型標識成員變數用來在運行時識別到底是哪一個具體的派生類對象:
如①處和②處所示,增加type用來標識派生類對象的具體類型。另外注意這時③處和④處此時已經不再使用virtual聲明。
其各派生類在構造時,必須設置具體類型,以Circle派生類為例:
對圖形繼承體系的使用者(這裡是Canvas)而言,其Paint和RotateSelected也需要修改:

程序功能


因為要實現相同的程序功能(語義),已經看到,每個對象雖然沒有編譯器生成的虛擬函數指針(析構函數往往被設計為virtual,如果如此,仍然免不了會隱含增加一個虛擬函數指針,這裡假設不是這樣),但是還是需要另外增加一個type變數用來標識派生類的類型。構造對象時,雖然不必初始化虛擬函數指針,但是仍然需要初始化type。另外,圖形繼承體系的使用者調用函數時雖然不再需要一次間接的根據虛擬函數表找尋虛擬函數指針的操作,但是再調用之前,仍然需要一個switch語句對其類型進行識別。
綜上所述,這裡列舉的5條虛擬函數帶來的缺陷只剩下兩條,即虛擬函數表的空間開銷及無法利用“內聯函數”的速度優勢。再考慮虛擬函數表,每一個含有虛擬函數的類在整個程序中只會有一個虛擬函數表。可以想像到虛擬函數表引起的空間開銷實際上是非常小的,幾乎可以忽略不計。

性能缺陷


這樣可以得出結論,即虛擬函數引入的性能缺陷只是無法利用內聯函數。
可以進一步設想,非虛擬函數的常規設計假如需要增加一種新的圖形類型,或者刪除一種不再支持的圖形類型,都必須修改該圖形系統所有使用者的所有與類型相關的函數調用的代碼。這裡使用者只有Canvas一個,與類型相關的函數調用代碼也只有Paint和RotateSelected兩處。但是在一個複雜的程序中,其使用者很多。並且類型相關的函數調用很多時,每次對圖形系統的修改都會波及到這些使用者。可以看出不使用虛擬函數的常規設計增加了代碼的耦合度,模塊化不強,因此帶來的可擴展性、可維護性,以及代碼的可讀性方面都極大降低。面向對象編程的一個重要目的就是增加程序的可擴展性和可維護性,即當程序的業務邏輯發生變化時,對原有程序的修改非常方便。而不至於對原有代碼大動干戈,從而降低因為業務邏輯的改變而增加出錯的可能性。根據這點分析,虛擬函數可以大大提升程序的可擴展性及可維護性。
因此在性能和其他方面特性的選擇方面,需要開發人員根據實際情況進行權衡和取捨。當然在權衡之前,需要通過性能檢測確認性能的瓶頸是由於虛擬函數沒有利用到內聯函數的優勢這一缺陷引起;否則可以不必考慮虛擬函數的影響。