從C++到.NET 揭開多態的面紗

來源:互聯網
上載者:User

多態是物件導向理論中的重要概念之一,從而也成為現代程式設計語言的一個主要特性,從應用角度來說,多態是構建高靈活性低耦合度的現代應用程式架構所不可忽缺的能力。從概念的角度來說,多態使得程式員可以不必關心某個對象的具體類型,就可以使用這個對象的“某一部分”功能。這個“某一部分”功能可以用基類來呈現,也可以用介面來呈現。後者顯得更為重要——介面是使程式具有可擴充性的重要特性,而介面的實現依賴於語言對多態的實現,或者乾脆就象徵著語言對多態的實現。

  本文並不大算贅述多態的應用,因為其應用實在俯拾皆是,其概念理論也早已完善。這裡,我們打算從實現的角度來看一看一門語言在其多態特性的背後做了些什麼——知其所以然,使用時方能遊刃有餘。

  或許你在學習一門語言的時候,曾經對多態的特性很迷惑,雖然教科書上所講的非常簡單,也非常明了——正如它的原本理念一樣,但是你也想知道語言(編譯器)在背後都幹了些什麼,為什麼一個衍生類別對象就可以被當作其基類對象來使用?用指向衍生類別對象的基類指標調用虛函數時憑什麼能夠精確的到達正確的函數?類的內部是如何布局的?

  我們這樣考慮:假設語言不支援多態,而我們又必須實現多態,我們可以怎麼做?

  多態的雛形:

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僅僅包含它實現的介面,則經由介面引用去調用虛函數時,必須Crowdsourced Security Testing道該介面在IOT中的相應位移,而這一資訊必須通過運行期的動態查詢才能夠知道(因為編譯器在手頭只有一個介面引用的情況下不可能知道它指向的是哪個類對象,從而也就不知道該類到底實現了哪些介面,所以要求助於運行期的動態查詢,而在前面所說的方式(也就是.NET所用的方式)下,編譯器不用知道介面引用到底指向哪個類對象,因為在每個類的CORINFO_CLASS_STRUCT中的固定位置都有一個pIOT,指向一個IOT,其中每個介面都對應一個固定的(編譯器知道的)表項)——顯然,在每次調用虛函數之前都進行一次動態查詢是不可容忍的效率損傷,所以.NET寧可讓IOT多一些表項,以空間換時間。

  或許你認為這過於複雜,但是這是必須的,.NET中的基於介面的繼承對應於C++中的多重繼承,後者的實現也有類似的複雜性——或許更複雜一些。

  最後,要說明的是,本文對於一個純粹的實用者或許顯得多餘,但是對於想把一門語言使用得更好的人卻是有用的。知其然而知其所以然,才能夠遊刃有餘。而其實現機理在實際運用中能起到拋磚引玉的作用也未可知。

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.