作者:Alex Blekhman

 翻译:朱金灿​​


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++类
  • 使用一个抽象的C++接口

源码(译注:文章附带的源码)包含两个工程:


  • XyzLibrary – 一个DLL工程
  • XyzExecutable – 一个Win32 使用"XyzLibrary.dll"的控制台程序

XyzLibrary工程使用下列方便的宏导出它的代码:



  1. #if defined(XYZLIBRARY_EXPORT) // inside DLL
  2. #   define XYZAPI   __declspec(dllexport)
  3. #else // outside DLL
  4. #   define XYZAPI   __declspec(dllimport)
  5. #endif  // XYZLIBRARY_EXPORT



​XYZLIBRARY_EXPORT​​​​标识符仅仅在​​XyzLibrary工程定义,因此在​​XYZAPI​​​​宏在​​​​DLL​​​​生成时被扩展为​​__declspec​​(​​dllexport​​)​​​​而在客户程序生成时被扩展为​​__declspec​​(​​dllimport​​)​​​​。​​​

​C​​​​语言方式​


句柄


  经典的C语言方式进行面向对象编程的一种方式就是使用晦涩的指针,比如句柄。一个用户能够使用一个函数创建一个对象。实际上这个函数返回的是这个对象的一个句柄。接着用户能够调用这个对象相关的各种操作函数只要这个函数能够接受这个句柄作为它的一个参数。一个很好的例子就是在Win32窗口相关的API中句柄的习惯是使用一个HWND句柄来代表一个窗口。虚构的Xyz对象通过下面这样一种方式导出一个C接口:







  1. typedef tagXYZHANDLE {} * XYZHANDLE;

  2. // 创建一个Xyz对象实例的函数
  3. XYZAPI XYZHANDLE APIENTRY GetXyz(VOID);

  4. // 调用Xyz.Foo函数
  5. XYZAPI INT APIENTRY XyzFoo(XYZHANDLE handle, INT n);
  6. // 释放Xyz实例和占用的资源
  7. XYZAPI VOID APIENTRY XyzRelease(XYZHANDLE handle);

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



下面是一个客户端调用的C代码:






  1. #include "XyzLibrary.h"

  2. ...

  3. /* 创建Xyz实例*/
  4. XYZHANDLE hXyz = GetXyz();

  5. if(hXyz)
  6. {
  7.     /* 调用 Xyz.Foo函数*/
  8.     XyzFoo(hXyz, 42);

  9.     /*析构 Xyz实例并释放已取得的资源. */
  10.     XyzRelease(hXyz);


  11.     hXyz = NULL;
  12. }



使用这种方式,一个DLL必须提供显式的对象构建和删除函数。

调用协定

     对于所有的导出函数记住它们调用协定是重要的。对于很多初学者来说忘记添加调用协定是非常普遍的错误。只要客户端的调用协定和DLL的调用协定匹配,一切都能运行。但是,一旦客户端改变了它的调用协定,开发者将会产生一个难以察觉的直到运行时才发生的错误。XyzLibrary工程使用一个​APIENTRY​​​​宏,这个宏在​​"WinDef.h"这个头文件里被定义为__stdcall。


异常安全性


     在DLL范围内不允许发生C++异常。在一段时间内,C语言不识别C++的异常,并且不能正确处理它们。假如一个对象的方法需要报告一个错误,这时一个返回码需要用到。


优点


l         一个DLL能被最广泛的合适的开发者所使用。几乎每一种现代编程语言都支持纯C函数的互用性。

l         一个DLL的C运行时库和它的客户端是互相独立的。因为资源的获取和释放完全发生在DLL模块的内部,所以一个客户端不受一个DLL的C运行时库选择的影响。

缺点

l            获取正确对象的合适的方法的责任落在DLL的使用者的肩上。比如在下面的代码片断,编译器不能捕捉到其中发生的错误:



缺点

l            获取正确对象的合适的方法的责任落在DLL的使用者的肩上。比如在下面的代码片断,编译器不能捕捉到其中发生的错误:






  1. /* void* GetSomeOtherObject(void)是别的地方定义的一个函数 */
  2. XYZHANDLE h = GetSomeOtherObject();

  3. /* 啊! 错误: 在错误的对象实例上调用Xyz.Foo函数*/
  4. XyzFoo(h, 42);


            ​​l         ​​​显式要求创建和摧毁一个对象的实例。其中特别烦人的是对象实例的删除。客户端必须极仔细地在一个函数的退出点调用​​XyzRelease​​​​函数。假如开发者忘记调用​​​​XyzRelease​​​​函数,那时资源就会泄露,因为编译器不能跟踪一个对象实例的生命周期。那些支持析构函数或垃圾收集器的语言通过在​​​​C​​​​接口上作一层封装有助于降低这个问题发生的概率。​


​l         ​​​​假如一个对象的函数返回或接受其它对象作为参数,那时​​​​DLL​​​​作者也就不得不为这些对象提供一个正确的​​​​C​​​​接口。假如退回到最大限度的复用,也就是​​​​C​​​​语言,那么只有以字节创建的类型(如​​​int, double, char​​*​​​​等等)可以作为返回类型和函数参数​




     ​​C++​​​​天然的方式:导出一个类​


​在​​​​Windows​​​​平台上几乎每一个现代的编译器都支持从一个​​​​DLL​​​​中导出一个类。导出一个类和导出一个​​​​C​​​​函数非常相似。用那种方法一个开发者被要求做就是在类名之前使用​​​__declspec​​(dllexport/dllimport)​​​​关键字来指定​​​​假如整个类都需要被导出,或者在指定的函数声明前指定假如只是特定的类函数需要被导出。这儿有一个代码片断:​







  1. // 整个CXyz类被导出,包括它的函数和成员
  2. class XYZAPI CXyz
  3. {
  4. public:
  5.     int Foo(int n);
  6. };

  7. // 只有 CXyz::Foo函数被导出
  8. //
  9. class CXyz
  10. {
  11. public:
  12.     XYZAPI int Foo(int n);
  13. };


​在导出整个类或者它们的方法没有必要显式指定一个调用协定。​​​根据预设,C++编译器使用​​__thiscall​​作为类成员函数的调用协定。然而,由于不同的编译器具有不同的命名修饰法则,导出的C++类只能用于同一类型的同一版本的编译器。这儿有一个MS Visual C++编译器的命名修饰法则的应用实例:




怎样从一个DLL中导出一个C++类_c




   注意这里修饰名是怎样不同于C++原来的名字。下面是屏幕截图显示的是通过使用​​Dependency Walker​​工具对同一个DLL的修饰名进行破译得到的:



怎样从一个DLL中导出一个C++类_c_02 





     只有MS Visual C++编译器能使用这个DLL.DLL和客户端代码只有在同一版本的MS Visual C++编译器才能确保在调用者和被调用者修饰名匹配。这儿有一个客户端代码使用Xyz对象的例子:




  1. #include "XyzLibrary.h"

  2. ...
  3. // 客户端使用Xyz对象作为一个规则C++类.
  4. CXyz xyz;
  5. 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​​)​​​​来指定类导出来告诉编译器来尝试导出任何和类相关的东西。它包括所有类的数据成员,所有类的成员函数(或者显式声明,或者由编译器隐式生成),所有类的基类和所有它们的成员。考虑:​







  1. class Base
  2. {
  3.     ...
  4. };

  5. class Data
  6. {
  7.     ...
  8. };

  9. // MS Visual C++ compiler 会发出C4275 warning ,因为没有导出基类
  10. class __declspec(dllexport) Derived :
  11.     public Base
  12. {
  13.     ...

  14. private:
  15.     Data m_data;    // C4251 warning,因为没有导出数据成员.
  16. };




​    在上面的代码片断,编译器会警告你没有导出基类和类的数据成员。所以,为了成功导出一个类,一个开发者被要求导出所有相关基类和所有类的已定义的数据成员。这个滚雪球般的导出要求是一个重大缺点。这也是为什么,比如,导出派生自​​​​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)​​​​指定。接口不需要任何额外的指定。​



  1. // Xyz object的抽象接口
  2. // 不要求作额外的指定
  3. struct IXyz
  4. {
  5.     virtual int Foo(int n) = 0;
  6.     virtual void Release() = 0;
  7. };

  8. // 创建Xyz对象实例的工厂函数
  9. extern "C" XYZAPI IXyz* APIENTRY GetXyz();





​在上面的代码片断中,工厂函数​​​​GetXyz​​​​被声明为​​​​extern XYZAPI​​​​。这样做是为了防止函数名被修饰(译注:如上面提到的导出一个​​​​C++​​​​类,其成员函数名导出后会被修饰)。这样,这个函数在外部表现为一个规则的​​​​C​​​​函数,并且很容易被和​​​​C​​​​兼容的编译器所识别。这就是当使用一个抽象接口时客户端代码看起来和下面一样:​






  1. #include "XyzLibrary.h"

  2. ...
  3. IXyz* pXyz = ::GetXyz();

  4. if(pXyz)
  5. {
  6.     pXyz->Foo(42);

  7.     pXyz->Release();
  8.     pXyz = NULL;
  9. }





​C++​​​​不用为接口提供一个特定的标记以便其它编程语言使用(比如​​​​C#​​​​或​​​​Java​​​​)。但这并不意味​​​​C++​​​​不能声明和实现接口。设计一个​​​​C++​​​​的接口的一般方法是去声明一个没有任何数据成员的抽象类。这样,派生类可以继承这个接口并实现这个接口,但这个实现对客户端是不可见的。接口的客户端不用知道和关注接口是如何实现的。它只需知道函数是可用的和它们做什么。​


​内部机制​


​在这种方法背后的思想是非常简单的。一个由纯虚函数组成的成员很少的类只不过是一个虚函数表——一个函数指针数组。在​​​​DLL​​​​范围内这个函数指针数组被它的作者填充任何他认为必需的东西。这样这个指针数组在​​​​DLL​​​​外部使用就是调用接口的实际上的实现。下面是​​​​IXyz​​​​接口的用法说明图表。​


          怎样从一个DLL中导出一个C++类_语言_03






    上面的图表演示了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​​​​接口的一个智能指针的用法的代码片断:​






  1. #include "XyzLibrary.h"
  2. #include "AutoClosePtr.h"

  3. ...
  4. typedef AutoClosePtr<IXyz, void, &IXyz::Release> IXyzPtr;

  5. IXyzPtr ptrXyz(::GetXyz());

  6. if(ptrXyz)
  7. {
  8.     ptrXyz->Foo(42);
  9. }

  10. // 不需要调用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                  ​​​​职业:软件开发者​


怎样从一个DLL中导出一个C++类_c++_04