库与代码重用
1、静态库vs动态库
静态库的优势和劣势
动态库的优势与问题
2、静态C/C++运行库 vs 动态C/C++运行库 & manifest
3、关于动态库接口设计
库与代码重用
对于像C,C++这样的语言来说,库是扩展语言功能的重要手段,对于项目来说,代码库是节约开发时间,缩减成本,划分项目模块以利于多人合作,并通过重用已有代码而避免重复劳动的必要工具。开发一个稍具规模的项目,总免不了设计和划分模块,如何设计和划分,采取什么方案解决问题,总要面临诸多取舍。
最近跟动态库/静态库/动态静态CRT/MFC打了诸多交道。本文是对这段时间遇到的问题与思考的总结。
1. 静态库 vs. 动态库
静态库本质上就是编译好的目标代码(使用vc的话,编译.cpp文件或者.c文件之后会得到目标文件.obj,编译结束后,link程序会按照指定的要求将目标文件链接成最终的输出文件——常见的有.lib/.dll/.exe)的一份合集。
相比于动态库,静态库没有入口函数,不能独立执行,不能动态链接,一旦被链接到目标文件中,静态库的代码段/数据段都会被合并到目标文件当中去。这是静态库与动态库的本质区别。
由于上述差别,那么我们就很容易理解下面的情况:如果你在静态库中定义一个静态或全局对象,最终该对象会被链接到目标文件的数据段中(目标文件可能是某个dll也可能是exe)。
静态库的优势在于,简单易用,你无需考虑内存的申请与释放问题,因为静态库的代码段作为目标模块的一部分(EXE或DLL),在其中所调用的new/delete在链接阶段会被重定位到该目标模块的CRT函数地址。
对应的,如果你使用DLL,由于DLL中会自行初始化一份CRT堆,因此它的内存申请与释放是在有别于在EXE的CRT堆中进行的(两个不同的CRT堆),如果你从DLL中申请一块内存出来使用,最后也必须交还给DLL模块内部去释放。否则会产生Heap Collision,在Debug模式下,你会收到一个系统位于malloc中的断言,并了解到发生了什么。*
此处的叙述存在错误,更正如下:
一个DLL或许会,或许不会拥有一份自行初始化的CRT堆,这取决于DLL是通过动态链接MSVCRT还是静态链接LIBCMT。由于CRT是通过一个全局变量_crtheap维护着一个Heap(Windows环境下),并且会在CRT初始化的时候初始化CRT堆,因此,如果整个应用程序都是通过链接动态的msvcrt,使用同一份crt,则不会有问题。但是如果有任一二进制模块链接了静态crt,则会拥有一份自己的crt堆。严格来说,你应当小心的保证来自于某个CRT的堆内存最后也被交还给该CRT堆,否则会发生错误,在Debug模式下,当你调用free函数释放来自于另一个CRT堆的内存时,会有一个_ASSERTE(_CrtIsValidHeapPointer(pUserData));断言失败,提醒你用错了堆。
如果我们的DLL是链接了一个静态的CRT并且发布出去了,那么我们还是应当保证所有来自于该DLL的内存都由该DLL回收。其原因如上述分析所言,是由于该DLL拥有一份自己的CRT堆。
再有,如果你的静态库A依赖于静态库B的头文件,而目标应用程序EXE依赖了静态库A,那么你在链接静态库A的时候,也必须链接静态库B。否则会有静态库A中的某些符号无法被找到,从而导致链接失败。而动态库A如果依赖于一个静态库B,那么你无需在依赖该动态库的EXE中再次包含该静态库,因为该动态库A已经将该静态库B链接到A模块当中了,在DLL A中已经有了一份,如果EXE模块没有直接依赖于静态库B的话,那么就无需在EXE中再次link该静态库了。
上述问题也通常是混用静态库和动态库时可能遇到的一个问题:如果有一个静态库A,被动态库B和EXE C都用到了,而EXE C又需要使用动态库B,那么这份A的代码和全局数据就在动态库B和EXE C中存在了两份。第一,这是一种对空间的浪费;其次,这可能会引发问题,比如,当我在EXE C中new一份A中的某对象X的指针,并将该指针传入到动态库B中使用,表面上看似乎没有问题,但是如果这个对象X引用了A库中的全局/静态数据,就可能会引发问题,因为此时动态库B中存在有一份A库中的全局/静态数据,而EXE C中有另一份,如果这个全局/静态数据被X使用的话,那么就会得到前后不一致的上下文环境。具体可能产生什么问题要看具体的逻辑是如何组织的。在这种情况下,最好能将静态库A重新组织成一个动态库,这样就不存在多份代码以及多套全局数据的问题了;如果不能这么做,你可以根据具体的应用程序组织的形式,采取DLL导出A库接口,EXE使用DLL导出接口去访问A库而不直接链接A库本身的方法来变通,但通常这不是根本的解决方案。
另一方面,在DLL中可以包含自己的资源文件,而LIB中不能。因为DLL是一份完整的PE文件,而LIB只是若干个OBJ打包。这并不是说,使用LIB的时候不能使用资源(RC),你可以将LIB中使用的RC文件,resource.h都include到目标应用程序当中,只要没有资源ID冲突就可以正常使用,就像是你在目标应用程序的资源当中定义了这些资源一样。而DLL中的资源则隶属于DLL自身,不会与EXE中的资源在ID号上产生冲突,只是在使用的时候还需要针对Resource做一些Handle上的设置(参考AfxSetResourceHandle),才能使得API访问DLL中的资源而非EXE中的。如果要编写一个带有资源的库模块,DLL确实是不二选择。
2. 静态&动态C/C++ runtime 以及 manifest
静态的C-runtime库叫做LIBC.lib,我们通常使用多线程的版本叫LIBCMT.lib,debug下的版本叫LIBCMTD.lib。而动态的C-runtime,在VC的环境下,我们使用的是MSVCRT.lib,以及debug下的MSVCRTD.lib(这两个lib实际是两个导出符号文件,实际的运行库位于msvcrt.dll/msvcrtd.dll中(VC6),如果是VC2005的版本则是msvcr80.dll/msvcr80d.dll)
C++的runtime库,静态版的是LIBCPMT.lib/LIBCPMTD.lib。动态版的是MSVCPRT.lib以及MSVCPRTD.lib。(类似的,实际的运行库位于<VC2005中>msvcp80.dll/msvcp80d.dll)
在第一点的对比中已经提到过,如果同时又DLL模块依赖一个静态库,而又有一个依赖该DLL模块的EXE还依赖这个静态库,那么最终的内存中就会存在两份该静态库的代码以及全局数据。这一点对于C/C++运行库也是一样的。所需要注意的是,只要是基于C语言以及C++语言的程序,几乎所有的都会用到C/C++运行库,因此如果在规划一个项目的时候,发现有多个dll模块,那么最好是使用动态版的运行库,以避免最终的内存中出现多份不必要的运行库代码。
再有一点是关于XP之后的系统中,以及在VC2005之后的编译环境中,微软添加的manifest机制。该机制是为了解决同名但版本错误的DLL加载问题而提出的。比如,我们手上有两份VC编译器,分别是不同的版本,其中携带的MSVCR80.dll也是不同的版本,但是都叫MSVCR80.dll,如果我们在应用程序安装时,简单粗暴的将系统目录下替换成我们应用程序所依赖的MSVCR80.dll,那么可能会造成以往的应用程序无法正确运行的问题,因为他们可能依赖的是其他版本的MSVCR80.dll。
解决这个问题的途径是将一个dll的运行环境,版本,以及校验值等信息编码到一个目录名当中,而在安装应用程序时,将该版本的dll放置到对应名字的目录下,这样尽管dll名称一致,我们也可以找到所需要正确版本的dll。但是应用程序怎么知道需要依赖哪个版本的dll呢?如果应用程序不知道,即使系统中有正确版本的dll,操作系统也不知道应该去加载哪一个dll。
因此manifest文件就把该模块所依赖的模块都标明了,通过名称,校验码,版本等等信息以确保所指明的模块不会有歧义(用记事本打开一个manifest文件,你就可以看到上述信息),当该应用程序EXE或者DLL被操作系统加载时,系统就会根据该信息去查找对应模块,首先会在C:/Windows/WinSxS目录下找(打开这个目录,你就能看到大量不同版本的CRT/CPRT/MFC/ATL等等运行库),如果没有找到那么也会应用程序当前目录下找。
这就是为什么使用VC2005编译一个dll也好,一个exe也好,总会多生成一个manifest的文件的原因。所以如果使用了动态版本的CRT运行库,或者其他任何DLL,那么最好就不要关闭manifest生成的编译选项。
3. 关于动态库接口设计
如果动态库可能经常会更新版本,提供新的功能接口,那么如果使得动态库的升级无需改动依赖于他的可执行模块就是一个值得思考的问题。
具体要达到上述目标,需要保证的有以下几点:
a. 动态库中的类的内存布局不暴露给外界,也就是说动态库提供给用户的.h中,不应当有类的成员信息。这是因为,如果类的内部数据修改了,增加了一个变量或者减少了,那么这个类所占用的内存大小也就修改了,倘若有exe使用上一版本的类定义,new了一个类对象,那么一旦发生类定义修改的情况,这部分代码就必须要重新编译链接了。比较标准的做法是dll只向外界提供接口定义,而不提供实现定义,即提供的是一个类,但是类中只包含纯虚函数,而没有数据<这种做法有点类似于COM的思路>;或者是只提供一系列普通函数以及一个实现类的指针,这种做法有一个好处,如果添加新的函数接口,也无需重新更新外部程序,但是如果使用虚函数接口则不然。
b. 动态库中的类应当不给外界提供构造析构的能力,同样是基于上述原因,如果外界可以new/delete该类,那么当类的定义修改时就会出现不得不重新编译使用该库的应用程序的情况。倘若只通过dll中定义好的借口进行交互,则不存在这个问题。比如外部不能直接new一个对象,而是必须从一个dll导出函数中获取一个对象的指针,外部同样不能delete这个对象,而是必须将之送入另一个导出函数交给dll去删除。这样只要dll保持着两个接口不变,更新dll时是无需更新依赖该dll的应用程序的。(一个比较好的做法是将dll提供外界的接口类的构造析构函数设置为private)
c. Dll应当尽可能使用动态crt,如果用到了mfc应当尽可能使用动态mfc。这是为了避免dll与外部模块中存在多份重复代码的情况。同时如果有dll需要依赖的其他静态库,也应当尽可能使用该库的动态版本。
p.s. 如果想了解更多的关于静态库,动态库,C运行库方面的知识,可以参考《程序员的自我修养——链接装载与库》一书。
p.s.2 果然如pongba老大所言,将自己认为没有问题的知识写下来是一次很好的学习过程,写下本篇文章的过程中,我将原先认为明白的,但是实际并不见得就真的明白的问题,梳理清楚了很多。写的过程有助于把原先大脑中下意识存在的假设显现出来,在思维中再次加工,将原先不明确的概念搞清楚。