面向對象技術最早出現於1960年代的Simula 67系統,並且在1970年代保羅阿托實驗室開發的Smalltalk系統中發展成熟。然而對於大部分程序員來說,C++是第一個可用的面向對象程序設計語言。因此,我們關於面向對象的很多概念和思想直接來自於C++。但是,C++在實現面向對象中關鍵的多態性時,選擇了與Smalltalk完全不同的方案。其結果是,盡管在表面上兩者都實現了相似的多態性,但是在實踐中卻有著巨大的區別。具體的說,C++的多態性實現更加高效,但是並不適用於所有場合。很多經驗不足的C++開發者不明白這個道理,在不合適的場合強行使用C++的多態性機制,落入削足適履的陷阱而不能自拔。本文將詳細探討C++多態性技術的局限性及解決的辦法。
兩種不同虛方法調用實現技術
C++的多態性是C++實現面向對象技術的基礎。具體的說,通過一個指向基類的指針調用虛成員函數的時候,運行時系統將能夠根據指針所指向的實際對象調用恰當的成員函數實現。如下所示:
- class Base {
- public:
- virtual void vmf() { ... }
- };
- class Derived : public Base {
- public:
- virtual void vmf() { ... }
- };
- Base* p = new Base();
- p->vmf(); // 這裡調用Base::vmf
- p = new Derived();
- p->vmf(); // 這裡調用
- // Derived::vmf
- ...
請注意代碼中突出注釋的兩行,雖然其表面語法完全相同,但是卻分別調用了不同的函數實現。所謂的“多態”即就此而言。這些知識是每一個C++開發者都熟知的。
現在我們假設自己是語言的實現者,我們應當如何來實現這種多態性?稍加思考,我們不難得到一個基本的思路。多態性的實現要求我們增加一個間接層,在這個間接層中攔截對於方法的調用,然後根據指針所指向的實際對象調用相應的方法實現。在這個過程中我們人為
增加的這個間接層非常重要,它需要完成以下幾項工作:
1. 獲知方法調用的全部信息,包括被調用的是哪個方法,傳入的實際參數有哪些。
2. 獲知調用發生時指針引用)所指向的實際對象。
3. 根據第1、2步獲得的信息,找到合適的方法實現代碼,執行調用。
這裡的關鍵在於如何在第3 步中找到合適的方法實現代碼。由於多態性是就對象而言的,因此我們在設計時要把合適的方法實現代碼與對象綁定到一起。也就是說,必須在對象級別實現一個查找表結構,根據1、2步獲得的對象和方法信息,在這個查找表中找到實際的方法代碼地址,並加以調用。現在問題變成了,我們應當根據什麼信息進行方法查找。對於這個問題有兩個不同的解決思路,一個是根據名稱進行查找,另一個是根據位置進行查找。粗看上去這兩種思路似乎沒什麼大的差別,但是在實踐中,這兩種不同的實現思路導致了巨大的差別。下面我們詳細地加以考察。
在Smalltalk、Python、Ruby等動態面向對象語言中,實際方法的查找是根據方法名稱進行的,其查找表結構如下:
由於這種查找表根據方法的名稱進行方法查找,因此在查找過程中涉及字符串比較,效率較差。但是這種查找表有一個突出的優點,就是有效空間利用率高。為了說明這一點,我們假設一個基類Base中有100個方法可供派生類改寫因此所有Base對象所共享的方法查找表有100項),而它的一個派生類Derived僅僅只打算改寫其中5個方法,那麼Derived類對象的方法查找表只需要5項。當一個方法調用發生的時候,runtime根據被調用的方法名稱在這個長度為5 的方法查找表中進行字符串查找,如果發現該方法在查找表中,則執行調用,否則將調用轉寄forward)給Base類執行。這是虛方法調用的標准行為。當派生類實際改寫的方法數量很少的時候,可以將查找表安排成線性表,查找時順序比較,這種情況下有效空間利用率達到100%。如果派生類實際改寫的方法數量較多,那麼可以采用散列表,如果采用合理的散列函數,同樣可以在空間利用率很高一般可接近75%).. 的情況下實現方法的快速查找。應當注意到,由於編譯器可以很容易地獲得所有被改寫方法的名稱,因此可以執行標准的gperf算法獲得最優的散列函數。