COM本质 之一
C++的重用在最开始是源代码级别的重用,也就是说库的开发人员发布自己的源代码,使用库的人员把代码加到自己的程序中,作为自己源代码的一部分编译,连接生成程序。这样做有下面几个不足:
库的开发人员必须共享自己的源代码,不能够保护自己的代码私有性。
库的使用人员很多时候必须理解源代码,增加了使用库的难度。
如果多个程序都使用了库,那么这些库不能共享内存,而是在每个程序中都占用相同的空间。
当库发生变动的时候,库的使用程序必须在更新了库的源代码后重新编译连接。
动态链(Dynamic Link Library)接库技术在一定程度上解决了上面的问题,通过该技术把库的实现包装起来,通过编译期指示符让编译期把库中类的方法或者函数从DLL中导出去。使用这个技术的时候所有导出的方法都被加在DLL中的引出表(export list)中,该表指出了到处方法在该模块中的相对位置(当然不是绝对的内存位置,在连接的时候才能决定实际的内存地址空间位置),而且连接库会产生一个引入库(import library),该引入库并不包含任何程序实现,它只是用来对外暴露要导出的函数的符号,只是简单的引用,这些引用指向DLL文件名和导出符号的名字,当客户连接引入库的时候,引入库的一些存根会加入到可执行文件中,以便后面程序运行的时候动态的加在对应的DLL文件以及把函数符号解析到内存的正确位置。
库的使用涉及到了3样东西,库的导出头文件,引入库,和库的DLL二进制文件。库的头文件参与和使用库的程序一起编译,库的引入库则在连接阶段定位导出函数的DLL文件名字和函数符号,库的DLL文件在运行的时候才动态的连接到库的使用程序中。三者在不同的时期发挥不同的作用。
这解决了下面几个问题:
库以二进制文件的形势发布,附上导入库和头文件,保护代码私有性。
库的开发人员只用关注导出的函数和类。
库使用的是共享的内存,只会存在一个内存副本。
库发生变动的时候,可以不用修改使用库的程序,而只是更换库的DLL文件(某些时候可行)。
DLL在移植方面的问题:
由于C++缺少一个二进制级别的标准,使得在使用库的DLL文件的时候,如果用不同于库的开发时使用的编译器,就会出现链接问题。因为C++编译期在实现重载的时候用到了名字联编(name mangling),不同的编译器的规则不一样。这个不一致就使得他们从同样的函数签名翻译成不同的名字,也就出现使用库的程序根据头文件把名字A翻译成Aa,然后去导入库中找Aa,却找不到,因为实际库的编译期把A翻译成了Ab放在导入库中,当然找不到。模块定义文件(Module Definition File)可以解决这个问题,这项技术在客户的链接器上做文章,它允许引出符号被化名为不同的引入符号,给定编译器相关的名字改编方案的信息,库厂商可以为每个不同的编译器产生一个专门的定制的引入库。解决了”于DLL在链接层上的兼容性“。
解决了链接的问题,下面还有运行时的问题,这涉及到最简单的语言结构和语言特性的厂商实现不一致性。
C++提供了封装性,但这些private,public只是语言上的封装性标准,C++并没有一个二进制层次上的封装。C++的编译模型必须知道对象的内存布局情况才能正确的构造实例,或者调用类的非虚拟函数。这些内存布局信息包括:对象中数据成员的大小和顺序。这样就引起了C++的可移植问题。
如果编译库和编译使用库的程序两者编译器不同,就可能引起运行时内存访问错误和操作达不到目的的情况,这就使两个编译期对结构理解不一样引起。
同一个库,如果在升级的过程中内存结构改变了,那么使用库的程序也需要重新和新的库编译链接。
这种C++编译器需要客户知道对象的布局结构导致了客户程序和对象可执行代码之间的二进制耦合,这种耦合使得C++不能支持独立的二进制组件设计,但是也可以让编译器产生更加高效的代码。由于这些耦合性和上面提到的编译器和链接器的不兼容使得”简单的把C++类从DLL中引出来“不可行。
把接口重实现中分离出来
基本思想就是把类的实现不暴露出去,而只是把类的公用方法暴露出去,通过接口保留一个类的实现的指针来传递对类的操作。这里的接口类相当于一个二进制防火墙,它位于客户程序和类的实现之间。客户程序就不需要知道类的实现细节,不需要知道类的内存布局,因为类的对象构造是在DLL中做的,客户的编译器在这方面并不需要了解类的内存结构。但是这仍然没有完全解决编译器和链接器的兼容问题(除非这个二进制防火前和编译器和链接器无关!)
来考虑兼容性起源于编译器对两个方面的不同考虑:(1)如何在运行时表现语言的特征;(2)在链接时如何表达符号的名字;下面我们就要找到一种把编译器和链接器在这两方面实现细节隐藏在后面的技术来实现DLL的兼容性。
语言在下面几个方面有统一性:复合类型struct在运行时表现形式对于不同的编译器一致(C系统调用都必须保证的),至少可以用条件编译来保证;所有的编译器用同样的顺序来传递函数参数,堆栈清理也一样,这一点也可以用条件编译来保证。“某个平台上的所有C++编译器都实现了同样的虚拟函数调用机制”,这个假设只用在“没有成员,最多只有一个基类”的情况下有效就行。将接口的函数定义成纯虚函数就产生了抽象基类,而实现类必须继承自抽象基类,这样实现对象的内存结构将是接口对象的内存结构的一个超集,而该接口对象的内存结构又具有编译器无关性质,于是就提供了一个编译器无关的二进制防火墙。这里有个特别情况,就是类的析构函数如果是虚拟函数的话,它在虚函数表中的位置不同会导致接口类不具有编译器无关性质,当然可以考虑用一个虚拟函数自己把自己析构掉。
到这里为止,我们用到了DLL来把代码包装起来,又用接口和实现分离的策略建立一个二进制的防火墙,解决了一部分编译器相关的问题(类的结构实现不一致问题),和部分链接问题(更新后的dll如果对象内存结构变了会引起内存访问异常)。虽然这个二进制防火墙把对象的内存细节放到了墙后,但是作为防火墙本身的接口需要达到一定的要求才能具有编译器无关性。它要是(1)一个纯虚函数(不能有数据成员,不然数据成员的顺序大小得不到保证),(2)不能含有虚拟析构函数(虚拟析构函数在虚函数表中的位置不一致,所有需要一个析构机制)。下面就引出了扩展它的问题,如果我们想在已有的组件上添加新方法,那该如何做呢?直接修改接口会导致使用新接口的客户程序如果加载了旧的DLL就会出现崩溃。于是考虑为新功能再写一个接口,让实现类实现它,然后通过客户程序动态类型识别(RTTI runtime type indentification)来查询对象支持的接口,但是RTTI也是只有语法上的规定,各个厂商实现机制不一样。把RTTI放到接口后面,而(3)接口暴露一个查询接口能力的方法则可以解决这个问题。对于(2),我们再来考虑,如何让对象不通过析构函数析构呢?提供一个Delete的虚拟函数?这就需要客户自己记得去调用了,而且对于多接口的对象还需要记得不要每个接口都去调用Delete,无疑即暴露了实现细节(至少客户知道对象在栈上)也加重了客户的负担,于是通过引用计数来让对象自己管理自己是个不错的方法。
针对这里的(1)(2)(3),这个接口是纯虚的,需要引用技术,需要查询函数,它实际就是COM的一个公共基类的雏形。