1. 程式人生 > >【ubuntu】windows鏈接庫——怎樣從DLL導出C++類

【ubuntu】windows鏈接庫——怎樣從DLL導出C++類

接口 文章 世紀 depend 多個 一起 關註 靜態 hole

原文鏈接(附代碼)
翻譯原文

介紹

DLL(動態鏈接庫)允許在一個獨立的模塊中封裝一系列功能函數,然後以一個顯式的C函數列表提供給外部使用者使用。在上個世紀80年代,當Dlls面世時,對於廣大開發者只有C語言是切實可行的開發手段。所以,winddows DLLs很自然的以C函數和數據的形式向外暴露功能。但實際上,Dll內部實現可通過任何語言實現,為了可以應用於其他語言和環境,DLL接口需要後退至最低的通用語言——C語言。
使用C接口並不是說開發者應該丟棄面向對象的開發方法。盡管這種方式可能被認為是一種單調乏味的實現呢方式,但實際上C接口也能用於真正的面向對象編程。C++作為第二大編程語言也可以用於DLL封裝,但是和C語言不同的是,當調用者和被調用者之間的二進制接口被很好的定義了,但卻不能被C++中任何應用程序二進制接口(ABI)識別。這意味著由C++編譯器生成的二進制代碼無法被其他的C++編譯器識別。還有,同一的C++編譯器生成的二進制代碼可能會由於編譯器版本不同而不兼容。所有的這些都使得從DLL中導出C++類是一個巨大的冒險。

這篇文章的目的是為了展示幾種從DLL模塊導出C++類的方法。 給的例子是導出類Xyz對象,Xyz只有一個方法Foo,Xyz的實現在DLL中,以便被多個使用者調用。調用者可以通過不同方式方位Xyz的函數:

  • 純C
  • 常規的C++類
  • 抽象c++類接口

實例源碼包括兩部分:XyzLibrary + XyzExecutable. XyzLibrary是動態鏈接庫工程,通過下面的宏定義將相關的代碼導出:

#if defined(XYALIBRARY_EXPORT) //inside dll
#define XYZAPI __declspec(dllexport)
#else //outside dll
#define XYZAPI __declspec(dllimport)
#endif //XYZLIBRARY_EXPORT

符號XYZLIBRARY_EXPORT只在XyzLibrary工程中定義,所以XYZAPI宏就被擴展為__declspec(dllexport)用於建立DLL,而對於使用者工程,XYZAPI宏被擴展為__declspec(dllimport)

純C方法

句柄

使用C語言進行面向對象編程的一種方式是使用晦澀的指針,比如句柄。用戶調用一個函數,這個函數在內部創建一個對象,返回指向這個對象的句柄。然後用戶就可以調用不同的函數,並將這個句柄作為參數,實現對對象的不同操作。win32有一個很好的示例就是HWND。在此處,Xyz對象通過C接口進行導出:

typedef tagXYZHANDLE{} *XYZHANGLE; 
XYZAPI XYZHANDLE APIENTRY GetXyz(VOID);//Factory function that creates instances of the Xyz object.
XYZAPI INIT APIENTRY XyzFoo(XYZHANDLE handle, INT n); //Calls Xyz.Foo method.
XYZAPI VOID APIENTRY XyzRelease(XYZHANDLE handle);//Release Xyz instance and frees resources.

//APIENTRY is defined as __stdcall in WinDef.h header.

對應的用戶調用Dll的C代碼如下所示:

#include "XyzLibrary.h"
...
/*Create Xyz instance.*/
XYZHANDLE hXyz = GetXyz();
if(hXyz) {
    /*call Xyz.foo method.*/
    XyzFoo(hXyz, 42);
    XyzRelease(hXyz); /*Destroy Xyz instance and release qcquired resources.*/
    hXyz = NULL; /*Be defensive.*/
}

用這個方法,DLL必須顯式的提供對象創建、刪除函數。

調用協定

對於所有的導出函數記住它們調用協定是重要的。使用者的調用協定和DLL的調用協定匹配時一切都能運行。但是,一旦客戶端改變了它的調用協定,將會產生一個難以察覺的直到運行時才發生的錯誤。XyzLibrary工程使用一個APIENTRY宏,這個宏在"WinDef.h"這個頭文件裏被定義為__stdcall。

異常

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

優點

  • 生成的DLL能被最廣泛的開發者所使用。幾乎每一種現代編程語言都支持純C函數的互用性。
  • DLL的C運行時庫和它的客戶端是互相獨立的。因為資源的獲取和釋放完全發生在DLL模塊的內部,所以客戶端不受DLL的C運行時庫選擇的影響。

缺點

  • 對對象實例的調用正確的方法的責任完全落在dll的使用者,例如:
/* void* GetSomeOtherObject(void) is declared elsewhere. */
XYZHANDLE h = GetSomeOtherObject();

/* Oops! Error: Calling Xyz.Foo on wrong object intance. */
XyzFoo(h, 42);

編譯器無法獲取到上面調用的錯誤。

  • 需要顯式函數調用實現對象實例的創建和刪除。刪除實例尤其惱人。客戶端函數必須在退出時調用XyzRelease函數,如果忘記調用,就會出現內存泄露,因為編譯器不能跟蹤對象實例的生命周期。那些支持析構或者有垃圾回收器的編程語言可能通過在C接口之上創建一個wrapper來解決這個問題。

  • 如果對象方法返回或接受別的對象作為參數,那麽DLL創建者需要也需對這個對象提供一個合適的C接口。 可選擇的方法就是,使用C語言內置的基礎數據類型作為返回值和方法參數(如:int,double,char*等)。

C++常規方法

幾乎現在的每個C++編譯器都支持windows平臺下從dll導出C++類。導出C++類和C函數很類似,需要做的就是在需要導出的類名前使用__declspec(dllexport/dllimport),或者在聲明的方法前使用,如果你只需要某個類的成員方法導出。例如:

// The whole CXyz class is exported with all its methods and members.
//
class XYZAPI CXyz
{
public:
    int Foo(int n);
};

// Only CXyz::Foo method is exported.
//
class CXyz
{
public:
    XYZAPI int Foo(int n);
};

這裏不需要顯式明確導出類及其方法的調用協定。默認情況下,C++編譯器對類方法使用__thiscall調用協議。但是,由於不同的編譯器可能使用不同的命名修飾方法,導出類可能只能用於同一版本的同類編譯器。下面是MS Visual C++編譯器的名稱修飾實例:
技術分享圖片

註意修飾後的名字和原來C++名字的區別,下面是相同的DLL模塊被Dependency 工具解碼後的名字:
技術分享圖片

只有MS Visual C++編譯器可以使用這個DLL。DLL和使用者代碼都必須用相同版本的MS VisualC++編譯器編譯,確保調用者和被調用者之間的名字的匹配關系。
註意:使用一個導出C++類的DLL和使用一個靜態庫沒有什麽不同。所有應用於有C++代碼編譯出來的靜態庫的規則完全適用於導出C++類的DLL。

所見並非所得

細心地讀者可能已經發現Dependency工具顯示了一個額外的導出類方法,賦值運算符。我們看到的是C++內存時的工作狀態,根據C++標準,每個類有四個特殊成員函數:

  • 默認構造方法
  • 拷貝構造函數
  • 析構函數
  • 賦值運算

如果類作者不聲明或者沒有提供這些函數的實現,那麽C++編譯器會自動聲明並內部默認實現。在本例中,前三個函數使用默認的,但賦值運算卻從DLL中導出。

註意:使用__declspec(dllexport)導出類告訴編譯器而導出與該類相關的一切。它包括類的成員數據,所有的類成員方法(顯式聲明或者編譯器隱式生成的),類的基類以及他們所有的成員。例如

class Base
{
    ...
};

class Data
{
    ...
};

// MS Visual C++ compiler emits C4275 warning about not exported base class.
class __declspec(dllexport) Derived :
    public Base
{
    ...

private:
    Data m_data;    // C4251 warning about not exported data member.
};

在該例中,編譯器將會警告你沒有導出的基類和沒有導出的類成員數據。所以,為了成功導出C++類,開發者需要導出所有相關基類和所有所有用於數據成員定義的類。這是一個非常大的缺點,這也是為什麽想要導出STL模板類的派生類或者使用STL模板類成員變量的類非常困難的原因。例如std::map<>的實例可能需要大量的內部類導出。

異常安全

導出的C++類可能跑出異常沒有問題。記住:使用DLL導出C++代碼與使用靜態lib等價。

優點

  • 導出的C++類可以與其他類一樣使用;
  • 異常拋出可被正常獲取;
  • DLL模塊中小改動不會影響其他模塊,不需要其他模塊重新生成;
  • 對於工程的模塊化有好處。

缺點

  • DLL被視為靜態庫;
  • 必須使用相同版本的C運行時庫;
  • 導出這個類的所有相關的東西,包括:基類、定義數據成員用到的類等;
  • 必須遵守相同的異常處理協定;

成熟的C++方法:抽象類接口

  一個C++抽象接口(比如一個擁有純虛函數和沒有數據成員的C++類)設法做到兩全其美:對對象而言獨立於編譯器的規則的接口以及方便的面向對象方式的函數調用。為達到這些要求去做的就是提供一個接口聲明的頭文件,同時實現一個能返回最新創建的對象實例的工廠函數。只有這個工廠函數需要使用__declspec(dllexport/dllimport)指定。接口不需要任何額外的指定。
// The abstract interface for Xyz object.
// No extra specifiers required.
struct IXyz
{
    virtual int Foo(int n) = 0;
    virtual void Release() = 0;
};

// Factory function that creates instances of the Xyz object.
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);
}

// No need to call ptrXyz->Release(). Smart pointer
// will call this method automatically in the destructor.

不管怎樣,使用智能指針將確保Xyz對象能正當地適當資源。因為一個錯誤或者一個內部異常的發生,函數會過早地退出,但是C++語言保證所有局部對象的析構函數能在函數退出之前被調用。

異常安全

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

優點

  • 一個導出的C++類能夠通過一個抽象接口,被用於任何C++編譯器
  • 一個DLL的C運行庫和DLL的客戶端是互相獨立的。因為資源的初始化和釋放都完全發生在DLL內部,所以客戶端不受DLL的C運行庫選擇的影響。
  • 真正的模塊分離能高度完美實現。結果模塊可以重新設計和重新生成而不受工程的剩余模塊的影響。
  • 如果需要,一個DLL模塊能很方便地轉化為真正的COM模塊。

缺點

  • 一個顯式的函數調用需要創建一個新的對象實例並刪除它。盡管一個智能指針能免去開發者之後的調用
  • 一個抽象接口函數不能返回或者接受一個規則的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++對象的不同方法。對每種方法的優點和缺點的詳細論述也已給出。下面是得出的幾個結論:

  • 以一個完全的C函數導出一個對象有著最廣泛的開發環境和開發語言的兼容性。然而,為了使用現代編程範式一個DLL使用者被要求使用過時的C技巧對C接口作一層額外的封裝。
  • 導出一個規則的C++類和以C++代碼提供一個單獨的靜態庫沒什麽區別。用法非常簡單和熟悉,然而DLL和客戶端有著非常緊密的連接。DLL和它的客戶端必須使用相同版本和相同類型的編譯器。
  • 定義一個無數據成員的抽象類並在DLL內部實現是導出C++對象的最好方法。到目前為止,這種方法在DLL和它的客戶端提供了一個清晰的,明確界定的面向對象接口。這樣一種DLL能在Windows平臺上被任何現代C++編譯器所使用。接口和智能指針一起結合使用的用法幾乎和一個導出的C++類的用法一樣方便。

【ubuntu】windows鏈接庫——怎樣從DLL導出C++類