c++类的二进制复用组件
本文内容是对 Essntial COM 一书中第一章内容的总结,该章内容很好的阐述了c++类的二进制复用所需要面对的一些问题及解决方案。
我在使用c++开发模块中,使用了以下几种分发方案:
1)源码分发;
2)使用extern "C"导出函数,所有与类相关的细节均在实现细节中;
3)不考虑升级及编译环境差异,直接导出类,库与调用者同步更新;
当然也面临过一些似乎无法解决的问题,如对MFC的UI模块进行封装,不同编译器之间的协调等问题。当以上问题无法解决时,便会使用源码方式进行协同工作。
本章内容很好的解释了我之前所面对的问题,确实值得记录。
1 源码分发
当c++类库使用了c++编程语言中被广泛支持的子集,则可以通过源码分发来实现复用。
书中给出了一个简单示例代码,快速查找字符串子串,后续所有阐述均是基于该类的适当修改,代码如下:
////////////////////////////////////////////////// // FastString.h class FastString { char* m_psz; public: FastString(const char* psz); ~FastString(); int Length() const; int Find(const char* psz) const; }; ////////////////////////////////////////////////// // FastString.cpp #include "FastString.h" #include <string.h> FastString::FastString(const char* psz) :m_psz(new char[strlen(psz) + 1]){strcpy(m_psz, psz);} FastString::~FastString(){delete[]m_psz;} int FastString::Length() const{return strlen(m_psz);} int FastString::Find(const char* psz) const { // 这里阐述了c++类的复用问题,所以该函数的具体实现并不重要 return 0; }
源码分发方式存在两个缺陷:
1)源码被编译到客户程序中,因此存在多个副本,当库函数较大且被多处使用时,会占用太多空间。
2)当发现现有库存在bug时,需要客户程序重新编译链接新源码才能解决问题。
2 动态链接库分发
使用动态链接库分发可以解决源码分发所面对的问题,具体如下:
使用编译指示符 __declspec(dllexport) 将类 FastString 中所有方法引出,多个客户程序通过链接库 FastString.lib 链接动态库,库实现则生成在 FastString.dll 中。
库文件在系统物理内存中只有一个副本,这样就解决了空间冗余问题。同时当库存在bug时,只有接口文件没有改变,客户程序不需要重新链接库文件。
修改后头文件代码如下:
////////////////////////////////////////////////// // FastString.h class __declspec(dllexport) FastString { char* m_psz; public: FastString(const char* psz); ~FastString(); int Length() const; int Find(const char* psz) const; };
程序调用结构如下:
动态链接库分发同样存在一些问题:
1)当库与客户程序使用相同的编译链接环境,动态链接库可以很好的工作,但当使用不同的编译环境时,则可能无法正确连接到成员函数。
这就是动态库的可移植性问题,主要原因是c++为了支持重载会对成员函数进行重命名,但重命名在不同编译器间却不是一个统一的规则。
extern "C" 可以强制函数不被重命名,但该声明对类的成员函数无效。
2)当为了支持某种更新而不得不修改接口头文件时,即使保持函数接口不变,而是仅仅增加成员变量都需要客户程序重新链接库文件,这是c++类的一个封装问题(仅源码级别)。
如现在Length()函数总是计算字符串的长度,由于字符串为常量,一种改进是在构造函数中计算一次字符串长度,通过增加一个变量来保存该长度,之后的调用直接返回长度即可。
////////////////////////////////////////////////// // FastString.h class __declspec(dllexport) FastString { const int m_cch; char* m_psz; public: FastString(const char* psz); ~FastString(); int Length() const; int Find(const char* psz) const; }; /////////////////////////////////////////////// // FastString.cpp FastString::FastString(const char* psz) :m_cch(strlen(psz)), m_psz(new char[m_cch + 1]){strcpy(m_psz, psz);}
当客户程序不重新编译链接而只是更新了库文件,客户程序在构造类时使用的内存布局为4字节,而新库的内存布局为8字节,这样新库在操作内存时必然出现内存越界问题。
3 将接口从实现中分离
针对动态链接库存在的对类的封装问题,可以采用迂回方式解决,即导出一个接口类,该接口类只包含一些公用的方法函数接口,具体实现则被放在了实现类中。
这样当存在升级库需求时,只有接口没有发生改变,则不需要客户程序重新编译链接。
然而,以上改进仅解决了封装问题,对于不同编译器间的可移植性问题,可以采用一个模块定义文件(Module Definition File),库文件对每个不同编译器生成一个对应的命名文件,
客户程序根据自身的编译环境选择对应的DEF文件即可解决可移植性问题。
将原有实现类保持不变,通过添加一个接口类可以实现分离,代码如下:
/////////////////////////////////////////////// // FastStringItf.h class FastString; // 实现类的声明 class __declspec(dllexport) FastStringItf { FastString* m_pThis; public: FastStringItf(const char* psz); ~FastStringItf(); int Length() const; int Find(const char* psz) const; }; /////////////////////////////////////////////// // FastStringItf.cpp #include "FastStringItf.h" #include "FastString.h" FastStringItf::FastStringItf(const char* psz): m_pThis(new FastString(psz)) {} FastStringItf::~FastStringItf(){delete m_pThis;} int FastStringItf::Length() const{return m_pThis->Length();} int FastStringItf::Find(const char* psz) const{return m_pThis->Find(psz);}
以上代码实现了接口分离,所有的细节均封装到实现类FastString中,当FastString成员变量增加时,始终会得到正确的构造。
接口类的引进间接的在客户程序与实现类之间建立了一道二进制防火墙,当设计好的协议没有改变时,库的升级不要求客户程序重新编译链接。
但是,这里并没有解决编译链接兼容问题,因为无法确保接口类的成员函数在不同的编译器之间使用一致的二进制构造!
4 使用抽象基类作为二进制接口
c++中接口可以使用抽象类(虚函数)来描述,如果将接口函数全部定义为纯虚函数,则实现了一个抽象接口。
虚函数在同一操作系统上不同编译器之间实现方式是一致的,这样就保证二进制接口也是一致的。
但由于仅导出了虚函数无法实例化对象,所以需要导出一个全局函数,使用该函数创建一个FastrString对象。
由于析构函数的虚函数的二进制构造不确保在不同编译器之间的一致性,所以需要在接口类中增加一个Delete函数来手动删除对象,代码如下:
/////////////////////////////////////////////// // IFastString.h class __declspec(dllexport) IFastString { public: // 使用纯虚函数作为接口 virtual void Delete() = 0; // 删除FastString对象 virtual int Length() const = 0; virtual int Find(const char* psz) const = 0; }; // 导出一个全局函数,使用该函数创建对象 extern "C" __declspec(dllexport) IFastString * CreateFastString(const char* psz); ////////////////////////////////////////////////// // FastString.h #include "IFastString.h" class FastString : public IFastString { const int m_cch; char* m_psz; public: FastString(const char* psz); ~FastString(); void Delete(); int Length() const; int Find(const char* psz) const; }; /////////////////////////////////////////////// // FastString.cpp #include "FastString.h" #include <string.h> FastString::FastString(const char* psz) :m_cch(strlen(psz)), m_psz(new char[m_cch + 1]){strcpy(m_psz, psz);} FastString::~FastString(){delete[]m_psz;} void FastString::Delete() { delete this; } int FastString::Length() const{return strlen(m_psz);} int FastString::Find(const char* psz) const { // 这里阐述了c++类的复用问题,所以该函数的具体实现并不重要 return 0; } // FastString对象在实现中创建,当实现细节改变后构造对象也同步改变 IFastString* CreateFastString(const char* psz) { return new FastString(psz); } /////////////////////////////////////////////// // Main.cpp #include "IFastString.h" // 仅引用接口头文件 #include "stdio.h" #include "stdlib.h" void main() { // 使用全局函数创建对象 IFastString* pfs = CreateFastString("this is a test"); if (pfs) { printf("Length = %d\n", pfs->Length()); pfs->Delete(); // 必须删除对象以避免内存泄露 } }
到目前为止,该组件实现了以下复用:
1)具有较好的封装性,任何不修改接口的细节变化均无需客户程序重新链接组件;
2)可以供不同c++编译器编译链接,在不同编译器间具有良好的可一致性;
当然,还有更多的可移植性问题,如不同语言间重用等问题,这些需要参考组件对象模型(COM)相关资料了。
参考资料:COM本质论 Don Box