介绍
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++类的用法一样方便。