原文作者: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工程使用下列方便的宏導出它的代碼:
- #if defined(XYZLIBRARY_EXPORT) // inside DLL
- # define XYZAPI __declspec(dllexport)
- #else // outside DLL
- # define XYZAPI __declspec(dllimport)
- #endif // XYZLIBRARY_EXPORT
XYZLIBRARY_EXPORT
標識符僅僅在
XyzLibrary工程定義,因此在XYZAPI
宏在
DLL
生成時被擴展為
__declspec(
dllexport)
而在客戶程序生成時被擴展為
__declspec(
dllimport)
。
C
語言方式
經典的C語言方式進行面向對象編程的一種方式就是使用晦澀的指針,比如句柄。一個用戶能夠使用一個函數創建一個對象。實際上這個函數返回的是這個對象的一個句柄。接着用戶能夠調用這個對象相關的各種操作函數只要這個函數能夠接受這個句柄作為它的一個參數。一個很好的例子就是在Win32窗口相關的API中句柄的習慣是使用一個HWND句柄來代表一個窗口。虛構的Xyz對象通過下面這樣一種方式導出一個C接口:
- typedef tagXYZHANDLE {} * XYZHANDLE;
- // 創建一個Xyz對象實例的函數
- XYZAPI XYZHANDLE APIENTRY GetXyz(VOID);
- // 調用Xyz.Foo函數
- XYZAPI INT APIENTRY XyzFoo(XYZHANDLE handle, INT n);
- // 釋放Xyz實例和占用的資源
- XYZAPI VOID APIENTRY XyzRelease(XYZHANDLE handle);
- // APIENTRY is defined as __stdcall in WinDef.h header.
下面是一個客戶端調用的C代碼:
- #include "XyzLibrary.h"
- /* 創建Xyz實例*/
- XYZHANDLE hXyz = GetXyz();
- if(hXyz)
- /* 調用 Xyz.Foo函數*/
- XyzFoo(hXyz, 42);
- /*析構 Xyz實例並釋放已取得的資源. */
- XyzRelease(hXyz);
- 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的使用者的肩上。比如在下面的代碼片斷,編譯器不能捕捉到其中發生的錯誤:
- /* void* GetSomeOtherObject(void)是別的地方定義的一個函數 */
- XYZHANDLE h = GetSomeOtherObject();
- /* 啊! 錯誤: 在錯誤的對象實例上調用Xyz.Foo函數*/
- XyzFoo(h, 42);
l
顯式要求創建和摧毀一個對象的實例。其中特別煩人的是對象實例的刪除。客戶端必須極仔細地在一個函數的退出點調用XyzRelease
函數。假如開發者忘記調用
XyzRelease
函數,那時資源就會泄露,因為編譯器不能跟蹤一個對象實例的生命周期。那些支持析構函數或垃圾收集器的語言通過在
C
接口上作一層封裝有助於降低這個問題發生的概率。
l
假如一個對象的函數返回或接受其它對象作為參數,那時
DLL
作者也就不得不為這些對象提供一個正確的
C
接口。假如退回到最大限度的復用,也就是
C
語言,那么只有以字節創建的類型(如
int, double, char*
等等)可以作為返回類型和函數參數
C++
天然的方式:導出一個類
在
Windows
平台上幾乎每一個現代的編譯器都支持從一個
DLL
中導出一個類。導出一個類和導出一個
C
函數非常相似。用那種方法一個開發者被要求做就是在類名之前使用
__declspec(dllexport/dllimport)
關鍵字來指定
假如整個類都需要被導出,或者在指定的函數聲明前指定假如只是特定的類函數需要被導出。這兒有一個代碼片斷:
- // 整個CXyz類被導出,包括它的函數和成員
- class XYZAPI CXyz
- public:
- int Foo(int n);
- // 只有 CXyz::Foo函數被導出
- class CXyz
- public:
- XYZAPI int Foo(int n);
在導出整個類或者它們的方法沒有必要顯式指定一個調用協定。
根據預設,C++編譯器使用__thiscall
作為類成員函數的調用協定。然而,由於不同的編譯器具有不同的命名修飾法則,導出的C++類只能用於同一類型的同一版本的編譯器。這兒有一個MS Visual C++編譯器的命名修飾法則的應用實例:
注意這里修飾名是怎樣不同於C++原來的名字。下面是屏幕截圖顯示的是通過使用Dependency Walker 工具對同一個DLL的修飾名進行破譯得到的:
只有MS Visual C++編譯器能使用這個DLL.DLL和客戶端代碼只有在同一版本的MS Visual C++編譯器才能確保在調用者和被調用者修飾名匹配。這兒有一個客戶端代碼使用Xyz對象的例子:
- #include "XyzLibrary.h"
- // 客戶端使用Xyz對象作為一個規則C++類.
- CXyz xyz;
- 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)
來指定類導出來告訴編譯器來嘗試導出任何和類相關的東西。它包括所有類的數據成員,所有類的成員函數(或者顯式聲明,或者由編譯器隱式生成),所有類的基類和所有它們的成員。考慮:
- class Base
- class Data
- // MS Visual C++ compiler 會發出C4275 warning ,因為沒有導出基類
- class __declspec(dllexport) Derived :
- public Base
- private:
- 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)
指定。接口不需要任何額外的指定。
- // Xyz object的抽象接口
- // 不要求作額外的指定
- struct IXyz
- virtual int Foo(int n) = 0;
- virtual void Release() = 0;
- // 創建Xyz對象實例的工廠函數
- extern "C" XYZAPI IXyz* APIENTRY GetXyz();
在上面的代碼片斷中,工廠函數
GetXyz
被聲明為
extern XYZAPI
。這樣做是為了防止函數名被修飾(譯注:如上面提到的導出一個
C++
類,其成員函數名導出后會被修飾)。這樣,這個函數在外部表現為一個規則的
C
函數,並且很容易被和
C
兼容的編譯器所識別。這就是當使用一個抽象接口時客戶端代碼看起來和下面一樣:
- #include "XyzLibrary.h"
- IXyz* pXyz = ::GetXyz();
- if(pXyz)
- pXyz->Foo(42);
- pXyz->Release();
- 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
接口的一個智能指針的用法的代碼片斷:
- #include "XyzLibrary.h"
- #include "AutoClosePtr.h"
- typedef AutoClosePtr<IXyz, void, &IXyz::Release> IXyzPtr;
- IXyzPtr ptrXyz(::GetXyz());
- if(ptrXyz)
- ptrXyz->Foo(42);
- // 不需要調用ptrXyz->Release(). 智能指針將在析構函數里自動調用這個函數
不管怎樣,使用智能指針將確保
Xyz
對象能正當地適當資源。因為一個錯誤或者一個內部異常的發生,函數會過早地退出,但是
C++
語言保證所有局部對象的析構函數能在函數退出之前被調用。
異常安全性
和
COM
接口一樣不再允許因為任何內部異常的發生而導致資源泄露,抽象類接口不會讓任何內部異常突破
DLL
范圍。函數調用將會使用一個返回碼來明確指示發生的錯誤。對於特定的編譯器,
C++
異常的處理都是特定的,不能夠分享。所以,在這個意義上,一個抽象類接口表現得十足像一個
C
函數。
優點:
l
一個導出的
C++
類能夠通過一個抽象接口,被用於任何
C++
編譯器
l
一個
DLL
的
C
運行庫和
DLL
的客戶端是互相獨立的。因為資源的初始化和釋放都完全發生在
DLL
內部,所以客戶端不受
DLL
的
C
運行庫選擇的影響。
l
真正的模塊分離能高度完美實現。結果模塊可以重新設計和重新生成而不受工程的剩余模塊的影響。
l
如果需要,一個
DLL
模塊能很方便地轉化為真正的
COM
模塊。
缺點:
l
一個顯式的函數調用需要創建一個新的對象實例並刪除它。盡管一個智能指針能免去開發者之后的調用
l
一個抽象接口函數不能返回或者接受一個規則的
C++
對象作為一個參數。它只能以內置類型(如
int
、
double
、
char*
等)或者另一個虛接口作為參數類型。它和
COM
接口有着相同的限制。
STL
模板類是怎樣做的
C++
標准模板庫的容器(如
vector,list
或
map
)和其它模板並沒有設計為
DLL
模塊(以抽象類接口方式)。有關
DLL
的
C++
標准是沒有的因為
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
職業:軟件開發者
國籍:以色列