從COM到.Net

轉自 這裏

 

COM的問題

COM的許多操作都依賴註冊表

  • 動態創建(CoCreateInstance)
  • 接口列集
    誇進程誇套間調用都依賴於接口列集
  • 獲取對像的類信息

COM根據ClassID在註冊表中找到DLL的位置把DLL加載到內存中,從DLL中獲得導出函數DllGetClassObject調用DllGetClassObject獲得ClassObject,再用ClassObject的CreateInstance創建對像。這就要依賴於Class所在的DLL事件已經在註冊表中註冊了Class相應的信息,但是註冊表的權限問題導致安裝必須在管理員權限下運行。而且也導致一個DLL只能安裝在同一個目錄下,同一個DLL在多個目錄下也會只有最後註冊的DLL會被加載。

COM使用代理與存根實現誇進程與套間的調用,但在生成代理與存根的過程需要Type library,Type library需要從註冊表中獲得它的位置。

接口轉換問題QueryInterface

  • 儘管高效但要小心實現
  • 每一個Class都要實現自己的QueryInterface
  • 不同的接口地址值不一樣

由於COM的接口是通過虛函數表來表現的,因此它需要有一種機制能夠在不同的接口之間轉換。COM要求每一個接口都繼承處IUnknown接口,IUnknown接口中有一個QueryInterface,從名字就可以瞭解這個函數就是用來獲得其它接口的。但每一個CLASS實現的接口不同,因此每一個CLASS都要實現一編自己的QueryInterface,QueryInterface根據調用者傳入不同的InterfaceID返回不同的接口地址。

爲什麼要不同呢?前面提到接口是能過虛函數表來表示接口中的函數地址,也就是說調用者要調用某一個函數時,會在接口指針的基礎上加上偏移量獲得要調用的函數地址,因此給調用者不同的虛函數表是必須的。我們也可能看出QueryInterface要非常小心的實現,如果給錯了接口指針將是災難。即使非常小心的實現了QueryInterface,不同的接口地址也給調用者帶來了很多麻煩。當調用者拿到了兩個接口指針想要知道這兩個指針所指的是不是同一個對象變是非常困難,因爲不同的接口所指向的地址的確是不同的。像下面這樣的代碼是不可能得到正確的結果的:

bool bIsSingle = pA == pB;

上面的例子中,isSingle爲真可以肯定pA與pB指的是同一個對像。但isSingle爲假,就不能說明pA與pB指的不是同一個對象。好在COM還有一個約定每一次QueryInterface獲得IUnknown都要返回相同的值,那麼前面的例子可以改成這樣:

IUnknown *pUn1, *pUn2;

pA->QueryInterface(__uuidof(IUnknown), &pUn1);

pB->QueryInterface(__uuidof(IUnknown), &pUn2);

bool isSingle = pUn1 == pUn2;

看到沒有,要判定兩個指針是不是指向同一個對象是如此的困難。真正的問題遠還沒有搞定呢,通常一個CLASS不是孤立的,它要與其它CLASS一起合作才能給使用者提供服務。而這兩個類之間合作的接口又不希望讓外面知道或使用,這就難辦了有時候就需要從一個接口得到C++對像的指針。由於不同的接口指向的地址是不同的,所以接口與C++對像的指針也可能不同。對於COM來說這種不同是必須的,但帶來的問題也變得非常難處理。

QueryInterface是二進制調用約定,運行時無法對得到的接口指針判定是否是正的相要的接口。如下代碼

IB * pB = ….;

IA *pA = NULL;

pB->QueryInterface(__uuidof (IA), &pA);

ASSERT(pB == pA); // 這裏的斷言可能正確也可能不正確

以上例子紅色部分說明COM的一個特色,在調用者不知道類的實現細結的情況下是不能下任何斷言的,儘管調用者知道IB從IA繼承也不能下這斷言。看下面這個CLASS的實現就明白了

 

    interface IA : IUnknown

    {

        ………………

    };

    interface IB : IA

    {

        …………………..

    };

    interface IC : IA

    {

        …………………

    };

 

class CObj :

    public IB,

    public IC

{

    …………….

}

在這個例子中如果pA是指向CObj的對像,這個斷言顯然是有問題的。說了這麼多就是要說明一個問題,在調用QueryInterface之前是無法確定所要的接口會指向哪裏,不然還要QueryInterface有什麼用。調用者無法對獲得的接口指針做簡單的判斷,更無法對接口指針做類型檢查。看來只無條件的信任接口的實現者,但要是實現者不小心……

兼容性問題

  • 一個接口發佈之後是不能修改了,加一個函數也不行
  • 方法與屬性的順序在發佈之後不能變
  • 給開發者提出了太高的要求

由於COM的接口是通過函數表來實現的調用者根據接口描述在基地址上加上索引值來獲得函數指針,所以函數的索引在發佈之後是不能改變了。COM沒有任何方式能夠驗證指定的索引是否是所要的函數,只能依靠接口的聲明確定函數的索引值。如果接口的設計者更改了函數的順序,調用者不一定知道這一變更仍然按老的索引值在使用,結果是無法預知的災難。如果在最後加一個函數呢?使用老接口的調用者雖然不知道新的函數,但沒關係不會有任何問題。不過反過來呢?調用者用新的接口而實現者只實現了舊接口,當調用者在調用新函數時結果還是無法預知的災難。看來加一個函數也不行,COM約定減少函數、調整函數的順序是想都別想,增加函數就要增加新的接口再好好設計一下要加些什麼函數。這種約定給設計者提出了很高的要求,設計者要在開發之初就要精心設計接口以免在發佈之後對錯誤的設計買單。廢棄的函數是永遠也別想去掉,這個污點成爲設計者永遠的痛。

靈活性

  • 不是所有的C++對象都能跨二進制模塊傳遞的
  • 參數類型的嚴格限制給開發帶來了很大的麻煩
  • 對於集合的實現沒有很好的方法

COM雖然有很多語言都可以開發和使用,但是它有獨立的型別系統不兼容所有語言。C++就是最典型的,許多時候想傳一個STL集合、精心設計的結構都變成比買房子還難的事情。C++對像根本就不適合在二進制模塊間傳遞,不然也不會有COM這玩意了。剛纔說了STL的集合不能傳,但還是要有集合不然複雜的數據結構怎麼表示成COM呢。COM自然就一套集合的約定,從接口到IDispatch接口聽DispID都有約定。但實現起來還是非常困難,想想都知道好多接口要實現、還有很多要注意的細節,反這些東西都寫出來就不怕Blog裏沒有東東了。當然還可以自己定義集合的接口,把STL做掉的事情全部再做一編,兩個字痛苦。

重用性

  • 原有的實現不能通過繼承而獲得(儘管聚合是一個辦法但實現起來難度非常高)
  • COM不是真正面向對象的,相反是利用面相對象的一種技術

通常當有人做了一些工作,而我們只想在它的基礎上做一些擴展自然就會選擇繼承,但是COM沒有繼承。你也許會說COM不是有聚合嗎,不錯聚合從某些方面可以代替繼承。當A聚合B時,B的所有接口與實現都變成了A的一部份。調用者調用A的QueryInterface可以獲得B的所有接口,獲得這些接口之後就可以直接調用B的實現,看下面的例子。

HRESULT QueryInterface(const IID & riid, void **ppvObj)

{

    // 判斷riid自己有沒有實現

    HRESULT hr InnerQueryInterface(riid, ppvObj);

    if (SUCCEEDED(hr) && *ppvObj != NULL)

        return hr;

 

    // 調用B的QueryInterface

    hr = m_pB->QueryInterface(riid, ppvObj);

 

    return hr;

}

這個例子裏這是簡單的將QueryInterface轉發到m_pB,但調者再通過B獲得A的接口呢?B也要把QueryInterface委託給A,看下面這行代碼。

HRESULT hr = CoCreateInstance(CLSID_B, this, CLCTX_ALL, IID_IUnknown, (void**)&m_pB);

這行代碼將聚合對象創建出來,第一處標紅的地方把自己傳給了聚合對象。CoCreateInstance的這個參數也只有在創建聚合對象時有用。爲了保證實體統一性B必須反QueryInterface、AddRef、Release無條件委託給A。慢一點變成無限遞歸了,爲了解決這個問題CoCreateInstance返回一個特殊的IUnknown實現,它的QueryInterface並不會委託給A。這就是第二處標紅的地方必須指定IUnknown,因爲這裏必須得到一個IUnknown的實現才能工作。

到此一個可被聚合的Class與外部對象都能正常工作了,開發起來困難吧?好在ATL做了很多事情,加上Visual Studio的Class Wizard中只要選中支持聚合就能自動生成聚合的代碼,看來開發可聚合的COM Class問題不大。接下來又有一個問題,我們不是用聚合代替繼承嗎。 如果我要重置一個B的函數呢,也就是要讓調用者調用某個函數時調用A的實現而不是B的實現?在COM中只能由A將這個函數所在的接口實現,並且把所有調用者委託給B除了要重置的這個函數。爲了一個函數要實現一堆函數,無論是開發還是在運行時的開消都的非常大。最後得出一個結論”COM不是真正面向對象的,相反是利用面相對象的一種技術

內存管理

  • 引用計數對C++程序員來說是噩夢
  • 循環引用領人非常的頭疼

COM是用引用計數來實現內存管理的,對於C++程序員來說簡直就是噩夢。通常什麼地方要加引用計數,什麼地方要減引用計數是有一個引用計數規則的。但是在實際開發過程中各種各樣的類傳來傳去還是會出一些差錯,一但出了差錯就是災難。引用計數出錯再所難免就像軟件必定會有BUG一樣,要找到引用計數出錯的位子非常的困難只能告Review代碼加上一編又一編的測試來發現問題。

假如出現這樣一種情況A引用B、B引C、C又引用A,這樣就出現了循環引用。一但出現的循環引用對象的生命期就變的無限長,只有打破這種循環引用纔能有效的釋放對象。在開發過程中,一定要注意設計是不是夠成了循環引用,如果是就一定要在合理的時候打破這個環。實際開發中循環引用往往是無意中發生的,一個複雜的系統動態情況下一不小心就形成了環,最終導致資源沒有被釋放甚至一些稀缺資源沒有釋放導致程序不能正常運行或死鎖。

連接點(事件)

  • 一個有三年C++開發經驗的工程師還在問我連接點怎麼用?
  • 用連接點接收事件也不是兩下就能搞的定的
  • 事件的接收者需要顯式的將連接斷開,不然有循環引用之憂

連接點(Connection Point)用於爲COM提類似於事件的方式,當一個組件運行到某個點完成了一些事情,就可以通過連接點向外發送事件。至今我如果不參考文檔還是無法自己開發出一個支持連接點的組件,要接收事件也要實現事件接口,顯式的調用Advice才能收到。同樣如果不顯式的調用Unadvice很有可能循環引用導致對象生存期無限長。關於連接點的實現細節限於編幅,我就不再深入探討了。

所有這些COM的問題在.NET裏一切都迎刃而解決

  • 清單文件及程序集使.NET不再依靠註冊表了
  • QueryInterface到isinst (JIT_IsInstanceof函數)
  • 由於方法的地址是在運行時由JIT確定,所以向中間插入方法不是問題(刪除一個方法,如果這個方法被其它模塊調用,還是會有問題的)
  • 跨語言、二進制模塊繼承不在話下(由於不同語言對CLR的支持程度不同,跨語言繼承還是有一點問題,但還是能這樣做的)
  • 引用計數到垃圾收集器,同時循環引用的問題也不復存在了。
  • 弱引用解決被全局對像引用,但又希望被垃圾收集器回收的問題。
  • CLR內建對事件的支持,使開發人員只要簡單的像聲明屬性一樣聲明事件。而接收事件也像回調函數一般簡單

模塊、程序集

模塊是一個字節流,通常作爲一個DLL或EXE的形式存在,同樣是一個有效的Win32模塊。模塊包含代碼、元數據和資源。模塊的元數據描述了模塊中定義的類型,包含名字、繼承關係、方法簽名和依賴信息等。

程序集就是一個或多個模塊的邏輯集合。程序集是部署的”原子”,被用來對CLR模塊進行打包、加載、分發及版本控制。每一個程序集中有一個模塊包含程序集清單,CLR根據清單來間接的加載相應的模塊。

類型

常用類型構件

  • 結構
  • 枚舉

.Net定義了一箇中間語言(CIL),因此在.NET中所有的二進制模塊都是用CIL來表示的。.NET同樣還定義了類型描述,也就是說每一個二進制模塊裏都會有類型信息稱之爲元數據,這對於運行時類型檢查非常有用。什麼公有成員、私有成員、靜態成員等就不多說了,這些一般語言有的功能.NET都有。

.NET的類型主要分爲三種,類、結構、枚舉。類就是最普通的哪種,必須在堆上被分配比如C#語言則必須用new運算符才能創建實例。結構與類的區別就在於,它可以在棧中分配。除此之外結構與類還有一個區別就是,結構在生命期結果所立刻被消毀,而類要過一段時間由垃圾收集器統一消毀。我們把在棧中分配的變量叫做值類型,在堆中分配的變量叫做引用類型,.NET的許多原生類型就是值類型。枚舉就不多說了,大同小異。

 

類的基本成員

.NET中的類型成員基本被分爲三類,字段、方法、嵌套類型。類型的每個成員都有自己的訪問修飾符(access modifier)(如public、internal)控制對於成員的訪問。還可以控制成員按實例訪問(per-instance member)還是按類型訪問(per-type member)。C#中使用static關鍵字表示按類型訪問,就是通常所說的靜態成員。

 

  • 字段

字段就是類的成員變量,控制內存如何分配。CLR(Common Language Runtime)使用類型的字段來決定分配多少內存給這個類型。CLR會在類型首次加載的時候,給靜態字段分配一次內存,在每次創建實例時,爲非靜態字段分配內存。默認情況下,確切的內存佈局是不透明的。CLR使用虛擬的內存部局,並經常會重新排序字段以優化訪問和使用。如果CLR以類型聲明的順序佈局字段,爲了字節對齊不得不在某些字段中間插入一些空間。重新排序,可以有效的避免不必要的空間浪費。

有時要對一個字段進行約束,讓它成爲常量。CLR提供了兩種將字聲明爲常量的方式。第一種方式,字段在編譯時計算,在元數據中僅僅是一個字面值。運行時它會被編譯器內聯到任何訪問這個字段的地方,在C#中使用const關鍵字聲名。第二種方式,可以將initonly特性應用到一個字段,那麼一旦構造函數執行完畢,就不允許現對字段值修改。C#使用readonly聲明這類常量字段。

  • 方法

同字段一樣,方法也同樣可以通過訪問修飾符來限制對方法的訪問。.NET也同樣支持靜態方法,所不同的是象C#語言不支持通過對象限定符訪問類的靜態成員。方法在傳遞參數時,可以有兩種方式值傳遞、傳引用,對於C#語言缺省是值傳遞你也可以有關鍵字ref或out限定傳引用。看下面的例子,這個例子裏fist使用值傳遞,second與ok都使用引用傳遞。

public void Method(double fist, ref double second, out bool ok);

.NET與C++一樣可以將方法聲明爲虛方法,後面的方法調用一節會提到.NET的虛方法與C++的虛方法同樣高效。

  • 嵌套類型

嵌套類型是一種在另一個類型的範圍之內聲明的類型。比較有代表性的運用構建輔助對象(例如,迭代器、序列化器),它支持聲明類型的實例。不用多講與C++中的嵌套差不多,它能夠訪問外部類的保護成員。

類型初始化

類型允許提供一個特別方法,在它首次被初始化時調用。一個簡單的靜態方法,類型初始化器(.cctor)。一個類型最多隻有一個類型初始化器,沒有參數和返回值,也不能被直接調用。它是被CLR作爲類型初始化的一部份調用的。在C#中,可以編寫一個名字與類型名相同的靜態方法。看下面的例子

namespace Sample {

    public sealed class A{

        static A() {

            t1 = SystemDateTime.Now.Ticks;;

            Debug.Assert(t2 <= t3);

            Debug.Assert(t3 <= t1);

        }

        interal static long t1 ;

        interal static long t2 = SystemDateTime.Now.Ticks;

        interal static long t3 = SystemDateTime.Now.Ticks;

    }

}

C#支持兩種方式創建類型初始器,第一種使用初始化表達式、第二種顯式聲明初始化方法。有趣的是C#還支持兩種方式都同時用,看起來比C++好用多了。當兩種方法都一起使用時,C#會先執行初始化表達式,然後是顯式的類型初始化方法。因此字段將以t2、t3、t1這樣的順序初始化。

當類型實例每次被子分配時,CLR將自動調用另一個不同的方法。這個方法被稱爲構造函數(constructor),並有一個截然不同的名字.ctor。與C++一樣,一個Class可以重載多個構造函數。看下面這個例子

namespace Sample {

    public sealed class A{

        interal long t1 ;

        interal long t2 = SystemDateTime.Now.Ticks;

        interal long t3 = SystemDateTime.Now.Ticks;

 

        public A() {

            t1 = SystemDateTime.Now.Ticks;

        }

 

        public A(long init){

            t1 = init;

        }

    }

}

在這個例子裏同樣出現了顯式構造函數與初始化表達式。編譯器會在顯式的方法體之前插入非靜態字段的初始化代碼。

基類和接口

我們經常需要根據兩個或更多的類型所設的公共假設將類型劃分成不同的類別。這種歸類相當於類型的附加文檔,因爲只有顯式地聲明屬於這個類別的類型,才被認爲是可以共享該類別的假設。在CLR中,將這些類型的類別稱爲接口(interface)。一個類可聲明支持多個接口,但是一個類只能從一個基類繼承。

現在重點來了,COM提昌二進制重用使用接口,也就是虛函數表,所以COM無法做繼承。而前面提到.NET導出元數據描術類型信息,並且有一箇中間語言CIL,所以實現繼承非常容易。所有的語言都編譯成CIL,所以繼承也是繼承自己編譯成中間語言的基類。

運行時的類型

  • 類型轉換
  • 運行時類信息
  • 訪問成員字段
  • 反射
  • 屬性
  • 事件
  • 索引

對象屬於一個類型,因爲對象總是通過對象引用來訪問的,所以被引用對象的實際類型可能不匹配該引用的聲明類型。當引用的聲明是一個抽象類型時,就是這樣的情形。顯然我們需要某種機制來明確對象與其類型的從屬關係,以處理這種情形。接下來讓我們看下面的圖,分析CLR的對象頭。

CLR的每個對象都以一個固定大小的對象頭開始,對象頭不能通過程序直接訪問,但它確實存在。對象頭的確切格式沒有正式文檔說明,上圖只是一些高手的推測。對象頭有兩個個字段,第一個字段是同步塊索引。第二個字段纔是我們關心的句柄(handle),它指向一個不透明的數據結構,用於表示該對象的類型。儘管這個句柄的位置沒有正式文檔說明,但通過System.RuntimeTypeHandle類型能夠顯式地支持。對象引用總是指向對象頭的類型句柄字段,而不是指向fields的第一個字段。我想可能出於兩個原因,第一類可能沒有fields。如果要指向一個沒有fields的對象,這個引用就沒處可指了。第二個原因是在後面的計論會發現,在訪問對象時,總是要藉助類型句柄。將引用指向類型句柄,出於性成的考慮。

給定類型的每一個對象頭中都會有同樣的類型句柄值。當從一個對象的引轉到另一個引用類型時,必須考慮兩個類型之間的關係。如果初始類型與目標類兼容,那麼最終只是簡單的將引用值複製過去。通常把一個派生類的引用傳給基類的引用,就是這樣的情形。如果賦值是從一個基類或接口引用到一個深度派生的類型時,CLR必須運行一遍測試。以確定對象的類型與所需要的類型是否兼容。CLR定義了兩個指令:isinst和castclass。這兩個指命都有兩個參數:一個對象引用和一個用於表示所期望的引用類型的元數據標記。這兩個指命都會檢查對象的類型句柄,以確定對象的類型是否與請求的類型相兼容。如果測試成功,兩個指令只是簡單地將對象引用保存在堆棧上,如果測試失敗castclass會拋出System.InvalidCastException類型的異常,ininst只是簡單的在堆棧上保存一個空引用。

isinst與castclass都使用類型句柄所指的數據結構,來確定對象是否兼容指定的類型。這個結構被稱之爲CORINFO_CLASS_STRUCT,它保含許多關鍵信息。

如上圖每個類都有一張接口表,它包含了類型所兼容的接口的入口項,每個入口項包含了接口的類型句柄。對接口類型的轉換將通過這張表進行匹配。

C#對isinst提供了兩個關鍵字,as和is。我們來看下面的代碼

IA a = ….;

bool bIsIB = a is IB;

IB b = a as IB;

is關鍵字返回一個bool值,表示對象是否與指定的類型兼容。As則返回一個目標類型的引用,如果對象與目標類型不兼容則返回NULL。看起來比COM的QueryInterface簡單多了,關鍵是這個轉換是由CLR完成的。不需要特定的語言做一些額外的事情,它要做的只是發一個isinst。

C#通過強制類型轉換操作符公開castclass指令。

IA a = ….;

try {

IB b = (IB)a;

}

Catch (System.InvalidCastException ex) {}

注意上面例子中的try,在使用強制轉換是會產生異常的。

儘管我們不知道類型句柄所指的結構的細結,基於CLR工作的程序也不能直接訪問類型句柄。但程序員可以通過System.Type類型訪問。我們可以調用System.Object.GetType方法,獲得對象的類型信息。在.NET中的所有類型,都是從System.Object繼承的。

這個System.Type中的信息可不是簡單的一個類名了事,我們可以通過它獲得所有的基類及所兼容的所有接口。不但如此,我們還可以通過System.Type獲得字段的信息,並且獲取或修改指定字段的值。同樣我們也可以通過System.Type獲得方法的信息,並調用指定的方法。驚歎吧,.NET如此的強大!

Public static void DumpTypes(object o)

{

    // 獲取對象的類型

    System.Type = o.GetType();

    // 列出該類型直接的或者間接的基類型

    For (System.Type c = type; c != NULL; c = c.BaseType)

        System.Console.WriteLine(c.AssemblyQualifiedName);

// 列出該類型顯式的或者隱式的接口

    System.Type[] itfs = type.GetInterfaces();

    For (int i = 0; I < itfs.Length; i++)

System.Console.WriteLine(itfs[i]. AssemblyQualifiedName);

}

上面的例子展示了,如何通過System.Type獲得接口信息。

Using System;

Using System.Reflection;

public sealed class Util

{

public static void DumpMembers(Type type)

{

    // 獲取類型的成員

    BindingFlags f = BindingFlags.Static |

                BindingFlags.Instance |

                BindingFlags.Public |

                BindingFlags.NonPublic |

                BindingFlags.FlattenHierarchy;

    MemberInfo[] members = type.GetMembers(f);

    // 列出成員

    for (int i = 0; I < members.Lenth; i++)

        Console.WriteLine(”{0} {1} “,

                    members[i].MemberType,

                    members[i].Name);

}

}

上面的例子列出這指定類型的,所有成員。在這個例子中惟一沒有列出的成員就是基類的private成員。

我看了上面這兩個例子之後,第一個想到的是寫C#程序的智能感知肯定要比C++要快而準,實際上的確如此。有如此強大的運行時類型支持,所以在.NET中,誇二進制重用不再需要接口了。當然接口的存在還有別的價值,在.NET中將接口定義爲類型的公共約定。若干個類可以共享同一個公共約定,這種約定在運行時也有非常高的可靠性保障。

程序員經常會針對一個命名的值定義一對方法,典型的情形就是用一個方法get(獲取)這個值,另一個方法set(設定)這個值。CLR提供一個附加元數據,屬性(property)。屬性是類型的成員,它指定一個或兩個方法,並與屬於該類型的命名值相對應。與字段一樣,屬性有名字和類型。與字段不同的是,屬性沒有存儲空間。屬性只是一個指向同一類型中其他方法的命名引用。看下面的C#例子

public sealed class Utils

{

public static void Adjust(Invoice inv)

{

    decimal amount = inv.Balance;

amount *= 1.0825;

imv.Balance = amount;

}

}

 

public sealed class Invoice

{

    // 屬性定義就如同字段

    public decimal Balance

{

    // c#屬性定義包含一個或兩個方法定義

    get {

        return currentBalance;

}

set {

    if (value < 0)

        throw new System.ArgumentOutOfRangeException();

    currentBalance = value;

}

Internal decimal currentBalance;

}

}

我們看到,set方法判斷了value不能小於零。如果沒有這個判斷,直接使用字段應該更簡單與直接。

與屬性一樣CLR還提供了事件的支持,它引用同一類型中的其他方法。在被引用的方法中,有一個是用於註冊事件處理程序。另一個則用於取消該註冊。事件的類型必須派生於System.Delegate,在後面關於委託的討論中會深入討論這個類型。看下面的C#代碼

using System;

public sealed class Utils {

    public static void FinishIt(Invoid inv, EventHandler eh)

{

    inv.OnSubmit += eh; // 調用add方法

    inv.CompleteWork(); // 進行可能產生事件的工作

    inv.OnSubmit -= eh;

    }

}

 

public sealed class Invoice {

    public event EventHandler OnSubmit

    {

        add {

            eh = (EventHandler)Delegate.Combine(eh, value);

        }

        remove {

            eh = (EventHandler)Delegate.Remove(eh, value);

        }

}

}

上例中前面的代碼,用於註冊與接收事件。其中有一個System.EventHandler類型,它從System.Delegate繼承。後面我們討論委託時,會發現創建一個這樣的對象是非常簡單的。爲了滿足你的好奇心,我先寫一行代碼給你看看。

EventHandler eventHandler = new
EventHandler(OnSubmit);

上面例子中的OnSubmit,就是一個參數類型符合EventHandler聲明的函數。簡單吧?

最後討論一下索引屬性。如果一個屬性的方法接收的的參數不是setter方法的標準值參數,這個屬性就被稱爲索引屬性(indexed property)。C#中的每個類型只支持一個索引屬性。看代碼

public sealed class Utils

{

public static void Adjust(InvoiceLines iq)

{

iq["widget"] = 3; // 調用set方法

decimal cs = iq["qizmo"] // 調用get方法

iq["doodad"] = cs * 2; // 調用set方法

}

}

 

using System;

publid sealed class InvoiceLines

{

internal decimal cWidgets;

internal decimal cGizmos;

internal decimal cDooDads;

// 這裏是索引器……….

public decimal this[string partID] {

get {

switch (partID)

case “widget”:

return cWidgets;

case “dizmo”:

    return cGizmos;

case “doodad”:

    return cDooDads;

default:

    throw new InvalidArgumentException(”partID”);

}

set {

switch (partID)

case “widget”:

cWidgets = value;

case “dizmo”:

    cGizmos = value;

case “doodad”:

    cDooDads = value;

default:

    throw new InvalidArgumentException(”partID”);

}

}

}

這個例子顯的有些繁鎖,不過也下說明了索引的靈活性。

方法

  • 方法和JIT編譯
  • 調用與類型
  • 虛方法調用
  • 接口
  • 顯式方法調用
  • 間接方法調用與委託

CLR只執行本機代碼。如果一個方法體由CIL組成,那麼它就必須在調用之前被轉換爲本機的機器碼。JIT(just-in-time compilation),就是專門負責把CIL編譯成機器碼的組件。有了JIT使得.NET有着不亞於C++的性能。別跟我說你用C++寫一個Hell world,我用C#寫一個Hell world比一下誰的運行速度快。.NET的性能體現在,一個擁用許多二進制可執行模塊的複雜應用之上的。系統越是複雜,就越能體現出.NET的優越性。下面讓我們來看一下,JIT是如何工作的。

.NET最終發佈的代碼,都是基於CIL組成的。按照常規CIL的尺寸要比機器碼小的多,原因很簡單許多CIL指令,需要很多機器指令才能完成。CLR在第一次調用某個方法時,會對方法進行實時編譯(JIT)。通常一個類中只有少數幾個方法會被用到,那些沒有被用到的方法也就沒必要編譯它了。CLR爲每個類型在初始化時分配了一個內存數據結構(CORINFO_CLASS_STRUCT),並通過存儲在每個對象中的RuntimeTypeHandle引用。在CORINFO_CLASS_STRUCT中存有一個方法表,這個方法表是一個帶有長度前綴的內存地址數組,每個方法都有一個入口項。與COM不同的是,CLR方法表既包含實例方法的入口項,又包括靜態方法的入口項。

CLR通過方法表路由所有的方法調用。

class Bob

{

static int x;

static void a(){x += 2;}

static void b(){x += 3;}

static void c() {x += 4;}

static void f()

{

        c(); b(); a();

}

}

實際上,Bob.f方法,經JIT編譯後的代碼是這樣的:

;設立堆棧幀

push ebp

mov ebp, esp

 

;通過方法表調用Bob.c

call dword ptr ds:[37565ch]

 

; 通過方法表調用Bob.b

call dword ptr ds:[375658h]

; 通過方法表調用Bob.a

call dword ptr ds:[375654h]

 

;清理堆棧並返回

pop edp

ret

這三個call指令中使用的地值,分別對應於方法表中相應方法的入口項。它並沒有直接調用方法編譯後生成的代碼地址,而是指向一個惟一的存根,讓我想起了COM的代理與存根。初始化時每個存根包含一個JIT編譯器的調用。在JIT編譯生成本機代碼後,它會重寫存根例程,插入一個jmp指令跳轉到剛纔JIT編譯的代碼。這意味着對於方法隨之而來的調用,除了調用點和方法體之間的jmp指令,不會有別的開銷。

前兩個圖,第一個圖展示了Bob.f調用過程中的Bob方法表情況,由於Bob.c已經被調用,所以c的存根只是一個jmp指令,它簡單的將控制權傳遞給Bob.c的本機代碼。Bob.a和Bob.b還沒有被調用,a和b的存根例程將包含通用的call語句,它將控制權傳遞給JIT編譯器。想不到CLR用瞭如此巧妙的方法,存根。如此JIT生成的調用代碼與C++的代碼就沒什麼區別了。

單一的jmp指令也可能產生性能問題。然而,擴展的jmp指令所提供的間接性,使得CLR能夠很快的調整工作集。CLR能名輕鬆的將目標代碼移來移去,要移動一般代碼只要改一下存根的jmp指令就可以了。CLR可以將經常被調用的方法,放在當前內存分頁中。把不常用的方法放在,其它分頁中被切換到磁盤。把不再被調用的方法拋棄,重新將jmp指令指向JIT例程。從這一點看.NET的性能,還可能比C++做的好。

CLR使用類型的方法表來定位目標方法的地址。在前面的討論中,發現JIT生成的代碼直接將方法表中對應的入口項地址應用於call指令。這意味着派生類型存在一個名字和簽名恰好匹配的方法,也總是分發到自己的方法去。這類調用JIT是根據調用時,對象的引用類型去查找方法表,確定存根地址。

爲了使JIT考慮對象的具體類型,需要把方法聲明爲virtual。虛方法是一個實例方法,其實現可被派生類重寫。在開發時編譯器(指的是高級語言編譯器如C#,並不是JIT)遇到一個虛方法調用時,它生成一個callvirt指令而不是傳統的call指令。JIT遇到callvirt與call的處理結果是不同的。JIT對callvirt指令產生一個特別的指令,它將根據目標對象的具體類型決定調用哪個方法。

對於虛方法和非虛方法,CLR在方法表中分配的入口項是不同的。方法表中有兩個相鄰的區域,第一個區域用於虛方法,第二個區域用於非虛方法。第一個區域將包含每一個被聲明爲virtual的方法入口項,不管該方法是在當前類型中,還是在基類型與接口中。CLR總是把基類型的虛方法放在前面,這樣更有利於虛方法的分發,因爲在繼承結構中用於特定虛方法的索引都是相同的。到這裏我也明白,.NET爲什麼只能有一個基類不能多重繼承。JIT生成的代碼會獲取,指定對象的方法表。然後在方法表上加一個固定的偏移量,執行call指命。目標代碼大概是這樣

mov ecx, esi

mov eax, dword ptr [ecx]

call dword ptr [eax + 38h]

第一個mov指令將目標對象的引用存儲在ecx寄存器中。第二個mov指令將對象的類型句柄存儲在eax寄存器中。第三條指定在給eax加上一個偏移量,執行call。一段高效的代碼,看起來與C++生成的代碼一樣。這裏的38h是個常量,JIT在編譯時計算出來的。這裏就算誰插入了一個虛方法在中間,JIT在編譯時也會根據方法的新位置,計算出一個新的常量用於目標代碼。既保證了靈活性,又確保了執行效率。

.NET的虛方法,有很大的靈活性。可以將一個方法聲明爲newslot,表示這個方法與基類的虛方法無關。如果不想讓子類替換我的實現,可以將方法聲明爲final,禁止替換虛方法。

在C++和COM中,一個給定的具體類型對於每個基類或支持的接,都有一個方法表。而在CLR中,一個給定的具體類型只有一個方法表。以此類推,一個基於CLR的對象只有一個類型句柄。對於C++和COM而言,一個對象往往會按基類或每個接口而有一個虛函數指針。

由於一個類型可以支持多個接口,所以對於支持給定接口的所有類型,方法表對應表項的絕對偏移量可能是不同的。爲了處理這種變化,當通過基於接口的引用調用虛方法時,CLR將添加另外一層間接性。

CORINFO_CLASS_STRUCT包含指向描述類型所支持接口的兩個表的指針。類型轉換使用其中的一個表,確定類型是否支持給定的接口。第二個表是接口偏移量表,當由基於接口的對象引用分發虛方法調用時,CLR將會使用它。一個基於接口引用的方法調用,必須首先在該對象相應的方法表中定位表項的範圍。在CLR找到這個偏移量後,它將添加方法相關的偏移量,並分發調用。

在前面運行時類型中提到,System.Type及其相關類庫可以訪問類型信息。前面還提到通過System.Reflection.MethodInfo類型,可以獲得方法的信息。事實上,它通過MethodInfo.Invoke方法公開了,調用相應函數的功能。

using System;

using System.Globalization;

namespace System.Reflection {

public abstract class MethodInfo : MethodBase {

public virtual object Invoke(object target,

                        BindingFlags invokeAttr,

                        Binder binder,

                        object[] args,

                        CultureInfo culture);

public virtual object Invoke(object target, object[] args);

}

}

Invoke有兩個版本,其中較爲複雜的一個允許調用方提供映射碼,以處理參數類型不匹配和重載解析。較簡單的方法,假定調用方提供了底層方法期望出現的參數。強啊!既支持高效的函數調用,又支持動態間接調用。MethodInfo.MethodHandle是一個指向System.RuntimeMethodHandle的對象的引用,通過RuntimeMethodHandle的GetFunctionPointer方法能獲得底層代碼的地址。得到這個地址後,能夠直接調用這個方法,而不用承擔Invoke的開銷。

因爲MethodInfo對象屬於一個類型,而不是對象,所以在使用MethodInfo調用方法時,需要在每次調用方法時都顯式地提供目標對象的引用。在很多方面,這都能滿足要求。不過,你還需要經常需要將一個特定的方法綁定到一個特定的對象上,這就需要用委託。委託對象必須屬於一個與底層方法簽名相關的委託類型。委託有一個Invoke方法,幫定到委託的方法都必須有與該委託的Invoke方法一致的簽名。CLR將在編譯時和運行時強制實施這種簽名的匹配。看看下面的C#語句:

public delegate int AddProc(int x, int y);

這條語句定義了一個名爲AddProc的委託類型,有點像C++中定義函數指針。

using System;

public delegate int BinaryOp(int x, int y); // 聲明委託類型

 

public class MathCode

{

internal int sum = 0;

public int add(int m, int n)

{

sum += m + n;

return m + n;

}

 

public static int Subtract(int a, int b)

{

return a – b;

}

}

 

class app

{

static void Main()

{

MathCode target = new MathCode();

 

// 只要方法參數類型能匹配,委託可以幫定到不同的方法上

BinaryOp op1 = new BinaryOp(MathCode.Subtract); // 創建委託

BinaryOp op2 = new BinaryOp(MathCode.Add); // 創建委託

 

int a = op1(3, 1); // a == 2

//int a = op1.Invoke(3, 1);

int b = op2(3, 1); // b== 4

//int b = op2.Invoke(3, 1);

}

}

上面的例子展示了聲明委託、創建委託、調用委託幫定的底層方法的全過程。C#處理委託調用時,不能通過名字顯式地調用Invoke方法。它省略了Invoke名,使看起來更像C風格的函數指針調用。我覺得這沒增加什麼好處,返而對代碼的理解帶來的困擾。

Invoke的CLR實現支持多路委託鏈,因此單個Invoke調用可以同時觸發多個方法調用。System.Delegate提供兩個方法用於管理委託鏈:Combine和Remove。

namespace System {

public abstract Delegate : ICloneable, ISerializable

{

static public Delegate Combine(Delegate a, Delegate b);

static public Delegate Combine(Delegate[], delegates);

static public Delegate Remove(Delegate src, Delegate node);

}

}

前面提到的事件,就是這個委託實現的。其中的類型EventHandler,就是一個委託類型的例子。前面提到,事件成員幫助我們解決Combine與Remove的工作。事件使用委託,具有強類型檢查與高度靈活性。

CLR同樣也支持異步方法調用,通過使用一個工作隊列實現異步方法調用。當異步調用一個方法時,CLR將方法參數和目標方法的地址打包到一個請求消息中。接着,CLR將這條消息插入到工作隊列中。CLR維護一個操作系統級別的線程池,用於監聽這個工作隊列。當隊列上的請求到達時,CLR從線程池中分發一個線程來執行工作。要執行異步調用,必須通過委託對象。委託類型還有另外兩個方法用於執行異步調用,BeginInvoke方法和EndInvoke方法。BeginInvoke方法與Invoke的簽名相似,所不同的是BeginInvoke接收兩個客外的參數,用設定調用的處理方式。BeginInvoke總是返回一個調用對象的引用,這個調用對象表示該方法執行期間狀態,並且能夠用於在運行過程中控制和檢測調用。EndInvoke方法將返回與Invoke相同類型的返回值,同樣它還接受Invoke的所有標記爲ref及out的參數。這樣調用者就可以通過,EndInvoke獲得調用過程通過參數返回的值。

致此,我們對.NET有了一個較深入的認識。發現.NET通過元數據(二進制類型信息),爲我們提供運態類型轉換、運行時類型檢查、完整的面向對象的二進制開發方案。通過CORINFO_CLASS_STRUCT中的方法表間接調用,確保調用的安全、高效、與靈活。與COM通過接口實現二進制重用不同,.NET通過類型的元數據實現二進制重用。通常如果要實現二進制重用,並不需要接口的參與。在.NET中使用接口的目的只有一個,構建高度抽象的程序架構。通常在整個應用程序中,只佔了很少一部份。在.NET中,基於接口的調用,JIT會生成代碼通過接口表動態的確定接口在方法表中的位子。對於調用是一個不小的開消息,但要是對同一個對象進行平凡的調用,JIT還是能夠很容易的進行優化。而且在整個應用程序中佔很少一部份,所以這樣的開銷可以接受的。由此我得出了一個結論,.NET是一種更好的二進制重用方案。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章