http://www.allaboutprogram.com 開了一個新的版面:類庫討論,希望能夠對流行的類庫進行探討,不僅可以從中獲得使用經驗,也可以為自己今後設計類庫指明方向。ATL(Active Template Library,Active Template Library) 也是這個版面討論的對象之一,它是微軟開發的一套 COM(Component Object Model,元件物件模型) 支援庫。通常,脫離所支援的對象而討論類庫意義不大,我就寫一篇簡單的文章介紹一下,對於想學 COM 的人來說,也是一塊敲門磚,只是希望別用來砸我。(鑒於這個罈子上大家都對 C++ 很熟悉,所以我會拿 C++ 和 COM 做很多比較,也就是說,我預計的讀者至少比較熟悉 C++。並且,雖然 COM 中對象和介面的區別與 C++ 中的含義不同,但我可能會在不引起混淆的情況下混用這兩者。此外,我對 COM 的版本不做區分,也就是說,我用 COM 來稱呼 COM,DCOM(Distributed COM) 和 COM+(從 Windows 2000 開始支援的 COM 的一個改進版本)。)
http://www.allaboutprogram.com 開了一個新的版面:類庫討論,希望能夠對流行的類庫進行探討,不僅可以從中獲得使用經驗,也可以為自己今後設計類庫指明方向。ATL(Active Template Library,Active Template Library) 也是這個版面討論的對象之一,它是微軟開發的一套 COM(Component Object Model,元件物件模型) 支援庫。通常,脫離所支援的對象而討論類庫意義不大,我就寫一篇簡單的文章介紹一下,對於想學 COM 的人來說,也是一塊敲門磚,只是希望別用來砸我。(鑒於這個罈子上大家都對 C++ 很熟悉,所以我會拿 C++ 和 COM 做很多比較,也就是說,我預計的讀者至少比較熟悉 C++。並且,雖然 COM 中對象和介面的區別與 C++ 中的含義不同,但我可能會在不引起混淆的情況下混用這兩者。此外,我對 COM 的版本不做區分,也就是說,我用 COM 來稱呼 COM,DCOM(Distributed COM) 和 COM+(從 Windows 2000 開始支援的 COM 的一個改進版本)。)
1. 二進位對象標準
a. C++ 的物件版面配置
大家都知道,在 C++ 裡面,一個類可以擁有成員資料和成員函數,如果我有一個類和一個函數:
class MyClass
{
int number;
string name;
public:
MyClass(int, name);
string getName() const;
// ...
};
void print( const MyClass& obj )
{
cout<<obj.getName()<<endl;
}
有經驗的程式員都知道,如果這個類和函數是用 VC 編譯出來的,那麼你不要嘗試在 Borland C++ Builder 裡面使用它。別說 lib 檔案格式不一樣,就算一樣,你能保證兩個編譯器下面的 string 定義一樣?你能保證 VC 編譯的時候的對齊和你目前的一樣?你能保證 VC 裡面使用的堆和 BCB 的一樣?任何一個細微的差別,都可能導致問題,而且是 crash。所以,C++ 的對象標準不是二進位的,而是原始碼級的。就連不同的 C++ 編譯器都不遵循一個標準,更不要說和別的語言(VB,pascal)互動了。
b. 僅有 vtable 的類
在 C++ 的類中,有一種函數叫做虛擬函數。虛擬函數的調用是“動態綁定的”,也就是說,是在啟動並執行時候才決定的。譬如說這段代碼:
class MyInterface
{
virtual int getID() const = 0;
};
MyInterface* p = getSomeObject();
cout<<p->getID()<<endl;
這段代碼中的 p->getID() 的調用,可能和下面的虛擬碼差不多:
function_pointer* vtbl = (function_pointer*)p;
p->vtbl[0];
也就是說,我們調用一個函數的時候,我們使用一個整數(虛函數表的下標)來唯一確定這個函數。並且,因為我們沒有資料成員,所以這個類可能只有一個指標。這個指標的位移量不用計算,就是 0;或者說,如果 vtbl 的布局是相同的,並且調用某個函數的調用規範也是相同的,那麼我們可以在不同的編譯器之間共用這個對象指標。要統一 vtbl 的布局和調用方式,比統一不同編譯器類的布局,標準庫的實現以及各種編譯參數要簡單多了。
c. COM
COM,全稱為 Component Object Model,是 MS 提出的一個二進位的對象標準。它以來的就是我們在 b 中所說的相同的 vtbl 布局。任何一種可以通過間接指標調用函數的語言都可以實現或者使用 COM 物件,這些語言套件括 C/C++,VB,Pascal,Java,...,甚至 DHTML。在 COM 中,類使用者所看到的就是一個又一個的“介面”,也就是只有一個 vtbl 的類,它的每一個函數的調用方式是 __stdcall。
2. IUnknown - 生命期,發現新內容和版本
a. 對象生命期
OO 系統中,對象的所有權和生命期是一個永恒的話題:這是因為,在複雜的 OO 系統中,對象之間的關係非常複雜,我們既不希望在引用一個對象的時候發現它已經不存在了,也不希望一個無用的對象繼續在記憶體中佔用系統的資源。C++ 提出了 auto_ptr,shared_ptr,...,這一切都為了方便管理對象的生命期。在沒有 Garbage Collection 的系統中,常用的管理方式之一就是引用計數。當你需要使用一個對象的時候,把它的引用計數加一,使用完了,把它的引用計數減一。對象可以在內部維護一個計數器,也可以忽略這些資訊(對於靜態/棧上的對象來說)也就是說,我們通過一個對象引用計數使得對於不同對象生命期的管理具有同樣的介面。IUnknown介面具有兩個方法:
ULONG AddRef();
ULONG Release();
前者可以把介面的引用計數加一,後者可以把介面的引用計數減一(請注意,是介面的引用計數!這和對象的引用計數有區別)
同時使用引用計數還可以避免一個問題,就是建立形式不同的問題。譬如說,如果你在一個 dll 裡面有一個函數:string* getName();。你在 exe 裡面調用這個函數,獲得一個指標,你使用完了這個指標以後,你可能會 delete 它,因為在 dll 裡面,這個指標是通過 new 建立的。可是這樣很可能會崩潰,因為你的 dll 和你的 exe 可能是用不同的堆,這樣,exe 的 delete 在自己的堆裡面尋找這個指標,往往會失敗。
b. 發現新內容
C++ 裡面,如果我們擁有一個基類的指標,我們可以通過強制類型轉換獲得衍生類別的指標,從而獲得更豐富的功能:
class Derived:public Base
{
public:
virtual void anotherCall();
};
Base* b = getBase();
Derived* d = (Derived*)b;
可是,通常這被認為是一個很危險的操作,因為如果你不能確認這個 b 的確指向了一個 d,那麼接下去對 d 的使用帶來的很可能是程式崩潰。C++ 提供了一個機制,可以在類中加入類型資訊,並且通過 dynamic_cast 來獲得有效指標。出於種種原因(早期編譯器對於 dynamic_cast 支援不好,或者是類型資訊的加入往往是全域的,開銷很大,...)有些類庫,譬如說 MFC,自己實現了類似的機制。這樣的機制的實現,往往是基於在類的內部維護一張表,以知道“自己”是什麼,和“自己”有關係的類是哪些,...;同時,提供給外界一個可以查詢這些資訊的介面。要做到這個,必須解決兩個問題:我怎樣獲得這個公用的介面,以及我用什麼方法來標識一個類。在標準 C++ 中,我們通過運算子 typeid 來獲得一個 const type_info&。在 MFC 中,因為所有支援動態類型資訊的類都從 CObject 及其子類繼承,所以我們在 CObject 中提供這些方法。這樣,第一個問題對它們而言都解決了。在標準 C++ 中,表示一個類的動態資訊就是那個 type_info,你可以比較它,判斷它,或者擷取一個沒有確定含義的名字,但是你不能用它來做更多事情了。在 MFC 中,使用一個字串來標識一個類,你可以通過一個字串來動態獲得它所表示的類的類型資訊。由於使用簡單的字串非常容易發生衝突,COM 使用的是一個 128 位的隨機整數 GUID ( Global Unique Identifier ),這樣的隨機整數被認為是不太可能發生衝突的。IUnknown 中的另一個方法就是:
HRESULT QueryInterface( REFIID iid,void** Interface );
這個 REFIID 就是對於一個介面的唯一標識,我們也成為介面ID。你對一個介面指標調用 QueryInterface,來詢問另一個介面。如果它支援這個介面,會返回給你一個指標,否則會返回一個錯誤。
d. 一些約定
- 任意一個 COM 介面都必須從 IUknown 介面派生
- 對任意一個介面 QueryInterface 的結果,必須支援自反性,傳遞性,可逆性和持久性。也就是說:
對一個 interface 詢問它本身必須成功
如果你對類型為 A 的介面 a 詢問介面類型 B 並且成功地返回了指向 B 的指標 b,那麼你對 b 再詢問 A,一定能成立。
如果你對類型為 A 的介面 a 詢問介面類型 B 並且成功地返回了指向 B 的指標 b,同時從 b 詢問到了介面 C 的指標 c,那麼你必須能夠成功的從 a 詢問到介面 C 的指標。請注意,這個 C 的指標並不一定和前面那個 C 的指標相同。(也就是說,這個約定只保證詢問成功,但是不保證返回的指標相同)
如果你對介面 a 詢問介面 B 曾經成功過,那麼以後的每次詢問也將成功。
- 如果你要傳一個介面給函數,那麼應該是你保證這個介面在函數返回前的生命期有效。如果一個函數返回一個介面指標,那麼必須在返回前 AddRef,同理,如果你從一個函數獲得了一個介面指標,那麼不需要再 AddRef 了,但是用完後,應該 Release。
- 特別的,根據上一條,你對一個介面調用了 QueryInterface 以後,這個介面返回前已經被 AddRef 了。
- 向同一個介面指標多次查詢另一個介面,每次返回的指標不一定一樣,但是,如果你查詢的介面 ID 是 IID_IUnknown,那麼每次返回的指標都相同。這種說法等價於,指向 IUnknown 的指標是一個 COM 物件的標識,也就是說,比較兩個介面是否屬於同一個對象的唯一通用方法是對比從中 Query 出來的 IUnknown 介面。此外,需要注意雖然每個 COM 介面都是從 IUnknown 派生的,但是你不能簡單地通過把一個介面指標賦給一個 IUnknown 的指標來獲得一個類標識,儘管這在 C++ 中是合法的。
- 引用計數是基於介面的,所以這段代碼可能有問題IInterface1* p1 = getObj();
IInterface2* p2;
p1->QueryInterface( IID_Interface2, (LPVOID*)&p2 );//假設成功
p1->Release();
p1->func();//這個時候,p1 可能無效!!雖然這個對象有效
p2->Release();
3. IDL
IDL 就是介面定義語言( Interface Definition Language )。前面我們說道,COM 中所有的功能都是通過介面來展現的,作為一個二進位標準,我們需要有一個通用的方法去描述介面。C++ 雖然強大,但是不適合做這件事,首先,它過於複雜,其次,它的很多資料類型別的語言不一定支援。我們需要有一個中立的語言來定義整個介面的資料交換情況(我們並不需要用它來定義介面的功能,因為這是依賴於具體實現的。)IDL 就是這種語言。你可以使用 IDL 來描述一個 COM 物件或者是 COM 介面的屬性:這個函數接受什麼參數,每個參數的流向,...。IDL 比 C++ 佔優勢的另一點是,它比較簡單,可以用工具來處理,產生的二進位資訊,可以用來完全描述你的介面的外部特性。
其實在別的 ORB 系統中,通常是把 IDL 作為介面的原始定義語言。譬如說,CORBA 中,你先用 IDL 描述介面,然後使用某些程式轉換成一個 C++ 定義,並且在這個 C++ 定義上繼續實現介面的功能,這稱為從 IDL 到 C++ 的映射(mapping)。MS 的 MIDL 編譯器也可以實現同樣的功能,但是概念上不一樣。在 COM 中,你脫離 IDL 直接用 C++ 實現一個介面,是合法行為,並且是最初的常用行為;在 CORBA 中,你自己寫一個 C++ 映射,被認為是一種取巧行為(當然,熟悉的人可以這麼做)。
關於 IDL 具體的定義和用法,請參見 MSDN。
4. 位置無關
我看過的 COM 書裡面,總是把這部分內容放在較後解釋。但是我傾向於將它提前,理由有兩個:不難(對有經驗的 C++ 程式員來說),有用(對理解後面的內容來說)。
a. 一個 C++ 模型
假設我有一個 C++ 類,大概是這樣的:class Local
{
public:
virtual void encrypt( char* str ) = 0;//加密一個字串
};
我的客戶是這樣使用它的:
Local* p = getGlobalEncryptor();
p->encrypt( password );
這段代碼很簡單,如果My Code和客戶代碼在同一個模組裡,幾乎沒有任何問題。可是,如果我們有一天需要把這個加密的方法統一的放到一個伺服器上(加密代碼放在客戶機上,很難保證沒有彙編高手去讀懂代碼的),那麼這個程式就要修改了。我們當然希望這個程式修改的地方越少越好,通常的方法如下:
首先,我們另外寫一個 exe,這個 exe 運行在伺服器上,它 load 這個 dll,並且監聽某個特定的 TCP 通訊埠,如果有串連,它就先讀進來 4 個位元組的字串長度,然後再讀進整個字串,然後調用 dll 中的函數,然後把返回結果,還是按照 4 個位元組長度 + 內容的形式,通過這個串連發回去。
其次,我們在 getGlobalEncryptor 上面做一點手腳,我們返回一個“假的”對象,這個對象接收到一個 string 參數後,會把這個參數用前面所說的方法發送到 server 端的那個特定連接埠。這樣一來,我們的客戶代碼不需要改變,也可以實現相同的功能。熟悉設計模式的人可能會想起,這是一個 proxy 模式。
這種方式來獲得“位置”無關性的關鍵在於,我們使用了兩個額外的對象,一個在用戶端“類比”所需要的介面,一個在服務端使用用戶端傳來的資訊調用真正的介面。至於究竟通過什麼方式來傳遞這個調用資訊,並不是很重要。如果情況簡單點,也就是說,我們的實現代碼和客戶代碼在同一台機器上的兩個進程內,那麼我們可以用別的方式來完成這個通訊,譬如說 Windows 的訊息;我們甚至可以使用具名管道,NetBIOS 甚至檔案來實現這個資訊交換。
b. COM 中的 proxy 和 stub
COM 的目標是跨越進程,跨越網路的分布式對象系統,那麼自然不能把對象和它的使用者限制在同一個進程空間裡面。所以 COM 提供了一套機制,稱為 proxy/stub,我們把client 這邊的這個類比對象稱為 proxy,把伺服器那邊的那個處理常式叫做 stub。你可以自己實現這兩個程式,但是更直接的方法是,如果我們的所有的函數的參數和傳回值都可以被 COM 基本服務所識別,它可以根據這些資訊自動建立這套 proxy。這些資訊可以通過用 MIDL 來編譯你的 IDL 檔案來獲得。MIDL 可以建立這些 proxy/stub的原始碼,你通過編譯得到相應的 dll;也可以直接建立一個二進位的類型描述檔案:type library,COM 運行時可以讀取這個檔案來產生 proxy/stub。COM 中,通過 proxy 和 stub 來串連遠程對象的機制稱為調度(marshal)。
如果你自己實現 marshal 代碼,你可以在自己的對象上實現 IMarshal 介面。當遠程建立對象時,stub 代碼會向你的對象詢問這個介面,並且對每一個需要的介面調用 MarshalInterface 方法,這個方法會傳入一個 IStream 介面指標,你可以把和介面相關的資料(譬如說,伺服器的名字,監聽的連接埠,...)寫入這個流,COM 服務會把這個流的內容傳遞給客戶機;stub 代碼還會通過調用 GetUnmarshalClass 來獲得一個 CLSID,這個 CLSID 會被用來在本地建立一個 COM 物件,並且這個 COM 物件必須實現 IMarshal 介面。那個流的內容將被傳給這個本地介面來建立一個 proxy 介面。
由於 dll 本身不能獨立執行,因此 COM+ 提供了一個服務,叫做 surrogate,這是一個可執行檔,它可以 load 你的 dll,並且使用其中的對象。這樣,你不僅可以訪問在遠程機器上的 dll 中實現的對象,你還可以把本地的 dll 放到代理進程中執行來獲得更好的安全性。
注意:
- COM 基本介面的 marshal 支援都已經實現了,你不必提供自己的實現。
- COM 不僅在實現遠程無關性中使用到 proxy/stub:在實現線程無關性的時候也使用了 proxy/stub,這在後面講到 COM 線程模式的時候會詳細討論。
- proxy/stub 代碼是基於介面的,而不是基於對象的。
- 需要遠程執行的 COM 方法的傳回值通常需要是 HRESULT(事實上推薦任意 COM 方法使用這個作為傳回值)。因為一旦客戶機和服務機之間的通訊出現問題的時候,代理對象可以通過返回一些標識遠程服務失敗的 HRESULT 來告訴你這個事實,否則,假設你的傳回值是 VOID,那麼 proxy 對象沒有任何方法可以通知你遠程伺服器出現的問題。
5. 建立或找到對象
所謂 OO 系統,當然離不開建立對象。我們通常在 C++ 中怎麼建立對象的呢?下面是一些常用的方法:
class Derived:public Base
{
virtual Derived* clone() const;
};
Base* b1 = new Derived();//顯式的建立對象
Base* b2 = b1->clone();//通過一個成員函數,返回一個對象的指標(可能是建立的,也可能是別的方式得來的)
Base* b3 = createObject("base");//通過一個全域函數,返回一個對象的指標,可能需要提供一些資訊。
顯式的建立對象需要你知道類的標識,調用函數建立對象,則可能不需要提供標識。COM 中,我們通常難以使用建立對象這個概念,因為我們通常關心的是:我怎麼獲得一個對象的第一個介面指標,因為獲得了這個指標後,我可以通過 QueryInterface 來獲得別的感興趣的介面。COM 並不提供一個類似於 new 的方法來建立對象,通常我們通過工廠對象來顯式建立一個新的對象(有一點必須注意,雖然這裡我們稱之為“建立對象”,但是實際上你獲得的是一個有效介面。雖然大多數時候你使用 IClassFactory 的確是有對象被建立了,但是還有些情況下,並沒有對象被建立。)。
什麼是對象工廠,對象工廠廣義上指一個類,這個類的一個或幾個方法可以根據你的需要,建立對象(或者返回給你有效對象指標,以下我們不再刻意區分這點);狹義上說,在 COM 世界裡面,我們通常指一個實現了 IClassFactory 介面的對象。通常我們通過調用一個函數來獲得對象工廠的指標:CoGetClassObject。它的原型如下:
STDAPI CoGetClassObject( REFCLSID rclsid, DWORD dwClsContext, COSERVERINFO * pServerInfo, REFIID riid, LPVOID * ppv );
rclsid 和前面所說的介面 ID 一樣,是一個 GUID,在這裡被稱為類別識別碼 ( Class ID )。在這裡使用一個 GUID 的原因和介面標識使用 GUID 一樣,也是因為字串的名字比較容易衝突。dwClsContext 和 pServerInfo 目前我們先不操心。riid 是一個介面 id,因為你建立的對象,必須以某個介面指標的形式返回給你,通常我們使用 IID_IFactory 來獲得一個對象工廠。ppv 自然就是那個建立出來的指標了。
為什麼這個函數叫 CoGetClassObject,而不叫 CoGetClassFactory 呢?ClassObject 在 OO 領域中,特指一個類的靜態部分。譬如說,如果你在一個 C++ 類中,有一些靜態方法,那麼你可以把這些靜態方法和待用資料放到一個獨立的對象裡面,這個對象就是這個類的類對象。COM 中,我們不能對介面給出靜態方法,所以就使用 ClassObject。IClassFactory 是一個 ClassObject 通常會實現的介面,但是 ClassObject 可以不支援這個介面。當然,通常我們實現自己的 ClassObject 的時候,會實現(甚至只實現) IClassFactory 介面,因為這是 COM 基本服務支援的標準類對象之一。
你得到了指向 IClassFactory 的指標後,可以調用它的 CreateInstance 來建立一個你所要的對象。
在 COM 中,提供了很多別的方法來獲得一個介面指標,譬如說,調用某個全域函數,例如 CoGetMalloc;或者是調用一個介面指標的方法獲得另一個介面指標,典型的就是 QueryInterface。其中最值得一提的是 Moniker。(是否展開?)
COM 服務通過註冊表中的資訊來獲得 CLSID 到對象實現位置的有關資訊。當你把一個 CLSID 傳給 CoGetClassObject 的時候,COM 服務會去 HKEY_CLASSES_ROOT\CLSID 下面尋找相應的鍵,如果這個鍵定義了 InprocServer32 子鍵,那表示這個對象被定義在一個 dll 中,這個鍵的預設值是這個 dll 的完整檔案名稱;如果定義的是 LocalServer32,則表示這個對象被定義在一個 exe 中。COM 中的每一個類還有一個“易讀”的名字,稱為 ProgID。你可以在 HKEY_CLASSES_ROOT 下面發現很多類似於 xxxxx.yyyyy.n 的鍵,這些鍵有一個 CLSID 子鍵唯一表示了相對應的 CLSID。你可以使用 CLSIDFromProgID 和 ProgIDFromCLSID 來完成兩者之間的轉換。
當 COM 服務裝載了一個 dll 後,它會調用 dll 所匯出的 DllGetClassObject 來獲得一個 CLSID 所對應的類對象。當 COM 服務裝載一個 exe 時,它會使用 /embedding 參數運行這個 exe,這個 exe 應該調用 CoRegisterClassObject 來把註冊 ClassObject,當然這種情況下客戶最後獲得的 IClassFactory 指標往往是被 marshal 過的。
6. 錯誤處理
任意系統中,傳回值總是一個常用的處理錯誤的形式。COM 中很多函數的傳回值是一個 HRESULT。COM 是一個跨平台,跨語言的架構,必然要求傳回值的含義更多,並且可以方便使用者自己定義。HRESULT 一共 32 位,最高位叫 severity 表示這次操作成功與否,接下來的兩位保留,接下來的 13 位叫 facility 表示狀態(不僅僅是錯誤)的產生方。最後的 16 位就是狀態的代碼。舉個簡單的例子,HRESULT_FROM_WIN32 是把一個 Win32 的錯誤值轉成 HRESULT 的。所以,當這個值是 0 的時候,HRESULT 就是 0(最高位是 0,表示成功)。否則,它會把最高為設成 1,並且把 facility 設成 FACILITY_WIN32,然後把狀態值設成(錯誤碼 & 0xffff)。
如果你要給自己的介面方法返回一些自己的錯誤資訊,可以使用 FACILITY_ITF。ITF 表示 interface,也就是說是介面自訂的錯誤資訊。別的 FACILITY 有 FACILITY_NULL,FACILITY_RPC,...
很多情況下,一個簡單的傳回值顯得很蒼白,譬如說,開啟檔案失敗是一個傳回值,但是究竟哪個檔案開啟失敗了呢?一個 32 位的整數不能表達這樣的資訊。C++ 中,我們可以使用一個異常類來給出更多的錯誤資訊,COM 中類似的機制是 IErrorInfo。對客戶方來說,它可以對一個介面查詢 ISupportErrorInfo,並且調用 ISupportErrorInfo 的 InterfaceSupportsErrorInfo 方法來確定這個對象的某個介面是不是支援 IErrorInfo,如果一個介面支援 IErrorInfo,並且你調用它的方法返回了一個表示錯誤的 HRESULT,你就可以調用 GetErrorInfo 來獲得一個 IErrorInfo,並且調用這個介面指標的方法獲得詳細的錯誤資訊。對伺服器方來說,它需要實現 ISupportErrorInfo,並且在發生錯誤的時候,調用 CreateErrorInfo 來建立一個 ICreateErrorInfo 介面指標,並且完成相應的設定工作。
7. 線程
多線程總會給你的程式帶來額外的麻煩。Windows 作為一個多線程系統,自然沒辦法拒絕在 COM 中使用多線程。假設我們在 C++ 中使用一個別人提供的庫函數:
int doSomeCriticalThing( int );
如果我們本身的程式是多線程的,並且我們每個線程中都要調用這個函數,那麼該怎麼辦呢?首先當然是檢查這個函數的文檔,看它是不是安全執行緒的。如果它是安全執行緒的,那什麼都不用考慮,直接調用。如果它不是安全執行緒的,那麼我們就需要在調用它的時候作一些同步。譬如說,我們設定一個全域的臨界區,每次調用前進入臨界區,然後調用這個函數,返回後再退出臨界區。當然,還有一個方法是利用 Windows 現存的訊息機制,我建立一個訊息線程,這個線程接收訊息並且根據訊息的參數來調用這個函數,並且將結果返回給調用方(當然也是通過訊息)。而調用這個函數的調用方,則每次都把這個函數的參數通過訊息發到那個線程(是不是有點像 marshal?)更極端的情況是,這個函數的實現者根本就不知道世界上有多線程這件事情,所以他寫的函數,只能在程式的主線程裡面運行。這個時候怎麼辦?只有把主線程變為訊息線程,然後別的線程通過向主線程發送訊息來間接調用這個函數。
我們再來看另一種情況,如果我們的程式是一個單線程的程式,而需要調用的函數支援多線程,那麼是否就可以直接調用了呢?不總是!對於上面這個函數來說可以,但是對於下面這個函數來說,不一定。
typedef int (* Callback )( int );
int doSomeCriticalThingAndCallMeBack( int, Callback );
這個函數裡面,我們必須傳給它一個回呼函數,我們的回呼函數可能是線程不安全的,而這個函數既然是多線程的,它可能內部實現了一個線程池,並且把這個工作指派給別的線程執行,如果它在那個線程裡面調用這個回呼函數,很難保證不出現同步問題,所以也需要作上面的特殊處理。
COM 裡面,每一個對象都屬於一個套間(Apartment),每一個套間可以由零個,一個或多個線程組成。COM 支援四種套間模式:STA(Single Threaded Apartment,一個線程),MTA(Multiple Threaded Apartment,多個線程),NA(Neutral Apartment,0 個線程),Main STA。STA 就是我們前面說的單線程並且通過訊息調度的方式,MTA 就是多線程方式,Neutral Apartment 類似於我們前面說的臨界區方式,Main STA 是指那種必須在主線程啟動並執行 STA。我們可以對某個對象做標記,來告訴系統它支援什麼方式的多線程,COM 支援五種標記方式:STA,Main STA,MTA,Both 和 NA,其中 Both 意味著一個對象既可以生存在 STA 中,也可以生存在 MTA 中。每一個線程在調用 CoInitializeEx 的時候,根據參數的不同,會進入某個套間。一個進程可以有多個 STA,但是只能有一個 MTA。不同的套間之間的對象調用,可能需要 marshal。
8. 資料存放區和傳遞
9. persist
對象系統的一個痛點就是持久化。幾乎每一個 OO 的 framework 都會實現自己的持久化機制。我們在 C++ 中,通常採用某些特定的類函數來完成持久化,譬如說 operator << 和 operator >>,但是在多根體系中,給出一個全域有效持久化機制通常很困難。COM 通過一系列的 IPersist* 機制(IPersistStream,IPersistFile,IPersistStorage,IPersistStream)實現持久化機制。當你獲得一個介面後,你可以根據需要對它查詢 IPersist* 中的某個,如果查詢成功,就可以調用相應的功能來實現持久化資料的寫入或讀出。通過結合上面的 IStorage 和 IStream 介面,我們可以實現高效且優雅的持久化系統。
10. 重用
C++ 中,說到重用往往是繼承和組合。這些重用方法,也可以使用在 COM 程式中,前提是你使用 C++ 開發,並且可以訪問所有的原始碼。我們把這種類型的重用稱為原始碼重用。在 COM 中我們還可以使用一些二進位的重用方式:包含(containment/delegation)和彙總(aggregation)。
所謂包含,非常容易理解,就是在你的對象內部包含了指向另一個對象的介面指標,你通過調用那個介面的方法,來實現自身部分或者全部的功能。這個和 C++ 中的組合幾乎一樣,區別在於,C++ 中的組合可以把另一個對象完全的包含在自身對象的內部,而 COM 中你只能在內部包含另一個介面的指標。
彙總是一個比較有趣的技術,因為它可以完全使用另一個對象的實現來取代本身需要實現的某個介面。假設我們要實現的對象為 Outer,我們需要實現的介面是 IPrint,而一個名為 Inner 的對象正巧有這個介面,並且可以直接為我們使用。為了能夠做到這點,我們需要以下的實現:首先,在我們建立 Outer 的時候,我們調用 CoCreateInstance 建立一個 Inner 對象,並且把這個對象的指標保留在資料成員中;其次,在我們的 QueryInterface 中,每當客戶需要 IPrint 介面的時候,我們把這個請求傳遞給 Inner 對象的 QueryInterface。乍一看,使用者可以通過這種方式很方便的獲得重用,但是這違反了 COM 的幾個基本原則。首先,雖然我可以從 Outer 對象獲得了一個 IPrint 的指標,但是如果我對這個 IPrint 查詢某個 Outer 對象實現的介面,它會返回一個錯誤,因為 Inner 對象並沒有實現這個介面。並且,如果我對這個 IPrint 介面查詢 IUnknown,它返回的指標和我直接在 Outer 對象的某個介面上查詢出的 IUnknown 指標不同,這樣,違反了 COM 對對象的標記原則。要解決這個問題的關鍵點在於,我們必須讓 Inner 知道它正在被 Outer 彙總,COM 中已經為我們提供了這個機制。CoCreateInstance 以及 IClassFactory 的 CreateInstance 方法,都有一個參數叫做 IUnknownOuter。當 Outer 在建立 Inner 時,對這個參數傳入非 NULL 的值(自己的 IUnknown 指標),Inner 就知道它將被這個 IUnknownOuter 所彙總。這個時候,Inner 需要把這個 IUnknownOuter 指標儲存下來,然後把自己真正的 IUnknown 指標返回給 Outer,並且以後每次外界對 IPrint 介面調用 AddRef/Release/QueryInterface 的時候,簡單的把這些請求傳遞給 IUnknownOuter(如果你熟悉傳統戲曲的話,應該知道這就是兩個對象一起唱的一出雙簧),這樣一來,可以保證 QueryInterface 的傳遞性,可逆性以及 IUnknown 對對象的唯一標識。對於 Outer 來說,它會保留那個建立時候返回的 IUnknownInner,並且把對 IPrint 介面的查詢傳遞給它。實現彙總的時候,需要注意以下幾點:
- Inner 獲得 Outer 的 IUnknown 指標後,不要對它進行 AddRef,否則這兩個對象會循環參考,以至於永遠也不能釋放
- Outer 建立 Inner 的時候,只能要求返回 IUnknown 指標,否則,建立失敗。因為如果你獲得的是別的介面的指標,你將不能實現我們上面所說的功能
- Outer 的 QueryInterface 不能把所有的查詢都無條件的轉給 IUnknownInner,而只能傳遞所需要的介面。否則,Inner 可能會提供給 Outer 的客戶一些難以預料的介面。
11. 註冊資訊
12. C++ 和 COM
有些 C++ 程式員覺得 COM 的某些機制和 C++ 格格不入,並把這個作為 COM 不好的理由之一。常見的抱怨有:我用慣異常了,COM 裡面的錯誤處理不象異常那樣可以跳躍多個層次;C++ 的繼承多方便,我在 COM 裡面實現重用,還要自己寫彙總代碼;...。其實使用 C++ 來實現或使用 COM 物件的時候,遇到這樣的問題,應該是我們檢討自己 C++ 水平,或者檢討 C++ 這個語言(如果某個功能,沒有任何人可以很簡單的實現的時候)。因為 COM 是一個技術,是一個標準,C++ 是一個通用語言,如果我們都不能夠通過正確的使用 C++ 的機制和 OO 的思想來對 COM 做出一個完善的描述,那是 C++ 語言的不足。其實使用 C++ 來建立/使用 COM 物件,非常的方便。譬如說,你可以寫一個 COM 的 HRESULT 和 IErrorInfo 到 C++ 的異常的轉換函式,你也可以寫一個函數使用 try/catch 到的異常來設定 IErrorInfo;你可以實現一套 C++ 類來簡單的建立支援彙總的對象;你也可以實現 COM 介面的 smart ptr 來保證釋放了每一個介面;...。事實上,Visual C++ 提供的 ATL 以及一些 COM 支援類,實現了大部分這樣的功能,我們可以通過建立/擴充/使用這樣的類庫來簡化所遇到的 COM 開發工作。
13. 結語
我在向有些 C++ 程式員介紹 COM 的時候,他們總是問我一句話:這些功能不難,為什麼不自己實現?其實這個問題我在自己看 COM 的時候也經常地問自己,尤其是當你看到 MTS,Object Pool,DCOM 以及 LCE 等所謂的 COM 的“進階功能”的時候,你會覺得每一個功能,其背後的實現和想法都很基本。對於這個問題,我給自己的回答是:既然微軟提供了一套高效,成熟,無錯(相對你自己的實現來說)的基本服務,為什麼我們不使用它呢?C++ 程式員和使用別的語言的程式員比起來,往往缺乏安全感,通常我們如果不知道一個東西背後的具體實現究竟是怎樣的,我們可能就不會去使用它。所以對大多數 C++ 程式員來說,我們在使用一個新的類庫之前都會做一件事情:閱讀原始碼,這樣,當我們使用這個類庫的功能的時候,我們可以知道我們寫的每一句話究竟做了些什麼。在此我不對這種習慣做任何評價(我也是 C++ 程式員之一 ),那麼我就希望這篇文章可以讓大家對 COM 基本服務究竟在做什麼,有一個大概的瞭解。
其實,我覺得 COM 分為兩部分,一部分是 COM 的標準,另一部分是 COM 的基本服務,這兩部分相輔相成,給實現 OO 系統的人帶來的很多好處。我遇到過不少高手覺得 COM 不值一提,也遇到過不少初學者,覺得 COM 已經過時。無論如何,COM 作為一個 OO 的基本系統,理解它的思想對於設計自己的 OO 系統還是非常有意義的。
走馬觀花,未能詳盡之處,歡迎另行討論。如果有熟悉 COM 的朋友覺得我遺漏了重要的知識點,請告訴我,我盡量在下一版中加入。