9.4 C++与动态链接
Linux下的绝大部分共享库都是用C语言编写的,这一方面是由于历史的原因,Linux下的程序主要都是使用C语言;另一方面是由于使用C++语言编写共享库比使用C语言要复杂得多。在本书的第2部分,我们已经讨论了C++的ABI以及C和C++之间如何互操作的问题(用extern "C")。除了上面这些问题之外,使用C++编写共享库还存在一个更大的问题是:共享库会更新。共享库可以单独更新是它的一大优势,但如果这是一个C++编写的共享库,那又是另外一回事了,它有可能是一场噩梦。这一切噩梦的根源还是由于C++的标准只规定了语言层面的规则,而对二进制级别却没有任何规定。
《COM本质论》里面举了一个很生动的例子。假设有个程序员实现了一个复杂度为O(1)的字符串查找算法,这个算法非常有用,于是该程序员打算把这个算法做成DLL并且卖给各大计算机软件厂商和软件开发者,每份DLL的价格是100元。程序员是这样定义他的排序算法头文件:
class __declspec(dllexport) StringFind {
char* p; // 字符串
public:
StringFind(char* p);
~StringFind();
int Find(char* p); // 查找字符串并返回找到的位置
int Length(); // 返回字符串长度
};
Find()成员函数的作用是查找字符串并返回查找结果。当然Find算法的具体实现非常复杂,运行时占用数十M内存,程序员把实现代码编译成StringFind.DLL,然后对该DLL的代码进行加密后与头文件一起出售,防止用户通过反向工程对该排序算法进行破解。很快,这个算法受到了各大厂商的好评,大家普遍认为这个100元的StringFind.DLL非常物美价廉。程序员也很受鼓舞,决定再接再厉,对算法进行改进: 第一个是Length()函数之前是调用strlen(this->p)实现的,时间复杂度为O(n),改进后的类里面增加了int length成员变量用于保存字符串长度,时间复杂度变成了O(1);第二个改进是应一些用户的要求,增加了一个叫做SubString的函数,用于取得字符串的子串;第三个是对Find()算法实现进行了改进,使得原先要占有数十M内存降低到只占用数M内存。改进后的头文件源代码如下:
class __declspec(dllexport) StringFind {
char* p;
int length;
public:
StringFind(char* p);
~StringFind();
int Find(char* p);
int Length();
char* SubString(int pos, int len);
};
按照程序员最初的设想,类只增加了一个私有成员变量和公有成员函数,并不会对现有的程序有任何影响,他用一些测试的代码进行了测试,发现没有任何编译错误和运行错误。于是他就把新版的StringFind.DLL以200元的价格卖出,而那些原先购买了旧版StringFind.DLL的用户只需要加100元的差价就可以购买新版的DLL。由于新版的DLL诸多性能改进和功能增加,各大厂商和用户立即购买了新版的DLL,并且他们得到程序员的保证:新版的DLL与旧版的DLL完全兼容。拿到该排序算法的DLL后,厂商们将它广泛地用于各种产品,并且随着他们的产品光盘、互联网下载各种手段发布给最终用户;已经发布出去的使用旧版StringFind.DLL的程序也都收到了一个补丁升级包,号称只要安装该补丁,原先的程序就会运行得更快更有效,于是大多数的用户不假思索地就点击了"升级"按钮。
很快厂商们接到用户铺天盖地的抱怨,说他们的程序经常莫名其妙地错误或者运行时间一长就会占用大量的内存最终导致程序崩溃,甚至影响其他程序的运行。于是这些厂商的技术工程师们连夜对他们的程序进行排查,最终发现这些问题全都来自于StringFind.DLL。主要发现了下面几个问题:
? 按照接口的设计,SubString返回指向字符串子串的指针,但StringFind.DLL并不负责该返回指针的内存释放工作,用户在用完该指针之后需要调用delete对它进行释放。这在有些时候是没有问题的,但是如果StringFind.DLL所使用的CRT版本与用户主程序或者其他DLL所使用的CRT版本不一样,程序就会发生内存释放错误。由于每个CRT都会有自己独立的堆,在一个CRT中申请内存而在另外一个CRT中释放内存将会导致释放出错。
- 各个厂商对DLL文件升级的做法往往就是简单地用新版的DLL覆盖旧版的DLL,这也是基于程序员保证新版完全兼容旧版DLL的基础上。但是当StringFind类在增加了一个length成员变量之后,新版的StringFind对象所占用的空间是8个字节,而原先只有一个成员变量时只占用4个字节。那么原先程序主模块在对StringFind进行实例化时,实际上是相当于实例化了旧版的StringFind。比如旧版中有new StringFind()这样的语句,实际上它的作用相当于申请4个字节的内存,然后调用StringFind()初始化函数。但是在新版的StringFind中,StringFind.DLL里面的StringFind构造函数和Length()都认为StringFind对象有8个字节,当任何一个函数访问length变量的时候实际上这块区域并不属于StringFind对象,很容易出现错误的数据访问,导致程序莫名其妙地崩溃。
- 很多程序在安装时就把StringFind.DLL放到系统的DLL目录下\WINDOWS\System32,而在升级或者重新安装时采用简单覆盖的方法。于是当一个安装程序将新版的StringFind.DLL覆盖旧版的DLL时,所有使用旧版DLL的程序都会发生程序运行错误。
在发生这一大堆问题之后,程序员受不了厂商的抱怨只好彻夜工作,并提出了一些改进的方法,比如增加一个ReleaseString()的成员类来释放SubString()所返回的字符串;将新版的StringFind.DLL命名为StringFind2.DLL以区别旧版等。一个简单的改进都成了程序员的噩梦,他都不敢再做任何深入的改进了,更别说在DLL中使用C++的其他特性诸如虚函数、多继承、异常、重载、模板等,谁知道又会发生什么样的情况。
这只是程序员在使用C++编写DLL时遇到的问题中的冰山一角,为了解决类似的兼容性问题,更大程度上使得程序能够有更好的重用性,微软公司很早就开始了组件对象模型(COM,Component object model)的开发工作,它的主要目的之一就是为了解决这些在程序开发中遇到的兼容性问题。
推荐阅读:《COM本质论》
《COM本质论》是一本很好的描述COM实现机制的一本书,作者Don Box通过生动的例子,深入浅出地将COM这个晦涩的技术剖析地非常浅显易懂。本文中的例子也是来源于这本书中的一个例子并加以改进。
COM的实现机制对于普通开发者来说显得复杂了一些,并且COM的学习曲线也比较陡,不太容易入门。但是我们可以把COM的一些精神提取出来,用于指导我们使用C++编写动态链接库。在Windows平台下(有些意见对Linux/ELF也有效),要尽量遵循以下几个指导意见:
- 所有的接口函数都应该是抽象的。所有的方法都应该是纯虚的。(或者inline的方法也可以)。
- 所有的全局函数都应该使用extern"C"来防止名字修饰的不兼容。并且导出函数的都应该是__stdcall调用规范的(COM的DLL都使用这样的规范)。这样即使用户本身的程序是默认以__cdecl方式编译的,对于DLL的调用也能够正确。
- 不要使用C++标准库STL。
- 不要使用异常。
- 不要使用虚析构函数。可以创建一个destroy()方法并且重载delete操作符并且调用destroy()。
- 不要在DLL里面申请内存,而且在DLL外释放(或者相反)。不同的DLL和可执行文件可能使用不同的堆,在一个堆里面申请内存而在另外一个堆里面释放会导致错误。比如,对于内存分配相关的函数不应该是inline的,以防止它在编译时被展开到不同的DLL和可执行文件。
- 不要在接口中使用重载方法(Overloaded Methods,一个方法多重参数)。因为不同的编译器对于vtable的安排可能不同。