C 多型技術的實現和反思

2021-05-27 13:06:02 字數 3542 閱讀 1423

楊喜敏  孟巖  

本文摘自《程式設計師》2023年11期

物件導向技術最早出現於2023年代的simula 67系統,並且在2023年代保羅阿托實驗室開發的smalltalk系統中發展成熟。然而對於大部分程式設計師來說,c++是第乙個可用的物件導向程式設計語言。因此,我們關於物件導向的很多概念和思想直接來自於c++。但是,c++在實現物件導向中關鍵的多型性時,選擇了與smalltalk完全不同的方案。其結果是,儘管在表面上兩者都實現了相似的多型性,但是在實踐中卻有著巨大的區別。具體的說,c++的多型性實現更加高效,但是並不適用於所有場合。很多經驗不足的c++開發者不明白這個道理,在不合適的場合強行使用c++的多型性機制,落入削足適履的陷阱而不能自拔。本文將詳細**c++多型性技術的侷限性及解決的辦法。

兩種不同虛方法呼叫實現技術

c++的多型性是c++實現物件導向技術的基礎。具體的說,通過乙個指向基類的指標呼叫虛成員函式的時候,執行時系統將能夠根據指標所指向的實際物件呼叫恰當的成員函式實現。如下所示:

class base

};class derived : public base

};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演算法獲得最優的雜湊函式。

事實上,我們還可以這樣理解這種方案的優勢,把表中每一項的「方法名」項視為「方法位址」項的描述資訊,因此可以認為這種方案中的方法查詢表攜帶自描述資訊(或者稱為元資料)。基於這種攜帶自描述資訊的資料結構,可以實現豐富多彩的擴充套件功能,比如在執行時

插入新的方法,或者使用者層次上的方法呼叫截獲等。因此,我們可以說這一方案的適用面廣,強大靈活,但在執行效率上並非最優。

另一種虛方法查詢方案則是c++ 開發者十分熟悉的,基於絕對位置的定位技術。其查詢表結構非常簡單,僅僅是乙個存放了方法位址的指標陣列。表中的每一項不具有自描述性,只有編譯器在編譯時知道它們究竟分別對應著哪乙個方法,並且將對於方法的呼叫**編譯成乙個緊湊的指標+偏移的呼叫的硬編碼。這種查詢表的最大特點就是高效率,基於這種查詢表進行方法呼叫僅僅需要多做一次陣列內的隨機訪問操作。在所有我們所能想到的「增加乙個間接層」的方案中,這種方案在效率上是最高的。但是使用這種方案有乙個限定,就是要求所有同族多型物件具有完全一樣的查詢表。也就是說,你必須確保所有實現了某個介面的物件的虛方法查詢表的第k 項都具有相同的語義。假設乙個基類有100個可供改寫的虛方法,那麼它的虛方法查詢表共有100項(實際上就是100個指向方法入口位址的指標)。而其所有派生類物件都必須有結構上完全相同的、長度至少為100項的虛方法查詢表。現在假設我們開發的乙個派生類中只改寫了基類的5個方法,那麼這個派生類物件所共享的虛方法表仍然長達100項,只不過其中95項與其基類物件虛方法查詢表中相應的項一模一樣,只有5項具有實際意義——正是這5項的存在才使派生類的存在有了意義。

在這種情況下,該方法表的實際有效利用率只有可憐的5%。總的來說,這一方案執行效率最優,但是並不適用於所有的場合。

當然,看上去上述兩種虛方法呼叫實現技術效果完全一樣,一切都被掩蓋在編譯器之下,與一般開發者毫無關係。但是,事實真的如此嗎?我們在下面會看到,c++ 的這種查詢表結構構成了c++應用開發中最險惡的技術陷阱之一。

兩種不同的多型性應用場景

或許有讀者讀到這裡,會對c++產生很大的懷疑。需要說明的是,c++選擇的多型性實現技術是完全符合c++哲學的。而且,c++允許你以各種可能的辦法來解決這個問題。時至今日,依靠各種成熟的gui框架,大多數情況下我們可以自動繞過暗礁。

問題的嚴重性在於,由於c++教育上的問題,很多開發者對於c++原生多型技術在上述第二種應用場合中的侷限性認識不足,因此當他們面臨類似的問題時,會不自覺地踏入陷阱中。在此我願提醒c++開發者,當你面對的系統中含有標準的事件處理特徵,而且事件數量較大時,請慎重考慮你的類層次結構設計。可以考慮模仿mfc或者qt的解決方法,但在我看來,乙個更加直接而且簡單的方法是,模擬本文第1節中描述的、基於字串比較的方法查詢表,用乙個單一的訊息分發物件來向各個物件分發訊息。由於這個訊息分發物件會經常需要調整變化,將它單獨放在乙個dll 甚至com元件中,在執行時載入到程序內。這種方案不是最精巧的,但是在大多數情況下有效,並且實現起來比較簡單。限於篇幅,這裡不詳細描述。

事實上,我本人認為,c++語言應當從編譯器上解決這個問題。基本思路為,當基類虛方法數量大而派生類改寫的方法數量小的時候(這個資訊可以從編譯過程中得到),改變派生類物件的虛方法查詢機制,改按位置查詢為按被呼叫函式實際資訊查詢。這樣一來,派生類中的虛方法錶可不必與基類保持結構上的一致,從而避免了空間上的浪費。這種思路跟delphi/object pascal語言中dynamic關鍵字有相似之處。本文不再贅述。

C 多型技術 靜態多型和動態多型

多型 polymorphism 一詞最初 於希臘語polumorphos,含義是具有多種形式或形態的情形。在程式設計領域,乙個廣泛認可的定義是 一種將不同的特殊行為和單個泛化記號相關聯的能力 和純粹的物件導向程式設計語言不同,c 中的多型有著更廣泛的含義。除了常見的通過類繼承和虛函 數機制生效於執行...

C 多型的實現和原理

c 的多型性用一句話概括就是 在基類的函式前加上virtual關鍵字,在派生類中重寫該函式,執行時將會根據物件的實際型別來呼叫相應的函式。如果物件型別是派生類,就呼叫派生類的函式 如果物件型別是基類,就呼叫基類的函式 1 用virtual關鍵字申明的函式叫做虛函式,虛函式肯定是類的成員函式。2 存在...

c 多型的定義和實現

編譯時多型 在程式編譯過程 現,發生在模板和函式過載中 泛型程式設計 執行時多型 在程式執行過程 現,發生在繼承體系中,是指通過基類的指標或引用訪問派生類中的虛函式。多型就是不同繼承類的物件,對同一訊息做出的不同響應,基類的指標指向或繫結到派生類的物件,使得基類指標呈現不同的表現形式。構成多型的條件...