(轉)怎樣從一個DLL中導出一個C++類


 

 原文作者:Alex Blekhman

 翻譯:朱金燦

原文來源:

http://www.codeproject.com/KB/cpp/howto_export_cpp_classes.aspx

譯文來源:http://blog.csdn.net/clever101/article/details/3034743

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++接口

源碼(譯注:文章附帶的源碼)包含兩個工程:

  • 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. /* 創建Xyz實例*/
  3. XYZHANDLE hXyz = GetXyz();
  4. if(hXyz)
  5.     /* 調用 Xyz.Foo函數*/
  6.     XyzFoo(hXyz, 42);
  7.     /*析構 Xyz實例並釋放已取得的資源. */
  8.     XyzRelease(hXyz);
  9.     hXyz = NULL;

使用這種方式,一個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. public:
  4.     int Foo(int n);
  5. // 只有 CXyz::Foo函數被導出
  6. class CXyz
  7. public:
  8.     XYZAPI int Foo(int n);
            

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

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

 

     只有MS Visual C++編譯器能使用這個DLL.DLL和客戶端代碼只有在同一版本的MS Visual C++編譯器才能確保在調用者和被調用者修飾名匹配這兒有一個客戶端代碼使用Xyz對象的例子:

  1. #include "XyzLibrary.h"
  2. // 客戶端使用Xyz對象作為一個規則C++類.
  3. CXyz xyz;
  4. 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. class Data
  3. // MS Visual C++ compiler 會發出C4275 warning ,因為沒有導出基類
  4. class __declspec(dllexport) Derived :
  5.     public Base
  6. private:
  7.     Data m_data;    // C4251 warning,因為沒有導出數據成員.

    在上面的代碼片斷,編譯器會警告你沒有導出基類和類的數據成員所以,為了成功導出一個類,一個開發者被要求導出所有相關基類和所有類的已定義的數據成員。這個滾雪球般的導出要求是一個重大缺點這也是為什么,比如,導出派生自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.     virtual int Foo(int n) = 0;
  5.     virtual void Release() = 0;
  6. // 創建Xyz對象實例的工廠函數
  7. extern "C" XYZAPI IXyz* APIENTRY GetXyz();

在上面的代碼片斷中,工廠函數GetXyz被聲明為extern XYZAPI這樣做是為了防止函數名被修飾(譯注:如上面提到的導出一個C++類,其成員函數名導出后會被修飾)這樣,這個函數在外部表現為一個規則的C函數,並且很容易被和C兼容的編譯器所識別這就是當使用一個抽象接口時客戶端代碼看起來和下面一樣:

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

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. typedef AutoClosePtr<IXyz, void, &IXyz::Release> IXyzPtr;
  4. IXyzPtr ptrXyz(::GetXyz());
  5. if(ptrXyz)
  6.     ptrXyz->Foo(42);
  7. // 不需要調用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++類的用法一樣方便

這篇文章,包括任何源碼和文件,遵循The Code Project Open License (CPOL) 協議

Alex Blekhman                  職業:軟件開發者

國籍:以色列

 
 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM