1. 程式人生 > >How to export C++ class from dll

How to export C++ class from dll

原文來源:

C++語言畢竟能和Windows DLLs能夠和平共處。

介紹

自從Windows的開始階段動態連結庫(DLL)就是Windows平臺的一個組成部分。動態連結庫允許在一個獨立的模組中封裝一系列的功能函式然後以一個顯式的C函式列表提供外部使用者使用。在上個世紀80年代,當Windows DLLs面世時,對於廣大開發者而言只有C語言是切實可行的開發手段。所以, Windows DLLs很自然地以C函式和資料的形式向外部暴露功能。從本質來說,一個DLL可以由任何語言實現,但是為了使DLL用於其它的語言和環境之下,一個DLL介面必須後退到最低要求的母體——C語言。

使用C介面並不自動意味一個開發者應該應該放棄面向物件的開發方式。甚至C介面也能用於真正的面向物件程式設計,儘管它有可能被認為是一種單調乏味的實現方式。很顯然世界上使用人數排第二的程式語言是C++,但它卻不得不被DLL所誘惑。然而,和C語言相反,在呼叫者和被呼叫者之間的二進位制介面被很好的定義並被廣泛接受,但是在C++的世界裡卻沒有可識別的應用程式二進位制介面。實際上,由一個C++編譯器產生的二進位制程式碼並不能被其它C++編譯器相容。再者,在同一個編譯器但不同版本的二進位制程式碼也是互不相容的。所有這些導致從一個DLL中一個C++類簡直就是一個冒險。

這篇文章就是演示幾種從一個DLL模組中匯出C++類的方法。原始碼演示了匯出虛構的Xyz物件的不同技巧。Xyz物件非常簡單,只有一個函式:Foo。

下面是Xyz物件的圖解:

Xyz

int Foo(int)

Xyz物件在一個DLL裡實現,這個DLL能作為一個分散式系統供範圍很廣的客戶端使用。一個使用者能以下面三種方式呼叫Xyz的功能:

  • 使用純C
  • 使用一個規則的C++類
  • 使用一個抽象的C++介面

原始碼(譯註:文章附帶的原始碼)包含兩個工程:

  • XyzLibrary – 一個DLL工程
  • XyzExecutable – 一個Win32 使用"XyzLibrary.dll"的控制檯程式

XyzLibrary

工程使用下列方便的巨集匯出它的程式碼:

  1. #if defined(XYZLIBRARY_EXPORT) // inside DLL
  2. #   define XYZAPI   __declspec(dllexport)
  3. #else // outside DLL
  4. #   define XYZAPI   __declspec(dllimport)
  5. #endif  // XYZLIBRARY_EXPORT

XYZLIBRARY_EXPORT識別符號僅僅在XyzLibrary工程定義,因此在XYZAPI巨集在DLL生成時被擴充套件為__declspec(dllexport)而在客戶程式生成時被擴充套件為__declspec

(dllimport)

C語言方式

控制代碼

  經典的C語言方式進行面向物件程式設計的一種方式就是使用晦澀的指標,比如控制代碼。一個使用者能夠使用一個函式建立一個物件。實際上這個函式返回的是這個物件的一個控制代碼。接著使用者能夠呼叫這個物件相關的各種操作函式只要這個函式能夠接受這個控制代碼作為它的一個引數。一個很好的例子就是在Win32視窗相關的API中控制代碼的習慣是使用一個HWND控制代碼來代表一個視窗。虛構的Xyz物件通過下面這樣一種方式匯出一個C介面:

  1. typedef tagXYZHANDLE {} * XYZHANDLE;
  2. // 建立一個Xyz物件例項的函式
  3. XYZAPI XYZHANDLE APIENTRY GetXyz(VOID);
  4. // 呼叫Xyz.Foo函式
  5. XYZAPI INT APIENTRY XyzFoo(XYZHANDLE handle, INT n);
  6. // 釋放Xyz例項和佔用的資源
  7. XYZAPI VOID APIENTRY XyzRelease(XYZHANDLE handle);
  8. // APIENTRY is defined as __stdcall in WinDef.h header.

下面是一個客戶端呼叫的C程式碼:

  1. #include "XyzLibrary.h"
  2. ...
  3. /* 建立Xyz例項*/
  4. XYZHANDLE hXyz = GetXyz();
  5. if(hXyz)
  6. {
  7. /* 呼叫 Xyz.Foo函式*/
  8.     XyzFoo(hXyz, 42);
  9. /*析構 Xyz例項並釋放已取得的資源. */
  10.     XyzRelease(hXyz);
  11.     hXyz = NULL;
  12. }

使用這種方式,一個DLL必須提供顯式的物件構建和刪除函式。

呼叫協定

     對於所有的匯出函式記住它們呼叫協定是重要的。對於很多初學者來說忘記新增呼叫協定是非常普遍的錯誤。只要客戶端的呼叫協定和DLL的呼叫協定匹配,一切都能執行。但是,一旦客戶端改變了它的呼叫協定,開發者將會產生一個難以察覺的直到執行時才發生的錯誤。XyzLibrary工程使用一個APIENTRY巨集,這個巨集在"WinDef.h"這個標頭檔案裡被定義為__stdcall。

異常安全性

     在DLL範圍內不允許發生C++異常。在一段時間內,C語言不識別C++的異常,並且不能正確處理它們。假如一個物件的方法需要報告一個錯誤,這時一個返回碼需要用到。

優點

l一個DLL能被最廣泛的合適的開發者所使用。幾乎每一種現代程式語言都支援純C函式的互用性。

l一個DLL的C執行時庫和它的客戶端是互相獨立的。因為資源的獲取和釋放完全發生在DLL模組的內部,所以一個客戶端不受一個DLL的C執行時庫選擇的影響。

缺點

l   獲取正確物件的合適的方法的責任落在DLL的使用者的肩上。比如在下面的程式碼片斷,編譯器不能捕捉到其中發生的錯誤:

缺點

l         獲取正確物件的合適的方法的責任落在DLL的使用者的肩上。比如在下面的程式碼片斷,編譯器不能捕捉到其中發生的錯誤:

  1. /* void* GetSomeOtherObject(void)是別的地方定義的一個函式 */
  2. XYZHANDLE h = GetSomeOtherObject();
  3. /* 啊! 錯誤: 在錯誤的物件例項上呼叫Xyz.Foo函式*/
  4. XyzFoo(h, 42);

            l         顯式要求建立和摧毀一個物件的例項。其中特別煩人的是物件例項的刪除。客戶端必須極仔細地在一個函式的退出點呼叫XyzRelease函式。假如開發者忘記呼叫XyzRelease函式,那時資源就會洩露,因為編譯器不能跟蹤一個物件例項的生命週期。那些支援解構函式或垃圾收集器的語言通過在C介面上作一層封裝有助於降低這個問題發生的概率。

l         假如一個物件的函式返回或接受其它物件作為引數,那時DLL作者也就不得不為這些物件提供一個正確的C介面。假如退回到最大限度的複用,也就是C語言,那麼只有以位元組建立的型別(如int,double, char*等等)可以作為返回型別和函式引數

     C++天然的方式:匯出一個類

Windows平臺上幾乎每一個現代的編譯器都支援從一個DLL中匯出一個類。匯出一個類和匯出一個C函式非常相似。用那種方法一個開發者被要求做就是在類名之前使用__declspec(dllexport/dllimport)關鍵字來指定假如整個類都需要被匯出,或者在指定的函式宣告前指定假如只是特定的類函式需要被匯出。這兒有一個程式碼片斷:

  1. // 整個CXyz類被匯出,包括它的函式和成員
  2. class XYZAPI CXyz
  3. {
  4. public:
  5. int Foo(int n);
  6. };
  7. // 只有 CXyz::Foo函式被匯出
  8. //
  9. class CXyz
  10. {
  11. public:
  12.     XYZAPI int Foo(int n);
  13. };

在匯出整個類或者它們的方法沒有必要顯式指定一個呼叫協定。根據預設,C++編譯器使用__thiscall作為類成員函式的呼叫協定。然而,由於不同的編譯器具有不同的命名修飾法則,匯出的C++類只能用於同一型別的同一版本的編譯器。這兒有一個MS Visual C++編譯器的命名修飾法則的應用例項:

   注意這裡修飾名是怎樣不同於C++原來的名字。下面是螢幕截圖顯示的是通過使用Dependency Walker工具對同一個DLL的修飾名進行破譯得到的:

 

     只有MS Visual C++編譯器能使用這個DLL.DLL和客戶端程式碼只有在同一版本的MS Visual C++編譯器才能確保在呼叫者和被呼叫者修飾名匹配。這兒有一個客戶端程式碼使用Xyz物件的例子:

  1. #include "XyzLibrary.h"
  2. ...
  3. // 客戶端使用Xyz物件作為一個規則C++類.
  4. CXyz xyz;
  5. xyz.Foo(42);

正如你所看到的,匯出的C++類的用法和其它任何C++類的用法幾乎是一樣的。沒什麼特別的。

重要事項:使用一個匯出C++類的DLL和使用一個靜態庫沒有什麼不同。所有應用於有C++程式碼編譯出來的靜態庫的規則完全適用於匯出C++類的DLL

所見即所得

一個細心的讀者必然已經注意到Dependency Walker工具顯示了額外的匯出成員,那就是CXyz& CXyz::operator =(const CXyz&)賦值操作符。在工作你所看到的正是C++的收入(譯註:我估計這是原文作者幽默的說法,意思是你沒有定義一個=賦值操作符,而編譯器幫你自動定義一個,不是收入是什麼?)。根據C++標準,每一個類有四個指定的成員函式:

  • 預設建構函式
  • 拷貝建構函式
  • 解構函式
  • 賦值操作符 (operator =)

假如類的作者沒有宣告同時沒有提供這些成員的實現,那麼C++編譯器會宣告它們,併產生一個隱式的預設的實現。在CXyz類,編譯器斷定它的預設建構函式,拷貝建構函式和解構函式都毫無意義,經過優化後把它們排除掉了。而賦值運算子在優化之後還存活並從DLL中匯出。

重要事項:使用__declspec(dllexport)來指定類匯出來告訴編譯器來嘗試匯出任何和類相關的東西。它包括所有類的資料成員,所有類的成員函式(或者顯式宣告,或者由編譯器隱式生成),所有類的基類和所有它們的成員。考慮:

  1. class Base
  2. {
  3.     ...
  4. };
  5. class Data
  6. {
  7.     ...
  8. };
  9. // MS Visual C++ compiler 會發出C4275 warning ,因為沒有匯出基類
  10. class__declspec(dllexport) Derived :
  11. public Base
  12. {
  13.     ...
  14. private:
  15.     Data m_data;    // C4251 warning,因為沒有匯出資料成員.
  16. };

    在上面的程式碼片斷,編譯器會警告你沒有匯出基類和類的資料成員。所以,為了成功匯出一個類,一個開發者被要求匯出所有相關基類和所有類的已定義的資料成員。這個滾雪球般的匯出要求是一個重大缺點。這也是為什麼,比如,匯出派生自STL模板類或者使用STL模板類物件作為資料成員是非常困難和令人生厭的。比如一個STL容器比如std::map<>例項可能要求匯出數十個額外的內部類。

異常安全性

一個匯出的C++類可能會在沒有任何錯誤發生的情況下丟擲異常。因為一個DLL和它的客戶端使用同一版本的同一型別的編譯器的事實,C++異常將在超出DLL的範圍進行捕捉和丟擲好像DLL沒有分界線一樣。記住,使用一個帶有匯出C++程式碼和使用帶有相同程式碼的靜態庫是完全一樣的。

優點

l         一個匯出的C++類和其它任何C++類的用法是一樣的

l         客戶端能毫不費力地捕捉在DLL發生的異常

l         當在一個DLL模組內有一些小的程式碼改動時,其它模組也不用重新生成。這對於有著許多複雜難懂程式碼的大工程是非常有用的。

l         在一個大工程中按照業務邏輯分成不同的DLL實現可以被認為真正的模組劃分的第一步。總的來說,它是使工程達到模組化值得去做的事

缺點

l         從一個DLL中匯出C++類在它的物件和使用者需要保持緊密的聯絡。DLL應該被視作一個帶有考慮到程式碼依賴的靜態庫。

l         客戶端程式碼和DLL都必須和同一版本的CRT(譯註:C執行時庫)動態連線在一起。為了能夠在模組之間修正CRT資源的紀錄,這一步是必需的。假如一個客戶端和DLL連線到不同版本的CRT,或者靜態連線到CRT,那麼在一個CRT例項申請的資源有可能在另一個CRT例項中釋放。它將損壞CRT例項的內在狀態並企圖操作外部資源,並很可能導致執行失敗。

l         客戶端程式碼和DLL必須在異常處理和產生達成一致,同時在編譯器的異常設定也必須一致

l         匯出C++類要求同時匯出這個類的所有相關的東西,包括:所有它的基類、所有類定義的用到的資料成員等等。

C++成熟的方法:使用抽象介面

一個C++抽象介面(比如一個擁有純虛擬函式和沒有資料成員的C++類)設法做到兩全其美:對物件而言獨立於編譯器的規則的介面以及方便的面向物件方式的函式呼叫。為達到這些要求去做的就是提供一個介面宣告的標頭檔案,同時實現一個能返回最新建立的物件例項的工廠函式。只有這個工廠函式需要使用__declspec(dllexport/dllimport)指定。介面不需要任何額外的指定。

  1. // Xyz object的抽象介面
  2. // 不要求作額外的指定
  3. struct IXyz
  4. {
  5.     virtual int Foo(int n) = 0;
  6.     virtual void Release() = 0;
  7. };
  8. // 建立Xyz物件例項的工廠函式
  9. extern "C" XYZAPI IXyz* APIENTRY GetXyz();

在上面的程式碼片斷中,工廠函式GetXyz被宣告為extern XYZAPI。這樣做是為了防止函式名被修飾(譯註:如上面提到的匯出一個C++類,其成員函式名匯出後會被修飾)。這樣,這個函式在外部表現為一個規則的C函式,並且很容易被和C相容的編譯器所識別。這就是當使用一個抽象介面時客戶端程式碼看起來和下面一樣:

  1. #include "XyzLibrary.h"
  2. ...
  3. IXyz* pXyz = ::GetXyz();
  4. if(pXyz)
  5. {
  6.     pXyz->Foo(42);
  7.     pXyz->Release();
  8.     pXyz = NULL;
  9. }

C++不用為介面提供一個特定的標記以便其它程式語言使用(比如C#Java)。但這並不意味C++不能宣告和實現介面。設計一個C++的介面的一般方法是去宣告一個沒有任何資料成員的抽象類。這樣,派生類可以繼承這個介面並實現這個介面,但這個實現對客戶端是不可見的。介面的客戶端不用知道和關注介面是如何實現的。它只需知道函式是可用的和它們做什麼。

內部機制

在這種方法背後的思想是非常簡單的。一個由純虛擬函式組成的成員很少的類只不過是一個虛擬函式表——一個函式指標陣列。在DLL範圍內這個函式指標陣列被它的作者填充任何他認為必需的東西。這樣這個指標陣列在DLL外部使用就是呼叫介面的實際上的實現。下面是IXyz介面的用法說明圖表。

          

    上面的圖表演示了IXyz介面被DLL和EXE模組二者都用到。在DLL模組內部,XyzImpl類派生自IXyz介面並實現它的方法。在EXE的函式呼叫引用DLL模組經過一個虛表的實際實現。

這種DLL為什麼能和其它的編譯器一起執行

簡短的解釋是:因為COM技術和其它的編譯器一起執行。現在作一個詳細解釋,實際上,在模組之間使用一個成員很少的虛基類作為介面準確來說是COM對外暴露了一個COM介面。如我們所知的虛表的概念,能很精確地新增COM標準的標記。這不是一個巧合。C++語言,作為一個至少跨越了十年的主流開發語言,已經廣泛地應用在COM程式設計。因為C++天生地支援面向物件的特性。微軟將它作為產業COM開發的重量級的工具是毫不奇怪的。作為COM技術的所有者,微軟已經確保COM的二進位制標準和它們擁有的在Visual C++編譯器實現的C++物件模型能以最小的成本實現匹配。

難怪其它的編譯器廠商都和微軟採用相同的方式實現虛表的佈局。畢竟,每個人都想支援COM技術,並做到和微軟已存在的解決方法相容。假設某個C++編譯器不能有效支援COM,那麼它註定會被Windows市場所拋棄。這就是為什麼時至今日,通過一個抽象介面從一個DLL匯出一個C++類能和Windows平臺上過得去的編譯器能可靠地執行在一起。

使用一個智慧指標

為了確保正確的資源釋放,一個虛介面提供了一個額外的函式來清除物件例項。手動呼叫這個函式令人厭煩並容易導致錯誤發生。我們都知道這個錯誤在C世界裡這是一個很普遍的錯誤,因為在那兒開發者不得不記得釋放顯式函式呼叫獲取的資源。這就是為什麼典型的C++程式碼藉助於智慧指標使用RAII(資源獲取即初始化)的習慣。XyzExecutable工程提供了一個例子,使用了AutoClosePtr模板。AutoClosePtr模板是一個最簡單的智慧指標,這個智慧指標呼叫了一個類消滅一個例項的主觀方法來代替delete操作符。這兒有一段演示帶有IXyz介面的一個智慧指標的用法的程式碼片斷:

  1. #include "XyzLibrary.h"
  2. #include "AutoClosePtr.h"
  3. ...
  4. typedef AutoClosePtr<IXyz, void, &IXyz::Release> IXyzPtr;
  5. IXyzPtr ptrXyz(::GetXyz());
  6. if(ptrXyz)
  7. {
  8.     ptrXyz->Foo(42);
  9. }
  10. // 不需要呼叫ptrXyz->Release(). 智慧指標將在解構函式裡自動呼叫這個函式

不管怎樣,使用智慧指標將確保Xyz物件能正當地適當資源。因為一個錯誤或者一個內部異常的發生,函式會過早地退出,但是C++語言保證所有區域性物件的解構函式能在函式退出之前被呼叫。

異常安全性

COM介面一樣不再允許因為任何內部異常的發生而導致資源洩露,抽象類介面不會讓任何內部異常突破DLL範圍。函式呼叫將會使用一個返回碼來明確指示發生的錯誤。對於特定的編譯器,C++異常的處理都是特定的,不能夠分享。所以,在這個意義上,一個抽象類介面表現得十足像一個C函式。

優點:

l         一個匯出的C++類能夠通過一個抽象介面,被用於任何C++編譯器

l         一個DLLC執行庫和DLL的客戶端是互相獨立的。因為資源的初始化和釋放都完全發生在DLL內部,所以客戶端不受DLLC執行庫選擇的影響。

l         真正的模組分離能高度完美實現。結果模組可以重新設計和重新生成而不受工程的剩餘模組的影響。

l         如果需要,一個DLL模組能很方便地轉化為真正的COM模組。

缺點:

l         一個顯式的函式呼叫需要建立一個新的物件例項並刪除它。儘管一個智慧指標能免去開發者之後的呼叫

l         一個抽象介面函式不能返回或者接受一個規則的C++物件作為一個引數。它只能以內建型別(如intdoublechar*等)或者另一個虛介面作為引數型別。它和COM介面有著相同的限制。

STL模板類是怎樣做的

C++標準模板庫的容器(如vector,listmap)和其它模板並沒有設計為DLL模組(以抽象類介面方式)。有關DLLC++標準是沒有的因為DLL是一種平臺特定技術。C++標準不需要出現在沒有用到C++語言的其它平臺上。當前,微軟的Visual C++編譯器能夠匯出和匯入開發者顯式以__declspec(dllexport/dllimport)關鍵字標識的STL類例項。編譯器會發出幾個令人討厭的警告,但是還能執行。然而,你必須記住,匯出STL模板例項和匯出規則C++類是完全一樣的,有著一樣的限制。所以,在那方面STL是沒什麼特別的。

總結

這篇文章討論了幾種從一個DLL模組中匯出一個C++物件的不同方法。對每種方法的優點和缺點的詳細論述也已給出。下面是得出的幾個結論:

l         以一個完全的C函式匯出一個物件有著最廣泛的開發環境和開發語言的相容性。然而,為了使用現代程式設計正規化一個DLL使用者被要求使用過時的C技巧對C介面作一層額外的封裝。

l         匯出一個規則的C++類和以C++程式碼提供一個單獨的靜態庫沒什麼區別。用法非常簡單和熟悉,然而DLL和客戶端有著非常緊密的連線。DLL和它的客戶端必須使用相同版本和相同型別的編譯器。

l         定義一個無資料成員的抽象類並在DLL內部實現是匯出C++物件的最好方法。到目前為止,這種方法在DLL和它的客戶端提供了一個清晰的,明確界定的面向物件介面。這樣一種DLL能在Windows平臺上被任何現代C++編譯器所使用。介面和智慧指標一起結合使用的用法幾乎和一個匯出的C++類的用法一樣方便。