編程入門
本文的目的是為剛剛接觸COM的程式設計師提供編程指南,並幫助他們理解COM的基本概念。內容包括COM規範簡介,重要的COM術語以及如何重用現有的COM組件。本文不包括如何編寫自己的COM對象和接口。
本文由淺入深描述COM的內在運行機制,教你如何使用第三方提供的COM對象(以Windows 外殼組件Shell為例)。讀完本文後,你就能掌握如何使用Windows作業系統中內建的組件和第三方提供的COM對象。
本文假設你精通C++語言。在例子代碼中使用了一點MFC和ATL,如果你不熟悉MFC和ATL也沒關係,本文會對這些代碼進行完全透徹的解釋。本文包括以下幾個部分:
簡單地說,COM是一種跨套用和語言共享
二進制代碼的方法。與C++不同,它提倡
原始碼重用。ATL便是一個很好的例證。源碼級重用雖然好,但只能用於C++。它還帶來了名字衝突的可能性,更不用說不斷拷貝重用代碼而導致工程膨脹和臃腫。
Windows使用DLLs在二進制級共享代碼。這也是Windows程式運行的關鍵——重用kernel32.dll, user32.dll等。但DLLs是針對C接口而寫的,它們只能被C或理解C調用規範的語言使用。由程式語言來負責實現共享代碼,而不是由DLLs本身。這樣的話DLLs的使用受到限制。
MFC引入了另外一種MFC擴展DLLs二進制共享機制。但它的使用仍受限制——只能在MFC程式中使用。
COM通過定義二進制標準解決了這些問題,即COM明確指出二進制模組(DLLs和EXEs)必須被編譯成與指定的結構匹配。這個標準也確切規定了在記憶體中如何組織COM對象。COM定義的二進制標準還必須獨立於任何程式語言(如C++中的命名修飾)。一旦滿足了這些條件,就可以輕鬆地從任何程式語言中存取這些模組。由
編譯器負責所產生的
二進制代碼與標準兼容。這樣使後來的人就能更容易地使用這些二進制代碼。
在記憶體中,COM對象的這種標準形式在C++
虛函式中偶爾用到,所以這就是為什麼許多COM代碼使用C++的原因。但是記住,編寫模組所用的語言是無關的,因為結果二進制代碼為所有語言可用。
此外,COM不是Win32特有的。從理論上講,它可以被移植到Unix或其它作業系統。但是我好像還從來沒有在Windows以外的地方聽說過COM。
基本元素定義
我們從下往上看。接口只不過是一組函式。這些函式被稱為方法。接口名字以大寫的I開頭,例如C++中的IShellLink,接口被設計成一個抽象基類,其中只有純粹的
虛擬函式。
接口可以從其它接口繼承,這裡所說的繼承的原理就好像C++中的單繼承。接口是不允許
多繼承的。
coclass(簡稱組件對象類——component object class)被包含在DLL或EXE中,並且包含著一個或者多個接口的代碼。組件對象類(coclasss)實現這些接口。COM對象在記憶體中表現為組件對象類(coclasss)的一個實例。注意COM“類”和C++“類”是不相同的,儘管常常COM類實現的就是一個C++類。
COM伺服器是包含了一個或多個coclass的二進制(DLL或EXE)。
註冊(Registration)是創建註冊表入口的一個過程,告訴Windows作業系統COM伺服器放在什麼位置。取消註冊(Unregistration)則相反——從註冊表刪除這些註冊入口。
GUID(諧音為“fluid”,意思是全球唯一標示符——globally unique identifier)是個128位的數字。它是一種獨立於COM程式語言的標示方法。每一個接口和
coclass有一個GUID。因為每一個GUID都是全球唯一的,所以避免了名字衝突(只要你用COM API創建它們)。有時你還會碰到另一個術語UUID(意思也是全球唯一標示符——universally unique identifier)。UUIDs和GUIDs在實際使用時的用途是一樣的。 類ID或者CLSID是命名coclass的GUID。接口ID或者IID是命名接口的GUID。
在COM中廣泛地使用GUID有兩個理由:
GUIDs只是簡單的數字,任何程式語言都可以對之進行處理;
GUIDs可以在任何機器上被任何人創建,一旦完成創建,它就是唯一的。因此,COM開發人員可以創建自己特有的GUIDs而不會與其它開發人員所創建的GUIDs有衝突。這樣就消除了集中授權發布GUIDs的必要。
HRESULT是COM用來返回錯誤和成功代碼的整型數字。除此之外,別無它意,雖然以H作前綴,但沒有句柄之意。下文會對它有更多的討論。
最後,COM庫是在你使用COM時與你互動的作業系統的一部分,它常常指的就是COM本身。但是為了避免混淆才分開描述的。
使用和處理
每一種語言都有其自己處理對象的方式。例如,C++是在棧中創建對象,或者用new動態分配。因為COM必須獨立於語言,所以COM庫為自己提供對象管理例程。
下面是對COM對象管理和C++對象管理所做的一個比較:
創建一個新對象
C++中,用new操作符,或者在棧中創建對象。
COM中,調用COM庫中的API。
刪除對象
C++中,用delete操作符,或將棧對象踢出。
COM中,所有的對象保持它們自己的
引用計數。調用者必須通知對象什麼時候用完這個對象。當引用計數為零時,COM對象將自己從記憶體中釋放。
由此可見,對象處理的兩個階段:創建和銷毀,缺一不可。當創建COM對象時要通知COM庫使用哪一個接口。如果這個對象創建成功,COM庫返回所請求接口的
指針。然後通過這個指針調用方法,就像使用常規C++
對象指針一樣。
創建COM對象
為了創建COM對象並從這個對象獲得接口,必須調用COM庫的API函式,CoCreateInstance()。
其原型如下:
HRESULTCoCreateInstance(REFCLSIDrclsid,LPUNKNOWNpUnkOuter,DWORDdwClsContext,REFIIDriid,LPVOID*ppv);
以下是參數解釋:
rclsid:
coclass的CLSID,例如,可以傳遞CLSID_ShellLink創建一個COM對象來建立捷徑。
pUnkOuter:這個參數只用於COM對象的聚合,利用它向現有的coclass添加新方法。參數值為null表示不使用聚合。
dwClsContext:表示所使用COM伺服器的種類。 本文使用的是最簡單的COM伺服器,一個進程內(in-process)DLL, 所以傳遞的參數值為CLSCTX_INPROC_SERVER。注意這裡不要隨意使用CLSCTX_ALL(在ATL中,它是個
預設值), 因為在沒有安裝DCOM的Windows95系統上會導致失敗。
riid:請求接口的IID。例如,可以傳遞IID_IShellLink獲得IShellLink接口
指針。
ppv:接口指針的地址。
COM庫通過這個參數返回請求的接口。 當你調用CoCreateInstance()時,它負責在註冊表中查找COM伺服器的位置,將伺服器載入到記憶體,並創建你所請求的
coclass實例。以下是一個調用的例子,創建一個CLSID_ShellLink對象的實例並請求指向這個對象IShellLink接口指針。
HRESULThr;IShellLink*pISL;hr=CoCreateInstance(CLSID_ShellLink,//coclass的CLSIDNULL,//不是用聚合CLSCTX_INPROC_SERVER,//伺服器類型IID_IShellLink,//接口的IID(void**)&pISL);//指向接口的指針if(SUCCEEDED(hr)){//用pISL調用方法}else{//不能創建COM對象,hr為出錯代碼}
首先聲明一個接受CoCreateInstance()返回值的HRESULT和IShellLink
指針。調用CoCreateInstance()來創建新的COM對象。如果hr接受到一個表示成功的代碼,則SUCCEEDED宏返回TRUE,否則返回FALSE。FAILED是一個與SUCCEEDED對應的宏用來檢查失敗代碼。刪除COM對象
前面說過,你不用釋放COM對象,只要告訴它們你已經用完對象。IUnknown是每一個COM對象必須實現的接口,它有一個方法,Release()。調用這個方法通知COM對象你不再需要對象。一旦調用了這個方法之後,就不能再次使用這個接口,因為這個COM對象可能從此就從記憶體中消失了。
如果你的應用程式使用許多不同的COM對象,因此在用完某個接口後調用Release()就顯得非常重要。如果你不釋放接口,這個COM對象(包含代碼的DLLs)將保留在記憶體中,這會增加不必要的開銷。如果你的應用程式要長時間運行,就應該在應用程式處於空閒期間調用CoFreeUnusedLibraries() API。這個API將卸載任何沒有明顯引用的COM伺服器,因此這也降低了應用程式使用的記憶體開銷。
繼續用上面的例子來說明如何使用Release():
//像上面一樣創建COM對象,然後,if(SUCCEEDED(hr)){//用pISL調用方法//通知COM對象不再使用它pISL->Release();}
接下來將詳細討論IUnknown接口。
基本接口
每一個COM接口都派生於IUnknown。這個名字有點誤導人,其中沒有未知(Unknown)接口的意思。它的原意是如果有一個指向某COM對象的IUnknown
指針,就不用知道潛在的對象是什麼,因為每個COM對象都實現IUnknown。
IUnknown 有三個方法:
AddRef() —— 通知COM對象增加它的
引用計數。如果你進行了一次接口指針的拷貝,就必須調用一次這個方法,並且原始的值和拷貝的值兩者都要用到。在本文的例子中沒有用到AddRef()方法;
Release() —— 通知COM對象減少它的引用計數。參見前面的Release()示例代碼段;
QueryInterface() —— 從COM對象請求一個接口指針。當
coclass實現一個以上的接口時,就要用到這個方法;
前面已經看到了Release()的使用,但如何使用QueryInterface()呢?當你用CoCreateInstance()創建對象的時候,你得到一個返回的接口
指針。如果這個COM對象實現一個以上的接口(不包括IUnknown),你就必須用QueryInterface()方法來獲得任何你需要的附加的接口指針。
QueryInterface()的原型如下:
HRESULT IUnknown::QueryInterface ( REFIID iid, void** ppv );以下是參數解釋:iid:所請求的接口的IID。ppv:接口指針的地址,QueryInterface()通過這個參數在成功時返回這個接口。
讓我們繼續外殼連結的例子。它實現了IShellLink 和IPersistFile接口。如果你已經有一個IShellLink
指針,pISL,可以從COM對象請求IPersistFile接口:
HRESULT hr;IPersistFile* pIPF;
hr = pISL->QueryInterface ( IID_IPersistFile, (void**) &pIPF );
然後使用SUCCEEDED宏檢查hr的值以確定QueryInterface()的調用情況,如果成功的話你就可以象使用其它接口指針那樣使用新的接口指針,pIPF。但必須記住調用pIPF->Release()通知COM對象已經用完這個接口。仔細做好串處理 ,這一部分將花點時間來討論如何在COM代碼中處理串。如果你熟悉Unicode 和ANSI,並知道如何對它們進行轉換的話,你就可以跳過這一部分,否則還是讀一下這一部分的內容。
不管什麼時候,只要COM方法返回一個串,這個串都是Unicode串(這裡指的是寫入COM規範的所有方法)。Unicode是一種
字元編碼集,類似ASCII,但用兩個位元組表示一個字元。如果你想更好地控制或操作串的話,應該將它轉換成TCHAR類型串。
TCHAR和以_t開頭的函式(如_tcscpy())被設計用來讓你用相同的
原始碼處理Unicode和ANSI串。在大多數情況下編寫的代碼都是用來處理ANSI串和ANSI WindowsAPIs,所以在下文中,除非另外說明,我所說的
字元/串都是指TCHAR類型。你應該熟練掌握TCHAR類型,尤其是當你閱讀其他人寫的有關代碼時,要特別注意TCHAR類型。
當你從某個COM方法返回得到一個Unicode串時,可以用下列幾種方法之一將它轉換成char類型串:
使用CString
構造器或賦值操作(僅用於MFC );
使用ATL 串轉換宏;
WideCharToMultiByte()你可以用WideCharToMultiByte()將一個Unicode串轉換成一個ANSI串。此函式的原型如下:
intWideCharToMultiByte(UINTCodePage,DWORDdwFlags,LPCWSTRlpWideCharStr,intcchWideChar,LPSTRlpMultiByteStr,intcbMultiByte,LPCSTRlpDefaultChar,LPBOOLlpUsedDefaultChar);
以下是參數解釋:CodePage:Unicode
字元轉換成的
代碼頁。你可以傳遞CP_ACP來使用當前的ANSI代碼頁。代碼頁是256個
字元集。字元0——127與ANSI編碼一樣。字元128——255與ANSI字元不同,它可以包含圖形字元或者讀音符號。每一種語言或地區都有其自己的代碼頁,所以使用正確的代碼頁對於正確地顯示重音字元很重要。
dwFlags:dwFlags 確定Windows如何處理“複合” Unicode
字元,它是一種後面帶讀音符號的字元。
如è就是一個複合字元。如果這些字元在CodePage參數指定的
代碼頁中,不會出什麼事。
否則,Windows必須對之進行轉換。傳遞WC_COMPOSITECHECK使得這個API檢查非映射複合字元。
傳遞WC_SEPCHARS使得Windows將字元分為兩段,即字元加讀音,如e`。
傳遞WC_DISCARDNS使得Windows丟棄讀音符號。
傳遞WC_DEFAULTCHAR使得Windows用lpDefaultChar參數中說明的預設字元替代複合字元。
預設行為是WC_SEPCHARS。
lpWideCharStr 要轉換的Unicode串。
cchWideChar lpWideCharStr在Unicode
字元中的長度。通常傳遞-1,表示這個串是以0x00結尾。
lpMultiByteStr 接受轉換的串的字元緩衝cbMultiByte lpMultiByteStr的位元組大小。
lpDefaultChar 可選——當dwFlags包含WC_COMPOSITECHECK | WC_DEFAULTCHAR並且某個Unicode字元不能被映射到同等的ANSI串時所傳遞的一個單字元ANSI串,包含被插入的“預設”字元。可以傳遞NULL,讓API使用系統預設
字元(一種寫法是一個問號)。
lpUsedDefaultChar 可選——指向BOOL類型的一個
指針,設定它來表示是否預設字元曾被插入ANSI串。可以傳遞NULL來忽略這個參數。
我自己都有點暈菜了……!,萬事開頭難啊……,不搞清楚這些東西就很難搞清楚COM的串處理。何況文檔中列出的比實際套用的要複雜得 多。下面就給出了如何使用這個API的例子:
//假設已經有了一個Unicode串wszSomeString...charszANSIString[MAX_PATH];WideCharToMultiByte(CP_ACP,//ANSI代碼頁WC_COMPOSITECHECK,//檢查重音字元wszSomeString,//原Unicode串-1,//-1意思是串以0x00結尾szANSIString,//目的char字元串sizeof(szANSIString),//緩衝大小NULL,//肥預設字元串NULL);//忽略這個參數
其原型如下:
size_twcstombs(char*mbstr,constwchar_t*wcstr,size_tcount);
以下是參數解釋:
mbstr:接受結果ANSI串的
字元(char)緩衝。
wcstr:要轉換的Unicode串。
count:mbstr參數所指的緩衝大小。
wcstombs()在它對
WideCharToMultiByte()的調用中使用WC_COMPOSITECHECK | WC_SEPCHARS標誌。用wcstombs()轉換前面例子中的Unicode串,結果一樣:
wcstombs ( szANSIString, wszSomeString, sizeof(szANSIString) );
CString MFC中的CString包含有
構造函式和接受Unicode串的賦值操作,所以你可以用CString來實現轉換。例如:
// 假設有一個Unicode串
wszSomeString...
CString str1 ( wszSomeString ); // 用
構造器轉換
CString str2;
str2 = wszSomeString; // 用賦值操作轉換ATL宏
ATL有一組很方便的宏用於串的轉換。W2A()用於將Unicode串轉換為ANSI串(記憶方法是“wide to ANSI”——
寬字元到ANSI)。實際上使用OLE2A()更精確,“OLE”表示的意思是COM串或者OLE串。下面是使用這些宏的例子:
#include<atlconv.h>//還是假設有一個Unicode串wszSomeString...{charszANSIString[MAX_PATH];USES_CONVERSION;//聲明這個宏要使用的局部變數lstrcpy(szANSIString,OLE2A(wszSomeString));}
OLE2A()宏“返回”轉換的串的
指針,但轉換的串被存儲在某個臨時棧變數中,所以要用lstrcpy()來獲得自己的拷貝。其它的幾個宏是W2T()(Unicode 到 TCHAR)以及W2CT()(Unicode到
常量TCHAR串)。
有個宏是OLE2CA()(Unicode到常量char串),可以被用到上面的例子中,OLE2CA()實際上是個更正宏,因為lstrcpy()的第二個參數是一個常量char*,關於這個問題本文將在以後作詳細討論。
另一方面,如果你不想做以上複雜的串處理,儘管讓它還保持為Unicode串,如果編寫的是控制台應用程式,輸出/顯示Unicode串時應該用
全程變數std::wcout,如:wcout << wszSomeString;
但是要記住,std::wcout只認Unicode,所以你要是“正常”串的話,還得用std::cout輸出/顯示。
對於Unicode串文字量,要使用前綴L標示,如:
wcout << L"The Oracle says..." << endl << wszOracleResponse;
如果保持串為Unicode,編程時有兩個限制:
必須使用wcsXXX() Unicode串處理函式,如wcslen();
在Windows 9x環境中不能在Windows API中傳遞Unicode串。要想編寫能在9x和NT上都能運行的套用,必須使用TCHAR類型,詳情請參考MSDN;
用例子代碼總結上述內容下面用兩個例子演示本文所講的COM概念。代碼中還包含了本文的例子工程。
使用單接口COM對象
第一個例子展示的是單接口COM對象。這可能是你碰到得最簡單的例子。它使用外殼中的
活動桌面組件對象類(CLSID_ActiveDesktop)來獲得當前桌面牆紙的檔案名稱。請確認系統中安裝了活動桌面(Active Desktop)。以下是編程步驟:
初始化COM庫。 (Initialize);
創建一個與活動桌面互動的COM對象,並取得IActiveDesktop接口;
調用COM對象的GetWallpaper()方法;
如果GetWallpaper()成功,則輸出/顯示牆紙檔案名稱;
釋放接口(Release());
收回COM庫(Uninitialize);
WCHARwszWallpaper[MAX_PATH];CStringstrPath;HRESULThr;IActiveDesktop*pIAD;//1.初始化COM庫(讓Windows載入DLLs)。通常是在程式的InitInstance()中調用//CoInitialize(NULL)或其它啟動代碼。MFC程式使用AfxOleInit()。CoInitialize(NULL);//2.使用外殼提供的活動桌面組件對象類創建COM對象。//第四個參數通知COM需要什麼接口(這裡是IActiveDesktop).hr=CoCreateInstance(CLSID_ActiveDesktop,NULL,CLSCTX_INPROC_SERVER,IID_IActiveDesktop,(void**)&pIAD);if(SUCCEEDED(hr)){//3.如果COM對象被創建成功,則調用這個對象的GetWallpaper()方法。hr=pIAD->GetWallpaper(wszWallpaper,MAX_PATH,0);if(SUCCEEDED(hr)){//4.如果GetWallpaper()成功,則輸出它返回的檔案名稱字。//注意這裡使用wcout來顯示Unicode串wszWallpaper.wcout是//Unicode專用,功能與cout.相同。wcout<<L"Wallpaperpathis:\n"<<wszWallpaper<<endl<<endl;}else{cout<<_T("GetWallpaper()failed.")<<endl<<endl;}//5.釋放接口。pIAD->Release();}else{cout<<_T("CoCreateInstance()failed.")<<endl<<endl;}//6.收回COM庫。MFC程式不用這一步,它自動完成。CoUninitialize();
在這個例子中,輸出/顯示Unicode 串 wszWallpaper用的是std::wcout。使用多接口的COM對象
第二個例子展示了如何使用一個提供單接口的COM對象QueryInterface()函式。其中的代碼用外殼的Shell Link組件對象類創建我們在第一個例子中獲得的牆紙檔案的捷徑 。
以下是編程步驟:
初始化 COM 庫;
創建一個用於建立捷徑的COM 對象並取得IShellLink 接口;
調用IShellLink 接口的SetPath()方法;
調用對象的QueryInterface()函式並取得IPersistFile接口;
調用IPersistFile 接口的Save()方法;
釋放接口;
收回COM庫;
CString sWallpaper = wszWallpaper;
//將牆紙路徑轉換為ANSII
ShellLink* pISL;
IPersistFile* pIPF;
// 1. 初始化COM庫(讓Windows 載入DLLs). 通常在InitInstance()中調用
// CoInitialize ( NULL )或其它啟動代碼。MFC 程式使用AfxOleInit() 。
CoInitialize ( NULL );
//2. 使用外殼提供的Shell Link組件對象類創建COM對象。.
// 第四個參數通知COM 需要什麼接口(這裡是IShellLink)。
hr = CoCreateInstance ( CLSID_ShellLink,
NULL,
CLSCTX_INPROC_SERVER,
IID_IShellLink,
(void**) &pISL );
if ( SUCCEEDED(hr) )
{
// 3. 設定捷徑目標(牆紙檔案)的路徑。
hr = pISL->SetPath ( sWallpaper );
if ( SUCCEEDED(hr) )
{
// 4. 獲取這個對象的第二個接口(IPersistFile)。
hr = pISL->QueryInterface ( IID_IPersistFile, (void**) &pIPF );
if ( SUCCEEDED(hr) )
{
// 5. 調用Save() 方法保存某個檔案得捷徑。第一個參數是
// Unicode 串。
hr = pIPF->Save ( L"C:\\wallpaper.lnk", FALSE );
// 6a. 釋放IPersistFile 接口。
pIPF->Release();
}
}
// 6. 釋放IShellLink 接口。
pISL->Release();
}
// 輸出錯誤信息部分這裡省略。
// 7. 收回COM 庫。MFC 程式不用這一步,它自動完成。
CoUninitialize();
處理HRESULT
這一部分準備用SUCCEEDED 和 FAILED宏進行一些簡單的出錯處理。主要是深入研究從COM方法返回的HRESULT,以便達到完全理解和熟練套用。
HRESULT是個32位符號整數,其非負值表示成功,負值表示失敗。HRESULT有三個域:程度位(表示成功或失敗),功能碼和狀態碼。功能碼錶示HRESULT來自什麼組件或程式。 微軟給不同的組件多賦予功能碼, 如:COM、任務調度程式等都有功能碼。功能碼是個16位的值,僅此而已,沒有其它內在含義;它在數字和意義之間是隨意關聯的;類似GetLastError()返回的值。
如果你在winerror.h頭檔案中查找
錯誤代碼,會看到許多按照[功能]_[程度]_[描述]命名規範列出的HRESULT值,由組件返回的通用的HRESULT(類似E_OUTOFMEMORY)在名字中沒有功能碼。
如:REGDB_E_READREGDB:
功能碼 = REGDB, 指“註冊表資料庫(registry database)”;
程度 = E 意思是錯誤(error);
描述 = READREGDB 是對錯誤的描述(意思是不能讀註冊表資料庫)。 S_OK: 沒有功能碼——通用(generic)
HRESULT;
程度=S;表示成功(success);
OK 是狀態描述表示一切都好(everything''s OK)。
好在有一種比察看winerror.h檔案更容易的方法來確定HRESULT的意思。使用VC提供的錯誤查找工具(Error Lookup)可以輕鬆查到為HRESULT內建功能碼。例如,假設你在CoCreateInstance()之前忘了調用 CoInitialize()。CoCreateInstance()返回的值是0x800401F0。你只要將這個值輸入到錯誤查找工具按“Look Up”按鈕,便可以看到錯誤信息描述“尚未調用CoInitialize”如下圖所示:
另外一種查找HRESULT描述的方法是在
調試器中。假設有一個HRESULT變數是hres。在Watch視窗的左框線中輸入“hres,hr”,表示想要看的值,“hr”便會通知VC顯示HRESULT所描述的值。如下圖所示:
通過以上的討論,想必你對COM編程有了初步的認識,本文第二部分將探討COM的內部機制。教你如何用C++編寫自己的接口。