C++类导出DLL

介绍

  DLL允许用外部用户可以使用的显式C函数列表将一部分功能封装在独立模块中。1980年,当Windows DLL被推广到世界时,对广大开发人员来说唯一可行的选择就是C语言。因此,Windows dll自然将其功能公开为C函数和数据。在内部,一个DLL可以用任何语言实现,但是为了在其他语言和环境中使用,一个DLL接口应该回到最小的公分母——C语言。
  使用C接口并不意味着开发人员应该放弃面向对象的方法。即使是C接口也可以用于真正的面向对象编程,但是它可能是一种冗长乏味的方式。C++与C语言相反,在C语言中,调用者和被调用者之间的二进制接口定义良好并被广泛接受,在c++世界中,没有可识别的应用程序二进制接口(ABI)。实际上,这意味着c++编译器生成的二进制代码与其他c++编译器不兼容。此外,同一个c++编译器的二进制代码可能与该编译器的其他版本不兼容。所有这些使得从DLL导出c++类变得非常困难。
   本文的目的是展示几种从DLL模块导出c++类的方法。Xyz对象的实现在DLL中,可以分发给广泛的客户端。用户可以通过以下方式访问Xyz功能:

  • 纯C接口
  • 常规C++类
  • 抽象C++接口

源代码包含两个工程:

  • Xyz Library - DLL库工程
  • Xyz Executable - 调用DLL的工程

C语言方法

  面向对象编程的经典C语言方法是使用不透明指针,即handle。用户调用在内部创建对象的函数,并返回该对象的句柄。然后,用户调用接受句柄作为参数的各种函数,并对对象执行各种操作。句柄用法的一个很好的例子是Win32窗口API,它使用HWND句柄来表示窗口。假想的Xyz对象通过C接口导出,像这样:

typedef struct tagXYZHANDLE{}*XYZHANDLE;
// void pointer point to c++ object
EXTERN_C XYZAPI XYZHANDLE GetXyz();
// initialize a object
EXTERN_C XYZAPI int XyzFoo(XYZHANDLE handle, int n);
// cpp class function API for user
EXTERN_C XYZAPI void XyzRelease(XYZHANDLE handle);
// release handle(obejct)

  所有关于的类定义全部隐藏在cpp中,头文件中只有handle定义以及对外接口的定义。至于这种接口设计怎么使用,可以参考下面的代码:

#include "Xyz.h"
#include 
int main()
{
	XYZHANDLE handle = GetXyz();	// create a object
	int k = XyzFoo(handle, 4);				// use class interface
	std::cout << k << std::endl;				
	XyzRelease(handle);						// release memory
	return 0;
}

这种方法需要显式的调用创建和释放对象的函数。

异常安全

  不允许c++异常跨越DLL边界。C语言对c++异常一无所知,也不能正确地处理它们。如果一个对象方法需要报告一个错误,那么应该使用一个返回值表示错误。

优点
  • 一个DLL可以被最广泛的编程受众使用。几乎每一种现代编程语言都支持与普通C函数的互操作性。
  • DLL的C运行时库和客户端是相互独立的。由于资源的获取和释放完全发生在DLL模块中,客户端不受DLL对CRT选择的影响。
缺点
  • 调用对象的正确实例的正确方法的责任取决于DLL的用户。简单来说就是创建的handle是没有类型的指针,指向的对象需要靠用户正确的调用创建handle的函数,编译器无法进行类型的检查。
  • 为了创建和销毁对象实例,需要显式的函数调用。这在删除实例时尤其恼人。客户端函数必须小心翼翼地在函数的所有退出点插入对XyzRelease的调用。如果开发人员忘记调用XyzRelease,那么资源就会泄漏,因为编译器不能帮助跟踪对象实例的生存期。
  • 如果对象方法返回或接受其他对象作为参数,那么DLL作者也必须为这些对象提供适当的C接口。另一种方法是使用最小的公分母,即C语言,只使用内置类型(如int、double、char*等)作为返回类型和方法参数。

C++ 朴素方法

  几乎存在于Windows平台上的所有现代c++编译器都支持从DLL导出c++类。导出一个c++类非常类似于导出C函数。如果需要导出整个类,开发人员需要做的就是在类名之前使用declspec(dllexport/dllimport)说明符;如果只需要导出特定的类方法,则在方法声明之前使用。以下是代码片段:

class XYZAPI CXyz
{
public:
	int foo(int n);
}	// whole class is export with all methods and members

class CXyz
{
public:
	XYZAPI int foo(int n);
}   // only foo method is export.
你看到不是你获得的

  根据c++标准,每个类都有四个特殊的成员函数:

  • 默认构造函数
  • 复制构造函数
  • 析构函数
  • 赋值操作(operator =)

  如果类的作者没有声明也没有提供这些成员的实现,那么c++编译器将声明它们,并生成隐式的默认实现。对于CXyz类,编译器认为默认构造函数、复制构造函数和析构函数都很简单,并对它们进行了优化。但是,赋值操作符经过了优化并从DLL中导出。
  还有一个非常重要的问题,如下所示:

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模板作为数据成员是非常困难和无聊的。

异常安全

  导出的c++类可能会毫无问题地抛出异常。因为一个DLL和它的客户端都使用相同版本的同一个c++编译器,所以c++异常会跨DLL边界抛出和捕获,就好像根本没有边界一样。请记住,使用导出c++代码的DLL与使用具有相同代码的静态库是相同的。

优点
  • 导出的c++类可以像其他任何c++类一样使用。
  • 客户端可以毫无问题地捕获DLL中抛出的异常。
  • 当在DLL模块中只做了很小的更改时,其他模块不需要重新构建。这对于涉及大量代码的大型项目非常有益。
  • 将一个大型项目中的逻辑模块分离为DLL模块可以看作是实现真正模块分离的第一步。总的来说,这是一种有益的活动,可以提高项目的模块化。
缺点
  • 从DLL导出c++类并不能防止对象与其用户之间的紧密耦合。就代码依赖而言,DLL应该被视为一个静态库。
  • 客户端代码和DLL必须动态链接到相同版本的CRT。这是必要的,以使正确统计模块之间的CRT资源。如果客户端和DLL链接到不同版本的CRT,或者静态链接到CRT,那么在一个CRT实例中获得的资源将在另一个CRT实例中释放。它将破坏尝试操作外部资源的CRT实例的内部状态,并且很可能导致崩溃。
  • 客户端代码和DLL必须就异常处理/传播模型达成一致,并且对c++异常使用相同的编译器设置。
  • 导出一个c++类需要导出与这个类相关的所有内容:它的所有基类、用于定义数据成员的所有类,等等。

C++ 成熟方法:使用抽象接口

  一个c++抽象接口(例如一个只包含纯虚函数而不包含数据成员的c++类)试图两全其美:一个独立于编译器的对象接口,以及一个方便的面向对象的方法调用方式。所需要做的就是提供带有接口声明的头文件,并实现返回新创建对象实例的工厂函数。只有工厂函数必须用__declspec(dllexport/dllimport)说明符声明。该接口不需要任何额外的说明符。

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 "Xyz.h"
#include 
int main()
{
	IXyz *xyz = GetXyz();
	int k=xyz->Foo(12);
	xyz->Release();
	std::cout << k << std::endl;
	return 0;
}

  c++不像其他编程语言(例如c#或Java)那样为接口提供特殊的概念。但这并不意味着c++不能声明和实现接口。创建c++接口的常见方法是声明一个没有任何数据成员的抽象类。然后,另一个单独的类继承接口并实现接口方法,这样可以实现对接口客户端隐藏实现。客户端既不知道也不关心接口是如何实现的。它只知道哪些方法是可用的,以及它们的作用。

使用标准C++智能指针

  MS Visual c++的最新版本提供了带有标准c++库的智能指针。下面是使用std::shared_ptr类的Xyz对象的例子:

#include "Xyz.h"
#include 
#include 
#include 
int main()
{
	typedef std::shared_ptr IXyzPtr;
	IXyzPtr ptr(GetXyz(), std::mem_fn(&IXyz::Release));
	if (ptr)
	{
		int k = ptr->Foo(12);
		std::cout << k << std::endl;
	}
	return 0;
}
异常安全

  就像COM接口不允许泄漏任何内部异常一样,抽象c++接口也不允许任何内部异常突破DLL的边界。类方法应该使用返回代码来指示错误。处理c++异常的实现对每个编译器都是特定的,不能共享。因此,在这方面,一个抽象的c++接口应该表现得像一个普通的C函数。

优点
  • 导出的c++类可以通过抽象接口在任何c++编译器中使用。
  • DLL的C运行时库和客户端是相互独立的。由于资源的获取和释放完全发生在DLL模块中,客户端不受DLL对CRT选择的影响。
  • 实现了真正的模块分离。可以重新设计和重新生成DLL模块,而不影响项目的其余部分。
  • 如果需要,DLL模块可以很容易地转换为成熟的COM模块。
缺点
  • 需要显式的函数调用来创建新的对象实例并删除它。但是,智能指针可以为开发人员节省后一种调用的时间。
  • 抽象接口方法不能返回或接受常规c++对象作为参数。它可以是内置的类型(比如int、double、char*等),也可以是另一个抽象接口。它与COM接口有相同的限制。

原文链接:HowTo: Export C++ classes from a DLL

本文示例代码:github

你可能感兴趣的:(编程语言,c++,设计模式,编程语言)