从C++到.NET 揭开多态的面纱
多態是面向對象理論中的重要概念之一,從而也成為現代程序設計語言的一個主要特性,從應用角度來說,多態是構建高靈活性低耦合度的現代應用程序架構所不可忽缺的能力。從概念的角度來說,多態使得程序員可以不必關心某個對象的具體類型,就可以使用這個對象的“某一部分”功能。這個“某一部分”功能可以用基類來呈現,也可以用接口來呈現。后者顯得更為重要——接口是使程序具有可擴展性的重要特性,而接口的實現依賴于語言對多態的實現,或者干脆就象征著語言對多態的實現。
本文并不大算贅述多態的應用,因為其應用實在俯拾皆是,其概念理論也早已完善。這里,我們打算從實現的角度來看一看一門語言在其多態特性的背后做了些什么——知其所以然,使用時方能游刃有余。
或許你在學習一門語言的時候,曾經對多態的特性很迷惑,雖然教科書上所講的非常簡單,也非常明了——正如它的原本理念一樣,但是你也想知道語言(編譯器)在背后都干了些什么,為什么一個派生類對象就可以被當作其基類對象來使用?用指向派生類對象的基類指針調用虛函數時憑什么能夠精確的到達正確的函數?類的內部是如何布局的?
我們這樣考慮:假設語言不支持多態,而我們又必須實現多態,我們可以怎么做?
多態的雛形:
class B
{
public:
int flag; //為表示簡潔,0代表基類,1代表派生類
void f(){cout<<”in B::f()”;} //非虛函數
};
class D:public B
{
public:
void f(){cout<<”in D::f()”;} //非虛函數
};
void call_virtual(B* pb)
{
if(pb->flag==0) //如果是基類,則直接調用f
pb->f(); //調用的是基類的f
else //如果是派生類,則強制轉化為派生類指針再調用f
(D*)pb->f(); //調用的是派生類的f
}
這樣,可以正好符合“根據具體的對象類型調用相應的函數”的理念。但是這個原始方案有一些缺點:;例如,分發“虛函數”的代碼要自己書寫,不夠優雅,不具有可擴展性(當繼承體系擴大時,這堆代碼將變得臃腫無比),不具有封閉性(如果加入了一個新的派生類,則“虛函數”調用的代碼必須作改動,然而如果恰巧這個調用是無法改動的(例如,庫函數),則意味著,一個用戶加入的派生類將無法兼容于那個庫函數)等等。結果就是——這個方案不具有通用性。
但是,這個方案能夠說明一些本質性的問題:flag數據成員用于標識對象所屬的具體類型,從而調用者可以根據它來確定到底調用哪個函數。但是,可不可以不必“知道”對象的具體類型就能夠調用正確的函數呢?可以,改進的方案如下:
class B
{
public:
void (*f)(); //函數指針,派生類對象可以通過給它重新賦值來改變對象的行為
};
class D:public B
{};
void call_virtual(B* pb)
{
(*(pb->f))(); //間接調用f所指的函數
}
void B_Mem()
{
cout<<”I am B”;
}
void D_Mem()
{
cout<<”I am D”;
}
int main()
{
B b;
b.f=&B_Mem; //B_Mem代表B的“虛函數”
D d;
d.f=&D_Mem; //以D_Mem來覆蓋(override)B的虛函數
call_virtual(&b); //輸出“I am B”
call_virtual(&d); //輸出“I am D”
}
在這個改進的例子中,派生類對象可以通過修改函數指針f的指向,從而獲得特定的行為,這里重要的是,call_virtual函數不再需要通過丑陋的if-else語句來判斷對象的具體類型,而只是簡單的通過一個指針來調用“虛函數”——這時候,如果派生類需要改變具體的行為,則可以將相應的函數指針指向它自己的函數即可,這招“偷梁換柱”通過增加一個間接層的辦法“神不知鬼不覺”地將“虛函數”替換(Override)掉了。
然而,這招仍然還有缺點——要用戶手動實現,可擴展性差,透明性差等等。然而,它的思想已經接近現代編譯器對多態機制的實現手法了。
通過將上面的例子中的函數指針擴展為一個隱含的指針數組——虛函數表(vtbl)——C++擁有了我們現在所看到的多態能力。在虛函數表中,每一個虛函數指針占有一個表項,如果派生類覆蓋(override)了相應的虛函數,則對應表項就改成指向派生類的那個虛函數的——這些工作由編譯器完成——從而,如上例所示,用戶不必知曉對象的確切類型,就能夠觸發其特定的行為(也就是說,調用“取決于對象具體類型”的成員函數),虛函數表對用戶是完全透明的,用戶只需要使用一個virtual關鍵字就能夠輕松擁有強大的多態能力。
如果一個C++類中有虛函數,則該類將會擁有一個虛函數表(vtbl),并且,該類的對象中(一般在頭部)有一個隱含的指向虛函數表的指針(vptr)。
現在假設有如下代碼:
void f(B* pb)
{
pb->f1();
}
則編譯器為該函數生成的代碼如下(以偽代碼表示,以示明了):
void f(B* pb)
{
DWORD* __vptr=((DWORD*)pb)[0]; //獲得虛函數表指針
void (B::*midd_pf)()=__vptr[offsetof_virtual_pf1];
//從表中獲得相應虛函數指針
(pb->*midd_pf)(); //調用虛函數
}
這樣一來,如果pb指向的是D對象,則獲得的是指向D::f1的函數指針(參考上面的第二幅圖),如果pb確實指向B對象,根據B對象內的vptr所指的虛函數表,獲得的是指向B::f1的函數指針。
現在,關于C++的多態機制基本已經明了。剩下的就是多重繼承下的虛函數表格局,大同小異,就不多說了。只不過,其中還是有一些微妙的細節的,可以參見《Inside C++ Object Model》(Lippman著)(中文名《深入C++對象模型》——侯捷譯)。
關于C++虛函數調用機制還有一個細節——在構造函數中調用虛函數要千萬小心,因為“在構造函數中”意味著“對象還沒有構造完畢”,這時候虛函數調用機制很可能還沒有啟動,例如:
class B
{
B(){this->vf();} //調用B::vf
virtual void vf(){cout<<”in B::vf()/n”;
};
現在,不管B身為哪個類的基類,B的構造函數中調用的都是B::vf。細心的讀者會發現:這是由于對象構造順序的關系——C++明確規定,對象的“大廈”是“自底向上”構建的,也就是說,從最底層的基類開始構造,所以,在B中調用this->vf時,雖然this所指的對象確實(即將)是派生類對象,但是派生類對象的構建行為還沒有開始,所以這次調用不可能跑到派生類的vf函數去,就好像第二層樓還沒有建好,一層樓的人是無法跑到二樓去的一樣。
說得更深一些,虛函數的調用是要經過虛函數指針和虛函數表來間接推導的,在B的構造函數中,編譯器會插入一些代碼,將對象頭部的vptr設置為指向B的虛函數表的指針,于是this->vf的推導使用的是B的虛函數表,當然只能跑到B的vf那兒去。而后來,當B構建完畢,輪到派生類對象部分構造時,派生類的構造函數會將對象頭部的vptr改成指向派生類的虛函數表的指針,這時候虛函數調用機制才算是Enable了,以后的this->vf將使用派生類虛函數表來推導,從而到達正確的函數。
.NET 對象模型
C++對象模型與.NET(或Java)有個主要的區別——C++支持多重繼承,不支持接口,而.NET(或Java)支持接口,不支持多重繼承。
而.NET的虛函數調用機制與C++也比較相似,只不過由于接口和JIT(即時編譯)的介入而有一些不同。
在.NET中,每一個類都有一個對應的函數指針表(事實上,這個“表”是個數據結構,里面還有其它信息),與C++不同的是,該類的每個函數(不管是不是虛函數)都在其中對應一個表項。這是由于JIT(即時編譯)的需要——對每個函數的調用都是間接的,都會經過該表推導一次,獲得函數代碼的地址。注意,第一次調用的時候,函數代碼還是中間代碼(.NET的中間語言MISL的代碼),所以將會跳至即時編譯器,編譯這些代碼并放到內存中,然后將表中的對應表項指向編譯后的native code,以后的每次調用都會直接跳到編譯后的代碼。
以上只是想讓你對.NET的“虛函數表”有個大體的認識。下面就來詳細剖析。
如果沒有接口,.NET的虛函數調用機制將是很單純的——幾乎與C++一樣。只不過,接口加入以后就不同了——可以將對象引用轉化為接口引用,然后再調用接口中的虛函數。所以,勢必要對“虛函數表”作某種改動,例如,對于下面的繼承結構:
public interface IFirst
{
void f1();
void f2();
}
public interface ISecond
{
void s1();
}
public class C:IFirst,Isecond
{
public override void f1(){}
public override void f2(){}
public override void s1(){}
public virtual void c1(){}
}?
類型C的內存布局大體是這樣的(由于.NET是單根的繼承結構,每個類都隱式的繼承自Object,所以,類型C的“虛函數表”中包含Object的所有成員函數)
ObjRef指向一個對象,在對象頂部(除了用于同步的sync#塊之外)是hType(可以看成對應于C++對象頂部的虛函數表指針),它所指的結構(CORINFO_CLASS_STRUCT,可以暫時將它看成虛函數表,盡管其中包含的信息不僅僅是虛函數指針)包含在C++中相當于虛函數表的部分,以及用于對象的運行時識別的信息。不同的是,在基于接口的.NET繼承風格中,對接口的虛函數的分派是基于一個IOT(Interface Offset Table,即接口偏移表),圖中的pIOT就是指向這樣一個表,其中每一項都是一個偏移量,反指向該接口中的虛函數指針數組在CORINFO_CLASS_STRUCT中的位置。
這樣,當基于接口的引用調用虛函數時,其背后的機制是:先根據接口引用取得該類所對應的CORINFO_CLASS_STRUCT結構的地址,然后在pIOT所指的接口偏移表中索引相應的虛函數指針數組的偏移量,最后經過指針間接調用虛函數。 可以看出,基于接口引用調用虛函數時要經過兩個間接層,第一,在IOT中索引對應接口的虛函數指針數組的偏移量,第二,在虛函數指針數組中索引相應的虛函數指針,最后才是調用。但是,當基于對象引用調用虛函數時,只要經過一個間接層——就像在C++中一樣——直接在虛函數表中索引對應虛函數指針,接著調用。
關于基于接口的引用調用虛函數,還有一個細節就是,IOT里為每一個接口都準備了一個表項(包括該類并沒有實現的接口),原因是效率——.NET需要每個接口在IOT里都有一個固定的(或者說,編譯期確定的)偏移量,這樣,在為虛函數調用生成代碼的時候才能夠通過這個固定的偏移去查找某個接口的虛函數指針數組的所在。 另一方面,如果某個類的IOT僅僅包含它實現的接口,則經由接口引用去調用虛函數時,必須先知道該接口在IOT中的相應偏移,而這一信息必須通過運行期的動態查詢才能夠知道(因為編譯器在手頭只有一個接口引用的情況下不可能知道它指向的是哪個類對象,從而也就不知道該類到底實現了哪些接口,所以要求助于運行期的動態查詢,而在前面所說的方式(也就是.NET所用的方式)下,編譯器不用知道接口引用到底指向哪個類對象,因為在每個類的CORINFO_CLASS_STRUCT中的固定位置都有一個pIOT,指向一個IOT,其中每個接口都對應一個固定的(編譯器知道的)表項)——顯然,在每次調用虛函數之前都進行一次動態查詢是不可容忍的效率損傷,所以.NET寧可讓IOT多一些表項,以空間換時間。
或許你認為這過于復雜,但是這是必須的,.NET中的基于接口的繼承對應于C++中的多重繼承,后者的實現也有類似的復雜性——或許更復雜一些。
最后,要說明的是,本文對于一個純粹的實用者或許顯得多余,但是對于想把一門語言使用得更好的人卻是有用的。知其然而知其所以然,才能夠游刃有余。而其實現機理在實際運用中能起到拋磚引玉的作用也未可知。
總結
以上是生活随笔為你收集整理的从C++到.NET 揭开多态的面纱的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: OCR-PIL.Image与Base64
- 下一篇: 洪磊父亲避谈洪磊获释 拘留已过24小时留