C++中的虛擬函式與靜態聯編和動態聯編
程式在呼叫函式時,將使用哪個可執行程式碼塊呢?編譯器負責回答這個問題,將原始碼中的函式呼叫解釋為執行特定的函式程式碼塊被稱為函式名聯編。在C中,因為每個函式名都對應一個不同的函式,而在C++中,由於函式過載的緣故,編譯器必須檢視函式引數以及函式名才能確定使用哪個函式。然而,C/C++編譯器可以在編譯過程中完成這種聯編,在編譯過程中進行聯編稱為靜態聯編,又稱為早期聯編;然而,虛擬函式使得這項工作變得困難,在有虛擬函式的類中,使用哪一個函式是不能再編譯時確定使用的,因為編譯器不知道使用者將選擇哪種型別的物件,所以,編譯器必須生成能夠在程式執行時選擇正確的虛方法的程式碼,這被稱為動態聯編,又稱晚期聯編。
1、指標和引用型別的相容性
在C++中,動態聯編與通過指標和引用呼叫方法相關,從某種程度上來說,這是由繼承控制的,公有繼承建立is-a關係的一種方法是如何處理指向物件的指標和引用。指向基類的引用或指標可以引用派生類物件,而不必進行顯式型別轉換,例如:BrassPlus dilly("Annie Dill",123,2000);Brass *pb = &dilly;Brass &rb = dilly;將派生類引用或指標轉換為基類引用或指標被稱為向上強制轉換,這使公有繼承不需要進行顯式型別轉換。相反,將基類指標或引用轉換為派生類指標或引用,稱為向下強制轉換,如果不使用顯式型別轉換,則向下強制型別轉換是不允許的。原因是is-a關係通常是不可逆的。以上規則是is-a關係的一部分。
2、虛成員函式和動態聯編//show是Brass和BrassPlus的虛擬函式 void fr(Brass &rb); void fp(Brass *pb); void fv(Brass b); int main() { Brass b("Billy Bee",123,1000); BrassPlus bp("Betty Beep",456,2000); fr(b); //呼叫Brass中的show(); fr(bp); //呼叫BrassPlus中的show(); fp(b); //呼叫Brass中的show(); fp(bp);//呼叫BrassPlus中的show(); fv(b); //呼叫Brass中的show(); fv(bp);//呼叫Brass中的show(); }
編譯器對非虛方法使用靜態聯編。編譯器在編譯時聯編。編譯器對虛方法使用動態聯編。如:BrassPlus
opjelia;Brass *bp;bp = &ophelia;bp->show();如果show是非虛,則編譯器使用靜態聯編,指標型別已知,呼叫基類Brass::show();如果show是虛擬函式,則編譯器使用動態聯編,只有程式在執行時才確定物件型別,呼叫派生類BrassPlus::show();
(1)靜態聯編的效率更高,如果要在派生類中重新定義基類的方法,則將它設定為虛方法,否則,設定為非虛方法。
(2)虛擬函式的工作原理:編譯器處理虛擬函式的方法是:給每個物件新增一個隱藏成員,隱藏成員中儲存了一個指向函式地址陣列的指標(陣列的指標,陣列的型別是函式的地址,即也是個指標)。這種陣列稱為虛擬函式表(virtual function table,vtb1)。虛擬函式表中儲存了為類物件進行宣告的虛擬函式的地址。例如,基類包含一個指標,該指標指向基類中所有虛擬函式的地址表,派生類物件包含一個指向獨立地址表的指標,如果派生類提供了虛擬函式的新定義,該虛擬函式表將儲存新函式的地址;如果派生類沒有重新定義虛擬函式,該vtb1將儲存函式原始版的地址,如果派生類定義了新的虛擬函式,則該函式的地址也新增到vtb1中。注意,無論類中包含的虛擬函式是1個還是10個,都只需在物件中新增1個地址成員,只是表得大小不同而已。
總之:使用虛擬函式時,在記憶體和執行速度方面有一定的成本,包括:
(1)每個物件都增大,增大量為儲存地址的空間;
(2)對於每個類,編譯器都建立一個虛擬函式地址表(指標陣列);
(3)對於每個函式呼叫,都需要執行一項額外的操作,即到虛擬函式表中查詢地址,雖然非虛擬函式的效率比虛擬函式的效率高,但不具備動態聯編功能。
3、有關虛擬函式的注意事項
(1)建構函式:建構函式不能為虛擬函式。建立派生類物件時,將呼叫派生類的建構函式,而不是基類的建構函式,然後派生類的建構函式將使用基類的一個建構函式,這種順序不同於繼承機制,因此,派生類不繼承基類的建構函式,所以將類建構函式宣告為虛擬函式沒有什麼意義。
(2)解構函式:解構函式應該是虛擬函式,除非類不用做基類。例如Employee是基類,Singer是派生類,並新增一個char*成員,該成員指向有new分配的記憶體,當singer物件過期時,必須呼叫~singer()解構函式來釋放記憶體。如:Employee *pe = new singer;……delete pe;如果使用預設解構函式,則使用靜態聯編,delete語句將呼叫~Employee()解構函式,釋放singer中Employee部分指向的記憶體,但不會釋放新的類成員指向的記憶體,如果解構函式是虛的,則上述程式碼先呼叫singer的解構函式釋放singer元件指向的記憶體,然後呼叫~Employee()解構函式釋放有Employee元件指向的記憶體。
給類定義一個虛解構函式並非錯誤,即使這個類不用做基類,這只是一個效率方面的問題。通常給一個基類提供一個虛解構函式。
(3)友元:友元不能是虛擬函式,因為友元不是類成員,而只有成員才能是虛擬函式。
(4)沒用重新定義:如果派生類沒有重新定義函式,將使用該函式的基類版本,如果派生類位於派生鏈中,則將使用最新的虛擬函式版本,例外的情況是基類版本是隱藏的。
(5)重新定義將隱藏方法:1、如果重新定義繼承的方法,應確保與原來的原型完全相同,但如果返回型別時基類引用或指標,則可以修改為指向派生類的引用或指標。這種特性被稱為返回型別協變。這種例外只適用於返回值,不適用於引數。2、如果基類宣告被過載了,則應在派生類中重新定義所有的基類版本。如:
class Dwelling
{
public:
virtual void showperks(int a) const
virtual void showperks(double x) const
virtual void showperks() const;
};
class Hovel:public Dwelling
{
virtual void showperks(int a) const
virtual void showperks(double x) const
virtual void showperks() const;
};
如果只重新定義一個版本,則另外兩個版本將被隱藏,如果不需要修改,則新定義可以只調用基類版本;void Hovel::showperks()const {Dewlling::showperks();}