如何从 DLL 导出 C++ 类






4.94/5 (206投票s)
C++ 编程语言和 Windows DLL 终究可以和平共处。
目录
- 引言
- C 语言方法
- 句柄
- 调用约定
- 异常安全
- 优点
- 缺点
- C++ 简易方法:导出一个类
- 所见非所得
- 异常安全
- 优点
- 缺点
- C++ 成熟方法:使用抽象接口
- 工作原理
- 为何此方法适用于其他编译器
- 使用智能指针
- 异常安全
- 优点
- 缺点
- STL 模板类呢?
- 摘要
引言
动态链接库 (DLL) 从一开始就是 Windows 平台不可或缺的一部分。DLL 允许将一部分功能封装在一个独立的模块中,并提供一个明确的 C 函数列表供外部用户使用。在 20 世纪 80 年代,当 Windows DLL 被引入世界时,与广大开发人员沟通的唯一可行选择是 C 语言。因此,Windows DLL 很自然地将其功能公开为 C 函数和数据。在内部,一个 DLL 可以用任何语言实现,但为了能被其他语言和环境使用,DLL 接口应回归到最低的共同标准——C 语言。
使用 C 接口并不自动意味着开发者应该放弃面向对象的方法。即使是 C 接口也可以用于真正的面向对象编程,尽管这可能是一种繁琐的方式。不出所料,世界第二大常用编程语言 C++ 也禁不住 DLL 的诱惑。然而,与 C 语言不同的是,C 语言中调用者和被调用者之间的二进制接口是明确定义且被广泛接受的,而在 C++ 世界中,没有公认的应用程序二进制接口 (ABI)。在实践中,这意味着一个 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 – 一个使用“XyzLibrary.dll”的 Win32 控制台程序
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;
// Factory function that creates instances of the Xyz object.
XYZAPI XYZHANDLE APIENTRY GetXyz(VOID);
// Calls Xyz.Foo method.
XYZAPI INT APIENTRY XyzFoo(XYZHANDLE handle, INT n);
// Releases Xyz instance and frees resources.
XYZAPI VOID APIENTRY XyzRelease(XYZHANDLE handle);
// APIENTRY is defined as __stdcall in WinDef.h header.
以下是客户端 C 代码可能的样子:
#include "XyzLibrary.h"
...
/* Create Xyz instance. */
XYZHANDLE hXyz = GetXyz();
if(hXyz)
{
/* Call Xyz.Foo method. */
XyzFoo(hXyz, 42);
/* Destroy Xyz instance and release acquired resources. */
XyzRelease(hXyz);
/* Be defensive. */
hXyz = NULL;
}
使用这种方法,DLL 必须为对象的创建和删除提供明确的函数。
调用约定
重要的是要记住为所有导出的函数指定调用约定。省略调用约定是许多初学者常犯的一个错误。只要客户端的默认调用约定与 DLL 的匹配,一切正常。但是,一旦客户端更改其调用约定,开发者可能不会注意到,直到运行时发生崩溃。XyzLibrary 项目使用了 APIENTRY
宏,它在“WinDef.h”头文件中被定义为 __stdcall
。
异常安全
任何 C++ 异常都不允许跨越 DLL 边界。就这样。C 语言对 C++ 异常一无所知,无法正确处理它们。如果对象方法需要报告错误,则应使用返回码。
优点
- DLL 可以被尽可能广泛的编程受众使用。几乎每一种现代编程语言都支持与纯 C 函数的互操作性。
- DLL 和客户端的 C 运行时库是相互独立的。由于资源的获取和释放完全在 DLL 模块内部进行,客户端不受 DLL 选择的 CRT(C 运行时库)的影响。
缺点
- 在正确的对象实例上调用正确方法的责任在于 DLL 的用户。例如,在下面的代码片段中,编译器将无法捕捉到错误:
/* void* GetSomeOtherObject(void) is declared elsewhere. */ XYZHANDLE h = GetSomeOtherObject(); /* Oops! Error: Calling Xyz.Foo on wrong object intance. */ XyzFoo(h, 42);
- 需要显式函数调用来创建和销毁对象实例。这在删除实例时尤其烦人。客户端函数必须在函数的所有退出点 meticulously 插入对
XyzRelease
的调用。如果开发者忘记调用XyzRelease
,就会导致资源泄漏,因为编译器不会帮助跟踪对象实例的生命周期。支持析构函数或垃圾回收器的编程语言可以通过在 C 接口上创建一个包装器来缓解这个问题。 - 如果对象方法返回或接受其他对象作为参数,那么 DLL 作者也必须为这些对象提供适当的 C 接口。另一种方法是回归到最低共同标准,即 C 语言,并仅使用内置类型(如
int
、double
、char*
等)作为返回类型和方法参数。
C++ 简易方法:导出一个类
几乎所有存在于 Windows 平台上的现代 C++ 编译器都支持从 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
调用约定。然而,由于不同编译器使用不同的名称修饰方案,导出的 C++ 类只能被同一编译器和同一版本的编译器使用。以下是 MS Visual C++ 编译器应用的名称修饰示例:
注意修饰后的名称与原始 C++ 名称有何不同。下面是使用 Dependency Walker 工具解析名称修饰后的同一 DLL 模块的截图:
现在只有 MS Visual C++ 编译器能使用这个 DLL。DLL 和客户端代码都必须使用相同版本的 MS Visual C++ 进行编译,以确保调用者和被调用者之间的名称修饰方案匹配。以下是使用 Xyz
对象的客户端代码示例:
#include "XyzLibrary.h"
...
// Client uses Xyz object as a regular C++ class.
CXyz xyz;
xyz.Foo(42);
如您所见,导出类的用法与任何其他 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 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<>
这样的 STL 容器可能需要额外导出数十个内部类。
异常安全
导出的 C++ 类可以毫无问题地抛出异常。因为 DLL 及其客户端都使用相同版本的同一 C++ 编译器,C++ 异常可以跨越 DLL 边界抛出和捕获,就好像边界根本不存在一样。请记住,使用导出 C++ 代码的 DLL 与使用包含相同代码的静态库是一样的。
优点
- 导出的 C++ 类可以像任何其他 C++ 类一样使用。
- 在 DLL 内部抛出的异常可以被客户端毫无问题地捕获。
- 当 DLL 模块只进行微小改动时,其他模块无需重新构建。这对于涉及大量代码的大型项目可能非常有益。
- 将大型项目中的逻辑模块分离到 DLL 模块中,可以看作是实现真正模块分离的第一步。总的来说,这是一项有益的活动,可以提高项目的模块化程度。
缺点
- 从 DLL 导出 C++ 类并不能防止对象与其用户之间的紧密耦合。就代码依赖性而言,应将 DLL 视为静态库。
- 客户端代码和 DLL 都必须动态链接到相同版本的 CRT(C 运行时库)。这是为了在模块之间正确地记录 CRT 资源所必需的。如果客户端和 DLL 链接到不同版本的 CRT,或者静态链接 CRT,那么在一个 CRT 实例中获取的资源将在另一个 CRT 实例中被释放。这将破坏试图操作外来资源的 CRT 实例的内部状态,并且很可能导致崩溃。
- 客户端代码和 DLL 都必须就异常处理/传播模型达成一致,并在 C++ 异常方面使用相同的编译器设置。
- 导出一个 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 "C"
。这是为了防止函数名被修饰所必需的。因此,该函数被公开为一个常规的 C 函数,可以被任何兼容 C 的编译器轻松识别。以下是使用抽象接口时客户端代码的样子:
#include "XyzLibrary.h"
...
IXyz* pXyz = ::GetXyz();
if(pXyz)
{
pXyz->Foo(42);
pXyz->Release();
pXyz = NULL;
}
C++ 没有像其他编程语言(例如 C# 或 Java)那样为接口提供一个特殊的概念。但这并不意味着 C++ 不能声明和实现接口。创建 C++ 接口的常用方法是声明一个没有任何数据成员的抽象类。然后,另一个独立的类继承自该接口并实现接口方法,但实现对接口客户端是隐藏的。接口客户端既不知道也不关心接口是如何实现的。它只知道哪些方法可用以及它们做什么。
工作原理
这种方法背后的思想非常简单。一个仅由纯虚方法组成的无成员 C++ 类,其实不过是一个虚函数表(vtable),即一个函数指针数组。这个函数指针数组在 DLL 内部被作者认为必要的内容填充。然后,这个指针数组在 DLL 外部被用来调用实际的实现。下面的图表演示了 IXyz
接口的用法。
点击图片在新窗口中查看全尺寸图表
上图显示了 DLL 和 EXE 模块都使用的 IXyz
接口。在 DLL 模块内部,XyzImpl
类继承自 IXyz
接口,并实现了其方法。EXE 模块中的方法调用通过虚函数表调用 DLL 模块中的实际实现。
为何此方法适用于其他编译器
简短的解释是:因为 COM 技术可以与其他编译器一起工作。现在是详细的解释。实际上,使用无成员的抽象类作为模块间的接口,正是 COM 用来公开 COM 接口的方式。我们在 C++ 语言中熟知的虚函数表的概念,与 COM 标准的规范非常契合。这并非巧合。C++ 语言作为至少十多年来的主流开发语言,被广泛用于 COM 编程。这得益于 C++ 语言对面向对象编程的天然支持。毫不奇怪,微软将 C++ 语言视为工业级 COM 开发的主要重型工具。作为 COM 技术的所有者,微软确保了 COM 二进制标准和他们在 Visual C++ 编译器中自己的 C++ 对象模型实现相匹配,且开销尽可能小。
难怪其他 C++ 编译器厂商也纷纷效仿,在他们的编译器中以与微软相同的方式实现了虚函数表布局。毕竟,大家都想支持 COM 技术,并与微软现有的解决方案兼容。一个不能有效支持 COM 的假设的 C++ 编译器,在 Windows 市场上注定会被遗忘。这就是为什么如今,通过抽象接口从 DLL 中公开 C++ 类,可以在 Windows 平台上与任何像样的 C++ 编译器可靠地工作。
使用智能指针
为了确保适当的资源释放,抽象接口提供了一个额外的方法来销毁实例。手动调用此方法可能既繁琐又容易出错。我们都知道在 C 语言世界里这个错误有多普遍,开发者必须记得用显式函数调用来释放资源。这就是为什么典型的 C++ 代码会借助智能指针大量使用 RAII 范式。XyzExecutable 项目使用了示例中提供的 AutoClosePtr
模板。AutoClosePtr
模板是一个最简单的智能指针实现,它调用类的任意方法来销毁实例,而不是使用 operator 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++ 语言保证在退出时会调用所有局部对象的析构函数。
使用标准 C++ 智能指针
最近版本的 MS Visual C++ 在标准 C++ 库中提供了智能指针。以下是使用 std::shared_ptr
类处理 Xyz
对象的示例:
#include "XyzLibrary.h"
#include <memory>
#include <functional>
...
typedef std::shared_ptr<IXyz> IXyzPtr;
IXyzPtr ptrXyz(::GetXyz(), std::mem_fn(&IXyz::Release));
if(ptrXyz)
{
ptrXyz->Foo(42);
}
// No need to call ptrXyz->Release(). std::shared_ptr class
// will call this method automatically in its destructor.
异常安全
就像 COM 接口不允许泄漏任何内部异常一样,抽象 C++ 接口也不能让任何内部异常突破 DLL 的边界。类方法应该使用返回码来指示错误。C++ 异常的处理实现对于每个编译器都是非常特定的,不能共享。因此,在这方面,抽象 C++ 接口的行为应该像一个纯 C 函数。
优点
- 导出的 C++ 类可以通过抽象接口被任何 C++ 编译器使用。
- DLL 和客户端的 C 运行时库是相互独立的。由于资源的获取和释放完全在 DLL 模块内部进行,客户端不受 DLL 选择的 CRT(C 运行时库)的影响。
- 实现了真正的模块分离。生成的 DLL 模块可以被重新设计和重建,而不会影响项目的其余部分。
- 如果需要,DLL 模块可以轻松转换为一个功能齐全的 COM 模块。
缺点
- 需要显式函数调用来创建新对象实例和删除它。不过,智能指针可以省去开发者后者(删除)的调用。
- 抽象接口方法不能返回或接受常规的 C++ 对象作为参数。它必须是内置类型(如
int
、double
、char*
等)或另一个抽象接口。这与 COM 接口的限制相同。
STL 模板类呢?
标准 C++ 库容器(如 vector
、list
或 map
)和其他模板在设计时并未考虑 DLL 模块。C++ 标准对 DLL 保持沉默,因为这是一种平台特定的技术,不一定存在于使用 C++ 语言的其他平台上。目前,MS Visual C++ 编译器可以导出和导入开发者用 __declspec(dllexport/dllimport)
说明符明确标记的 STL 类的实例化。编译器会发出一些恼人的警告,但它能工作。然而,必须记住,导出 STL 模板实例化与导出常规 C++ 类没有任何不同,伴随着所有相应的限制。因此,在这方面 STL 并没有什么特别之处。
总结
本文讨论了从 DLL 模块导出 C++ 对象的不同方法。对每种方法的优缺点进行了详细描述。概述了异常安全方面的考虑。得出以下结论:
- 将对象作为一组纯 C 函数导出,其优点是能与最广泛的开发环境和编程语言兼容。然而,DLL 用户需要使用过时的 C 技术或在 C 接口之上提供额外的包装器才能使用现代编程范式。
- 导出常规的 C++ 类与提供一个带有 C++ 代码的独立静态库没有区别。使用起来非常简单和熟悉;然而,DLL 与其客户端之间存在紧密的耦合。DLL 及其客户端都必须使用相同版本的同一 C++ 编译器。
- 到目前为止,声明一个无成员的抽象类并在 DLL 模块内部实现它,是导出 C++ 对象的最佳方法。这种方法在 DLL 和其客户端之间提供了一个清晰、定义明确的面向对象接口。这样的 DLL 可以在 Windows 平台上与任何现代 C++ 编译器一起使用。将接口与智能指针结合使用,几乎和使用导出的 C++ 类一样简单。
C++ 编程语言是一种强大、通用且灵活的开发工具。