COM 沉思录--转载

沉思者:COM技术的推理与思考- -

                                      

 

COM技术的推理与思考 http://meditator.blogchina.com/650109.html

前言

由于对自由软件的热衷,对自由世界的向往,一直以来,从思想深处,我对软件自由之头号敌人 Microsoft非常排斥。最极端的表现是,只要有可能,就不使用Microsoft操作系统,只要有可能,就不专门开发任何基于Microsoft操作系统的软件。所以对COM——一项在Windows世界非常流行的技术——一直以来持鸵鸟态度,视而不见。因为开放的Unix/Linux世界有很多很棒的东西等着我去了解。这就像你所爱的人已经值得你全心全意花一生的时间去爱,何必把时间花在你不屑,甚至讨厌的人身上?

但是,已经被证明正确的一点是,你不喜欢,甚至讨厌一个人,并不意味着它一无是处。就算它是你的敌人,兵法尚云"知己知彼,百战不怠"。你如果想成为一个武林绝代高手,必须学会从正、邪、非正非邪各种门派汲取营养。金大侠的小说已经用不止一个例子告诉我们这一点。

更何况,COM确实是一项不错的技术,尽管它的理论基础是如此简单与平凡。基本上它是Microsoft成立20多年来,唯一一项真正的创举(如果必须有两个,那另外一个就是.NET)。

到今天为止,COM技术的广泛使用仍然止步于Windows世界。但这并非意味着它的能力仅限于此。且不说国内的所谓"清华五剑客"推出的"和欣"嵌入式操作系统的技术核心就与COM有关;UTAH大学的著名的OSkit也大量使用了COM技术。如果你知道操作系统对于计算机技术的重要性,那么你也就能领悟COM技术的价值和意义。

于是,半年前,我花了一天的时间,逛了五个书店,为了买那本被称作"独一无二"的,"没有人能把COM阐释的比Don Box更清楚"的Don Box的《Essential COM》(中文名为《COM本质论》中国电力出版社)。然后,又花了一个晚上的时间(没有睡觉),读了它的前两章。在感叹Don Box的超级洞察力和精彩的推理过程之余,却发现即使是大师,也有犯错误的时候。因为,在你真正了解了COM之后,你会发现他推论的出发点就是错误的,即使结果是正确的,但他使用的错误的证明方法。我们知道,任何一套理论,都是有理论基础的,比如数学中的公理,所有数学定理如果向前推溯,最终总能推到公理。如果公理本身就是错误的,那么推理过程即是再正确,结果再符合事实,也是一套错误的理论。

于是,就想写点什么,一是为了系统的总结一下自己对COM的理解,二是把自己的心得与读这篇文章的人分享——也是为了接受检验。圣人尚且可能犯错误,那么我一个无名小辈,即使在这里对大师之作进行了错误的评论,更是无妨。如果能得到其他高人指点,让自己知道错在哪里,岂不更有醍醐灌顶之感?


1、NIH症

"追随硬件设计的方向!每次都从头开始新开发是不正确的。就像存在着VLSI(超大规模集成电路)设备目录一样,应该有一个软件元件目录。我们应该从这些目录中找到我们所需要的,并把它们组合起来,而不是每次都重新制作。我们应该写更少的软件,然后把精力放在我们需要写的地方,并把它们做的更好。然后,那些我们经常抱怨的问题——高成本,严重超期,缺乏可靠性等等,不就都解决了吗?为什么不这样?"
—摘自《Object-Oriented Software Construction》

软件技术的发展严重滞后于硬件发展的速度,不是因为软件人员的规模,教育水平和聪明程度不如硬件从业人员,而是恰恰相反,软件人员太聪明了,也往往非常自负,他们几乎不相信别人所写的代码,每件事都想亲自去做。这就是著名的NIH(Not Invented Here)症
就在上个周末,我在MSN上碰到一个在操作系统论坛上结识的哥们。以下是我们那次的对话片断(H代表他,M代表我):

H:Linux内核的SLAB算法可不可以被用到Application中。
M:当然可以。但你想用它做什么?
H:我想自己写一个用户级的Memory Management,但SLAB算法只允许在一个SLAB中分配固定大小的块,我想让我的MM能够支持任意大小的块分配。
M:MM算法有很多种,在选用一个算法之前你必须明确你的需求,并了解每个需求的重要程度,比如,性能,空间,以及要解决的问题,等等。
H:我想实现一个常规的内存管理,比如最小或最先适配块算法,但不知道内存碎片的问题如何解决。
M:如果你想没有内存碎片,那么Apache的mem_pool或许比较适合你。
H:但它是面向会话的,在一次会话里,只允许分配,不允许释放。等会话结束,统一释放。而我要实现的接口和系统提供的malloc和free一样。
M:如果是这样,那你或许可以参考Buddy算法。对了,你到底为什么要自己实现一个MM?
H:我现在需要维护一个系统,非常非常大的系统,有20万行代码,现在程序有问题,我估计是内存泄漏的问题。
M:首先,20万行代码的系统不算一个非常非常大的系统。其次,如果这个程序有内存泄漏,那么这是这个程序自身的行为导致的,与MM无关,你自己写一个MM也解决不了它对内存使用方式的错误啊。你不会是怀疑OS自己的MM有问题吧,你也太侮辱OS厂商了。
H:OS当然没有问题。我想在自己的MM里加上这个系统对内存分配与释放行为的跟踪,以及内存使用情况的统计信息。
M:如果你只是想对内存申请与释放行为进行跟踪,对内存使用情况进行统计的话,那么你完全没有必要去实现一个MM,而只需要在操作系统所提供的malloc和free之上包装一层,在这一层里实现跟踪与统计,这样你的工作量就会小很多。
(过了一会......)
H:对!但我还是想实现一个MM,为以后的项目做准备,大型系统都有自己的MM。
M:首先,并非所有的大型系统都有自己的MM,是否有自己的MM,完全根据需要而定。其次,既然大型系统都有自己的MM,那你需要的时候,拿一个适合自己的来用就行了,为什么还要再写一个?
H:那是别人写的,和自己写的不一样。
M:......,我困了,要去睡觉了。

这位哥们就是个典型的自负的程序员。实话实说,他智商相当不错,刚刚本科毕业一年,在公司已经小有名气。我之所以把他加入我的MSN,就是因为通过在论坛里的数次交流,我发现他非常具有优秀程序员的气质。但也正是这种气质,是软件重用的最大障碍。
这样说一定会引起困惑和争论。因为软件圈子里盛行的另外一句话是"优秀程序员的首要品质是懒惰"。而从通常意义上,懒惰的人才更希望重用。

但现实并非如此,懒惰让一个程序员总是希望远离枯燥乏味的重复性劳动,一旦碰到这种问题,他总是会想一个方案去解决这种问题,比如开发一些自动化的工具。于是,懒惰是刺激一个程序员发明创造的根源。但懒惰并不意味着一个优秀程序员会乐意使用别人提供的方案。是的,懒惰让程序员讨厌重复,喜欢重用,但重用的东西必须是自己创造的,他们对所有"Not Invented Here"的东西总是持一种排斥态度。这是因为优秀的程序员喜欢一切都在自己的控制之下,如果是别人提供的,自己心里总是很不踏实。这也正是为什么Microsoft Excel开发小组竟然在Excel里开发了自己的C语言编译器。事实上,优秀的程序员都非常勤奋,否则也不会有这么多优秀的软件出现,只不过它们不愿意把自己的大脑放到不需要智力的事情上罢了。


2、春秋战国
 
阻碍软件重用还有更复杂的因素。
 
首先,存在如此之多的编程语言,而这些编程语言之间的交叉编译存在着或大或小的麻烦。更甚的是,即使是同一种语言,不同编译器厂商的实现也存在着千差万别。尽管有不少人都尝试对每一种语言进行标准制订。但标准往往是各种实现的一个功能子集。厂商们在遵守标准的同时,为了增强竞争力,都会提供互不兼容的新特性,将使用不同编译器特性的同一语言的源代码放到一起使用某种编译器编译,往往会产生很多问题。再进一步说,即使没有这些东西,由于标准往往不规定,也无法规定编译器的具体实现,所以编译出来的二进制结果也是互不兼容的,除非提供源代码,否则链接就会有问题,不幸的是,出于商业问题的考虑,大家互相之间不可能完全做到提供源代码一级的共享。
 
前段时间,我从事的一个开发项目要使用STL,但我发现GNU的C++标准库的一些库路径的规划和我的参考书中不同,于是我打算使用STLPort,毕竟从它的名字来看,它是可移植的。所以到其网站上下载了一份最新的STLPort源码,然后在我的RedHat Linux下进行configure和make,最后发现竟然无法通过编译,原因是其开发者忘记在源代码包中放入对最新版本GCC进行支持的文件。于是,我又去下载了一份较老版本的STLPort源码,发现它根本无法使用最新的gcc编译器进行编译!!于是只好放弃使用它的计划,还是回到GNU C++标准库上来,毕竟它是GCC的一部分,肯定不会有类似的问题。
 
我很想重用,但想想看,已经在可移植方面做了大量工作的,开放源代码的STLPort都无法让我在一个并不特殊的环境下顺利的使用。如果这个世界STL库只有STLPort,那么这些麻烦很有可能让我最终放弃使用STL。STL作为一套被定义良好的算法容器库,按道理说,是可重性最好的设计。但真正使用起来却由于编译器,环境等等问题而提高了重用成本,更何况对于其它类型的库。
 
Java之所以被称为划时代的非凡技术,正是因为它致力于摆脱编译器和运行环境的困扰,所有的Java源代码按照统一的二进制字节码方案被编译。这些编译结果可以放在任何一个标准JVM上运行。另外,Java提供了统一的库,只有一颗继承树。所以Java在Application级别上做到了非常好的可重用性。这也正是为什么自从Java技术一出现,就迅速在全球软件领域内迅速流行。
 
但Java的模型和思想决定了它的局限性。且不说为人诟病的性能问题(这一点存在争论,有人曾经发表文章就这一点进行辩护),最主要的是,对于系统一级的开发,除非硬件自身就支持JVM,也就是说芯片本身就是Java芯片。否则它所使用的范围只能集中于Application。

3、动态链接
 
Microsoft,这只软件业最大的超级恐龙,首先是一个操作系统提供商。它希望它的操作系统所提供的服务——比如库,不仅仅由固定的某种或某几种语言来调用。这是一个重用问题。
 
但对于传统的编译链接模型,如果不加任何约束,不可能轻易做到这一点。
 
在动态链接出现之前,软件一直使用静态链接模型,但静态链接存在很多问题。
 
首先,使用静态链接模型无疑会造成资源的无谓浪费。有多少个系统调用某个库,那么这个库就会有多少份拷贝,这首先占用了静态资源(磁盘),当这些系统运行时,每个系统同样要为这些库在内存中分配空间,这就浪费了宝贵的动态资源(内存)。而这种浪费根本就是不必要的。
 
其次,库生产厂商提供库,其它软件厂商在自己的产品中调用这些库,这是一种自然的重用行为。但静态链接模型无疑提高了重用的成本。
 
任何一段代码都不可能是完美的,恒久不变的。随着需求的变化,本来完全运行良好的代码会变得不合需要。更不用说代码本身就存在BUG。所以,升级在软件领域(在其它领域也一样)是一个再普遍不过的行为。
 
如果仅仅是库升级了,而调用这个库的程序本身没有任何改变,在静态链接模型下,必须对整个系统进行重新链接,并把链接之后的产品重新发布,这是一件麻烦事。
 
这还不算什么,如果在一个指定的平台上,有多个系统调用同一套库;当这个库做出升级后,所有调用它的程序如果想使用新特性,或者想稳定工作(如果老版本的库有Bug),都必须重新编译。而这些系统很可能由不同的厂商提供,嗯,真是麻烦透顶。
 
于是,动态链接模型出现了。动态链接模型将库和它的调用者分开,在磁盘上只保存库的一份拷贝,在运行时也是如此,首先节约了资源。另外,如果库本身只是修改了某些代码逻辑,而没有修改任何对外是可见的数据结构,调用者则无须做任何事情,平台拥有者将旧版本的库替换掉就是了。
 
由此可见,动态链接技术降低了重用成本,使重用变得更容易。

4、接口与实现分离
 
动态模型让重用变得更容易,但还远远不够。因为动态模型仅仅从物理上将可重用库和库的调用者分开。而两者逻辑上可能存在千丝万缕的联系。一旦库发生了调用者可见的变化,调用者必须做出相应的改变。先看一个例子。假如库A由一个调用者可见的类。
 
class T{
public:
     void do_something(int v) { a = v; }
private:
   int a;
};
调用者在自己的程序中使用了这个结构体,比如直接声明这个类的一个实例。然后开始调用这个类的公开方法。如下: 
void func(int v){
  T t;
  t.do_something(v);  

然后,编译调用者,并指定其和库进行动态链接。
 
显然,调用者的执行进入func的时候,由于其了解class T的细节,所以它知道需要在stack中为class T的实例t,分配4个字节的空间(如果int的宽度为4 bytes的话),然后调用类函数T::do_something,这个函数的相关代码在库里,它会对a进行操作,如下所示:
 
          Library                     Client(Caller)
     |----------------|  Calling  |------------------|   
     |   Function    |<----------|                     |
     |do_something|Operation|  |------------|  |
     |                   |-----------|->|A T Instance|  |
     |----------------|             |    |------------|  |
                                       |------------------|


这不会有问题,因为无论是库函数还是调用者,大家对T的认知是一致的。
 
但随后,由于某种原因,在库的升级版本中,将T的定义改变了,如下:
 
class T
{
public:
     void do_something(int v)   { b=a; a = v; }
private:
   int a;
   int b;
};
 
此时,将库重新编译为动态链接库,然后将旧的版本替换掉,然后启动没有被重新编译的调用者,紧接着,系统在短暂的运行之后崩溃了。为什么会这样?
 
这是因为调用者对T的认知仍然停留着4个字节的阶段,而在升级之后的库看来,T应该是8个字节,类成员函数T::do_something会按照新的认知来操作T的实例t,于是发生越界操作。系统当然会崩溃掉。
 
那么,如果在调用者不直接声明T的实例,而是动态申请呢?比如调用者函数func修改为:
void func(int v)
{
  T* t = new T;
  t->do_something(v);  
}
 
结果是一样的,因为new事实上调用的是调用者的operator new(size_t size);请注意这个函数的参数size。T *t = new T其实最终被转化为T *t = operator new(sizeof(T))。而这个sizeof(T)在第一次被编译是为4,后来既然没有重新编译调用者,那么它依然为4。
 
由这个例子可以很清楚的得知,如果调用者了解库所提供的某个数据结构的实现细节,那么它就对这个库产生了编译依赖,当库里任何它的调用者所依赖的实现细节发生改变的时候,调用者也必须进行重新编译。
 
怎样才能避免这一点?很简单,那就是将"接口与实现分离"——库应该对调用者坚决隐藏所有实现细节,仅仅公开外部调用接口。如果想引用一个库提供的对象的时候,也应该由库来创建,然后调用者通过一个指向对象的指针来引用它。而对这个对象的操作,也完全有库所提供的公开接口来进行。既然对象的创建和对对象的操作都是由库完成的,那么无论库进行怎样的改变,它对实现细节的认知肯定是一致的。

5、二进制接口
 
将"接口与实现分离",进一步降低了重用的难度。但现实生活中仍然有很多的问题在阻碍重用。其中最重要是各种开发语言的互操作问题。
 
我们梦中存在的一种景象是,用任何一种语言开发出来的库,可以被任意其它语言轻松调用
 
这几乎无法做到,一个问题就可以把我们这个梦击的粉碎——编译型语言如何调用解释性语言所写的库?有人会说,这可以做到:编译型程序启动那个解释性语言的解释器,由它来解释由这种解释性语言写的库就行了。
 
除非我疯了,或者有人拿刀架在我的脖子上,否则我绝对不会采取这个方案。一个库的提供者,如果它提供这套库的目标是想做到让所有的语言都可以轻松调用它,却选取解释性语言来写,那它一定是大脑出了问题。
 
难道就这样放弃我们的梦想?别着急,冷静,冷静,再冷静。管理学中有一个重要的理论:最优目标是很难达到的,现实中我们往往追寻的是次优目标。我们不妨换个思路,如果我们的库是用编译型语言写的,那么是不是所有的语言都可以相对容易的调用?
 
一个重要的事实是,所有现代主流的的解释型语言都提供了对编译型语言,如C或C++库的调用接口,并且事实上它们在调用这些库的时候,不需要借助任何东西,调入内存,执行它就是了,这样性能反而更高。
 
啦......啦......,我们生活在一个幸福的年代,我们是一群幸运儿~~
 
Come down! 即使是编译型语言,由于编译器的实现千差万别,想实现跨语言调用,甚至同一种语言内部的互相调用,并不如想像的那么轻松。假如库是由C++写的,由于Name Mangling,C语言如何通过符号名调用它?即使由C++调用,由于不同编译器的Name Mangling的方案不同,也无法通过符号名调用。
 
事情似乎又回到了列宁的问题上:怎么办?
 
解决问题的思路当然是对症下药。既然存在Name Mangling,那我们不使用符号名来对应调用关系就行了,而是指定一个大家约定好的二进制格式,双方都可以根据这种格式找到相应的调用接口。
 
不错的思路,但还需要加一个约束,那就是这种二进制格式必须让各种编译型语言可以轻易的生成。而所有语言都可以轻松的识别。
 
让我们的头脑风暴继续狂野的进行下去——
 
在面向过程的语言中,所谓接口,其实一个函数集合。而在面向对象的语言中,一个函数集合可以被定义为一个仅仅包含函数成员的的类。比如:
struct interface{
   void function_1(int);
   int function_2(void);
   float function_3(int, float);
};
 
由于我们不能以符号——在这里就是函数名来进行双方的约定,那就使用函数指针。并且由于接口背后的实现可以被替换,也就是说这些函数指针在不同的实现中可以指向不同的函数,但函数原型却是一致的。所以,在C中,我们按如下方式定义一套接口:
struct interface
{
   void (*function_1)(int);
   int (*function_2)(void);
   float (*function_3)(int, float);
};
 
而在C++中,我们把函数定义为virtual的,就能让函数变为函数指针。
 
struct interface
{
   virtual void function_1(int) = 0;
   virtual int function_2(void) = 0;
   virtual float function_3(int, float) = 0;
};
 
将两者编译,C语言写成的结构将生成一个和定义吻合的函数指针表,但C++除了生成一张函数指针表外,还生成一个指向这张表格的指针。这张表在C++中被称作vtbl,这个指针称作vptr。
 
到底应该使用哪种方式?考虑一下我们需要的是什么。
 
按照"将接口与实现分离"的原则,调用者知道的仅仅是接口,但调用者通过这些接口仍然要操作数据,这些数据可能是全局变量,但更常见的应该是具体的对象,而这些对象是由库提供的接口函数来创建的。
 
好吧,我们进一步的说,按照《Object-Oriented Software Construction》所描述的样子,软件重用应该像硬件重用那样,提供的是一个一个元件,这些元件实现了一个或多个接口,然后把这些元件通过接口组合起来,形成系统。而在软件中,一个元件就是一个对象或者结构,这个对象有自己的属性,同时提供对外的接口。
 
回到原来的推理上,我们通过调用库,得到一个对象,然后调用这些接口从这个对象获取我们需要的功能。
 
既然一个元件即有自己的属性,还有自己的接口,那么它的布局应该是怎样的?
 
稍加思考我们就知道,C++的方式更符合我们的需要。因为按照C的方式,每一个对象实例都会包含所有的函数指针,这无疑是没有必要的。而C++让所有的对象实例共享同一个vtbl,每个对象实例仅仅包含一个vptr就够了。
 
我们的组件实现一个接口时,在C++中就是在实现类中继承这个接口类,这个实现类中可以有自己的私有属性,以及内部接口(私有的或受保护的),比如:
 
class component: public interface
{
public:
   virtual void function_1(int);
   virtual int function_2(void);
   virtual float function_3(int, float);
protected:
    void self_func(void);
private:
    int na;
    char* nb;
    long nc;
};
 
编译出来的布局为:
 
    |-------------------|             vtbl
    |     vptr          |------>|----------------|
    |-------------------|       |  p_function_1  |-->
    |     int na;       |       |----------------|
    |-------------------|       |  p_function_2  |-->
    |     char* nb      |       |----------------|
    |-------------------|       |  p_function_3  |-->
    |     long nc       |       |----------------|
    |-------------------|

 这样,当调用者得到一个对象时,它就能很容易的找到这个对象的vtbl,然后根据索引,而不是链接的符号名来调用vtbl中的接口函数。
 
我们把这个对象中的接口部分拿出来,就是如下的结构。

    |------------------|                   vtbl
    |     vptr         |------>|-----------------|
    |------------------|       |   p_function_1  |-->
                               |-----------------|
                               |   p_function_2  |-->
                               |-----------------|
                               |   p_function_3  |-->
                               |-----------------|


 
对于这样一个接构,用C可以很轻松的实现。我们之前用C声明的结构体就是vtbl,我们只需要在对象结构的最前面加上一个vptr就行了,如下:
struct object
{
   struct interface* vptr;
   int na;
   char* nb;
   long nc;
};
 
对于C++对象模型非常了解的人一定会提出这样两个问题:
1)不同编译器对vptr实现的位置不同。
2)对于vtbl,由于RTTI,其前面会有一个Type_info。
 
是这样,但它们都不影响我们使用C++生成这样的结构。
 
对于第一个问题,有些编译器把vptr放在对象其它数据的前面,有些放在后面,比如下面的类
 
struct T
{
   virtual void func1(void) = 0;
   virtual void func2(int) = 0;
private:
    int na;
    long nb;
};
 
由两种编译器编译出来的结构如下。

      |-----------------|      |----------------|
      |      vptr       |      |     int na     |
      |-----------------|      |----------------|
      |     int na      |      |     long nb    |
      |-----------------|      |----------------|
      |     long nb     |      |     vptr       |
      |-----------------|      |----------------|

 但这个这是基于没有继承关系的类而言的,如果struct T被继承,比如:
 
class Derived: public struct T
{
public:
   virtual void func1(void);
   virtual void func2(int);
private:
   char nc;
};
 
则两种编译器编译出的结构如下:

       |------------------|      |------------------|
      |       vptr       |      |     int na       |
      |------------------|      |------------------|
      |     int na       |      |     long nb      |
      |------------------|      |------------------|
      |    long nb       |      |     vptr         |
      |------------------|      |------------------|
      |    char nc       |      |    char nc       |
      |------------------|      |------------------|

仔细观察这两个结构,你就会知道,基类对象的所有属性都被排放在继承类属性之前。而如果我们把基类属性都除去,则vptr就成为唯一从基类继承下来的东西,那么它自然就成为继承类的第一个元素。
 
在我们的方案中,接口不包含任何属性,所有的实现类都是从接口类继承下来的,所以vptr当然被放在实现类对象的最前面。
 
对于第二个问题,RTTI是后来加入C++的特征,对于任意一个C++编译器,为了实现和早期的C++程序的链接,它们都提供了相应的编译选项,除去这个特征,比如g++提供了-nortti。
 
我们就这样得到了一个简单却绝对有效的,任何通用目的的编译型语言都可以毫不费力的生成的,任何非特殊目的的编程语言,无论是编译型还是解释型的(C, C++, Java, Ada,甚至Basic)都可以很容易识别的二进制结构。

6、继承

之前,我们把一个接口的二进制结构定义为一个vptr以及vptr指向的vtbl。一个实现了这个接口的对象的第一个属性就是vptr,于是我们可以很容易的定位并访问一个对象的接口。
如果我们限制组件只能实现一个接口,则大大降低了我们这种技术的方便性。我们必须允许一个组件可以实现多个接口。

既然一个接口包含一个vptr和一个vtbl,那么一个组件实现了多少个接口,组件里就会包含多少个vptr和vtbl。这些vptr依次放在组件对象结构的最前面,它们的顺序依赖于被声明的顺序。比如:

class Component: public interface_A, interface B, interface_C
{
public:

.... // 接口函数的声明及实现

private:
 int a;
};

则在组件Component对象的前面依次为interface_A, interface_B, interface_C的vptr,如下所示:

    |---------------------|
    |  interface_A_vptr   |---> interface_A_vtbl
    |---------------------|
    |  interface_B_vptr   |---> interface_B_vtbl
    |---------------------|
    |  interface_C_vptr   |---> interface_C_vtbl
    |---------------------|
    |      int a          |
    |---------------------|

而调用者想访问哪个接口,就使用哪个vptr。一切都很明了。

另外,也必须允许接口继承接口,这在面向对象的理论和实践中是一种自然的行为,不必过多的叙述。但这种继承必须是单继承。因为多重继承会影响一个接口的二进制格式。

我们之前已经把一个接口的二进制格式设计为一个vptr和一个vtbl,如果一个接口继承自一个接口,并不会改变这种布局。比如:

struct interface_A
{
 virtual int func_A1(void) = 0;
 virtual void func_A2(int) = 0;
};

struct interface_C: public interface_A
{
 virtual void func_C1(void) = 0;
};

则interface_C的结构为:

   interface_B_vptr -----> |-------------------|
                           |    func_A1        |
                           |-------------------|
                           |    func_A2        |
                           |-------------------|
                           |    func_C1        |
                           |-------------------|

这没有问题。但如果是多继承,则一个接口会包含多个vptr及多个vtbl。比如:

struct interface_B
{
 virtual int func_B1(int) = 0;
};

struct interface_C: public interface_A, interface_B
{
 virtual void func_C1(void) = 0;
};

则interface_C的二进制结构为:

   interface_A_vtpr -----> |-----------------|
   interface_B_vtpr --|    |    func_A1      |
                      |    |-----------------|
                      |    |    func_A2      |
                      |    |-----------------|
                      |    |    func_C1      |
                      |    |-----------------|
                      |
                      |--->|-----------------|
                           |   func_B1       |
                           |-----------------|

这显然与我们制定的接口二进制格式不符。另外,让接口进行多继承是没有必要的。我们完全可以通过让组件继承多个接口来实现与之等价的功能,同时却不破坏一个接口的二进制定义。

对于这个例子,我们可以通过如下方法实现:
struct interface_C: public interface_A
{
 virtual void func_C1(void) = 0;
};

class Component: public interface_C, interface_B
{
public:
 // 接口函数声明及实现。
private:
 // 组件属性
};

仔细考虑一下,你就会看出,两种方法得到了组件二进制结构是完全相同的(此时,你或许会质疑我之前的讨论),但两者之间在语义及操作上有着本质的不同,对于前者,组件实现的接口只有interface_C,那么就应该只存在一个vptr和一个vtbl,但组件却存在两个vptr和两个vtbl。而后者实现了两个接口,当然应该存在两套vptr和vtbl。

7、接口的不变性
 
将"接口与实现分离",让调用者不能了解库提供者的任何细节,而仅仅知道库所提供的公开接口,可以进一步降低重用的难度。而这些接口则是两者之间进行通信的协议,也可以称之为"契约"或"合同"。
 
只要双方都按照"合同"所规定的方式去做,"合同"之外的东西互相不需要了解。那么无论一方何时做出何种改变,只要仍然符合"合同"的所有规定,那么对另一方则毫无影响。但是,一旦"合同"发生了变化,也就意味着双方之前的约定被破坏。除非双方能够协商建立新的"合同",并都按照新的合同去操作,否则,双方的合作将会被破坏。
 
既然接口就是双方(库和其调用者)合作的"合同",所以接口必须具备"不变性",即一旦接口被发布,将永不能变。由于接口是函数声明的集合,所以接口的"变"有三种,集合中的函数被增加,减少,或改变。这个改变是指函数的参数以及返回值的改变,不包括函数名的改变(改变一个函数的名字,等同于先减少一个函数,再增加另外一个函数)。
 
不能减少和改变一个接口中的函数很容易可以理解,为什么增加一个函数也不可以?既然没有改变和减少原来的函数,原来的调用者似乎并不会受到影响。
 
但现实往往是残酷的。且不考虑增加一个接口函数就意味着向"合同"里增加新的"条款"一样已经改变了合同双方最初的约定等人文因素,即使从纯技术的因素考虑,也会带来致命的伤害。让我们来看看为什么?
 
因为接口是允许继承的,即一个接口可以从继承另外一个接口的函数集合,例如:
struct interface_A
{
     virtual void func_A1(int) = 0;
     virtual int func_A2(void) = 0;
};
 
struct interface_B: public interface_A
{
      virtual void func_B1(void) = 0;
};
 
编译后,interface_A和interface_B的二进制结构为:
 
        vptr ---------> |-----------------|
                        |    func_A1      |
                        |-----------------|
                        |    func_A2      |
                        |-----------------|
 
        vptr ---------> |-----------------|
                        |    func_A1      |
                        |-----------------|
                        |    func_A2      |
                        |-----------------|
                        |    func_B1      |
                        |-----------------|

 
如果现在我们为interface_A增加一个新的函数func_A3,则interface_B的二进制结构变为:

        vptr ---------> |-----------------|
                        |    func_A1      |
                        |-----------------|
                        |    func_A2      |
                        |-----------------|
                        |    func_A3      |
                        |-----------------|
                        |    func_B1      |
                        |-----------------|

 如果一个对象(组件)O实现了interface_B,而调用者使用了这个对象,则对于原来的版本,调用者调用接口B的函数func_B1时,编译出来的结果是访问vtbl的第3项。但对于新的版本,由于调用者没有重新编译,其仍然以vtbl的第三个入口来调用func_B1,但此时,对象O的接口interface_B的vtbl中的第三项已经改变为func_A3,于是错误的结果产生了。
 
关于这个问题,Don Box给出了一个不充分,甚至是错误的解释。他认为新的调用者(即针对修改后的接口编译的调用者)在碰巧调用旧版本的接口时,由于找不到新的接口函数会引起崩溃。仔细考虑一下,在同一台机器上,既然库已经升级为新版本,怎么可能还会调用到旧的版本?
 
但是,世间万物总是不完美的,接口也不能幸免。有时候,接口确实需要被改变。此时,至少有两种途径可以做到这一点:
 
1)如果仅仅是扩充接口的功能,即增加新的接口函数,可以通过继承原有的接口,形成新的接口。
2)干脆重新设计新的接口,让对象(组件)继承多个不相关的接口。

8、参数传递
 
指定了接口格式,以及接口在对象(组件)中的位置,可以让各种语言能够方便的识别并调用一个组件所提供的功能。事情进展的很顺利,一切都显得那么美妙。
 
但如果不做出进一步的约束,仍然会有问题。
 
"一个男孩要走过多少路,才能被称作男人......"
 
所谓访问接口,就是调用接口函数,而函数有可能存在多个参数。一个函数的调用过程如下:
1)将传递给这个函数的所有实参压栈。
2)将调用这个函数时的PC寄存器内容压栈。
3)保存相关寄存器内容。
4)通过call指令或者jump指令,从函数入口地址开始执行相关函数。
5)被调用函数根据需要到栈中存取那些实参。
6)函数执行结束时,通过ret指令或类似指令将保存在栈中的PC寄存器内容还原。
7)将之前压栈的实参出栈。
 
在不同的硬件平台上,这个过程或许有所不同,但基本原理是一致的。并且无论如何,都肯定面临参数压栈的问题(有些芯片会将一部分参数保存在寄存器,但如果参数过多,仍然需要压栈)。
 
请注意,在上述过程中,对参数的压栈是由函数的调用者完成的,而对参数的读取是由函数进行的,参数的出栈既可以由调用者完成,也可以由函数完成。既然是由两方面合作完成一件事情,那么就必须有所约定,以让双方对一件事情的理解是一致的。
 
从上述过程也可以看出,需要约定的有两点:
1)函数参数的压栈顺序必须统一;
2)确定由一方负责出栈操作。
 
对于第一点,如果两者使用同一语言,同一编译器,则不会有任何问题,因为只要不进行特别指定,语言和编译器的默认选项已经保证了一致性。
 
但如果仅仅局限于使用同一语言,同一编译器,我们就不需要在这里长篇大论的讨论新的重用方案。"小富即安"已经不能适应时代的需要。我们的野心决定了我们前行的距离。
 
不同语言对参数压栈的顺序并不一致,比如Pascal就是将参数从左到右依次压栈,而C/C++则是从右至左。这两种顺序没有本质上的区别,就像中国传统上书写和阅读习惯是从右向左一样只有技术性的差别。
 
但这种区别对于语言的互操作性非常不利。我先来看看"熊猫"这个名字的由来。
 
熊猫本质上是熊,或许我错了,我对生物的纲属科目一向搞不清楚,但至少它的外貌更像熊,而不是猫。所以它本来的名字叫"猫熊"。在民国时代,政府在重庆的一个展览会上采取了国际通用准则,使用中英文按照从左向右列出每一件展品的名字,但由于国内的阅读顺序还是从右至左,另外当时的大熊猫还不像现在这样作为国宝世人皆知。只有极少人知道它。所以大家都认为它的名字是"熊猫"。以缪传缪,约定成俗,最终它在华人世界的名字就被永久的改变了。
 
名字只是一个称呼而已,尽管会造成一些误解,却无妨大碍。但是对于要求严谨的技术而言,如果通信双方对一件事情的理解不一致,则会造成致命错误。
 
所以,必须在函数参数的传递顺序上做统一的规定,才能真正实现语言之间的互操作,才能真正达到更好的重用目标。
 
对于第二点,在某些平台上,比如x86,支持由被调用函数负责pop up之前调用时压栈的参数,只要这个函数本身的参数数量不是可变的(在C/C++中,如果一个函数声明的参数列表中有省略号,则意味着参数数量可变)。这种情况下,如果由函数负责pop up参数,则可以减少目标码的大小。因为,这避免了每个函数调用点都有一套pop up参数的代码。
 
我们在不考虑分布式系统的情况下,组件和调用者是处于同一种平台的。只要我们针对这种平台做统一的规定,则在此平台上库和调用者之间就不存在问题。

9、QueryInterface
 
通过之前的推理,我们已经得到了一个重用方案:
 
1)库和调用者之间动态链接;
2)库以组件的方式提供,这些组件的二进制格式是被严格定义的;
3)调用者(此后我们称之为client)仅仅可以看到一个组件的接口,并通过这些接口访问组件所提供的功能;
4)一个组件可以实现多个接口。
 
当一个组件实现了多个接口的时候,这个接口的vptr按照声明顺序依次放在一个组件对象模型的前面,一个问题是,当一个组件在升级的时候,增加了新的接口,去掉了原有的某个接口,或者改变了原有接口的声明顺序,那client对于组件的访问仍然会出问题。
 
这个问题的出现的原因是,vptr的以及vptr的顺序仍然是一个实现细节,如果把这一点也隐藏起来,将会使重用更加方便。

隐藏之后,client如何获取这些接口?答案很简单,所有的接口都提供一个函数就行了,client可以得到一个组件的时候,事实上得到指向一个接口的指针,然后通过这个接口的这个函数查询此组件实现的其它接口。
 
比如:
struct interface_common
{
 virtual interface_common* QueryInterface(IID id) = 0;
};
 
这个接口是所有其它接口的基类。我们为每一个接口赋予一个唯一的标示,我们暂时不指定其类型,而假定其为IID。
 
假如现在有个组件实现了两个接口:
IID interface_common_ID = IID_COMMON;
IID interface_A_ID = IID_A;
IID interface_B_ID = IID_B;
 
struct interface_A: public interface_common
{
   virtual interface_common* QueryInterface(IID id) = 0;
   virtual void func_A1(int) = 0;
   virtual int func_A2(void) = 0;
};
 
struct interface_B: public interface_common
{
   virtual interface_common* QueryInterface(IID id) = 0;
   virtual void func_B1(int) = 0;
};
 
class Component: public interface_A, interface_B
{
   interface_common* QueryInterface(IID id);
   void func_A1(int) {  }
   int func_A2(void) { return 0; }
   void func_B1(int) { }
private:
   int data;
};
 
interface_common*
Component::QueryInterface(IID id)
{
   switch(id){
   case IID_COMMON:
             return static_cast(this);
   case IID_A:
             return static_cast(this);
   case IID_B:
             return static_cast(this);
   default:
          break;
   }
 
   return 0;
}
 
这样,Client使用某个组件时,如果要访问任何接口,必须首先通过调用QueryInterface这个所有接口都包含的接口函数来得到相应的接口指针。Client有义务对查询失败的情况进行处理。这样,在任何时候,组件Component实现的接口发生变化——增加新接口,删除旧接口,改变接口声明的顺序——均不会对Client造成崩溃效果的影响。
 
通过这个接口可以隐藏一个组件的接口声明顺序的可见性。但还有一个声明顺序的隐藏很难通过简单的方案来解决,那就是一个接口内部的函数声明顺序。概念上,这些函数以何种顺序声明,从功能和语义上应该对Client没有影响。但事实上,按照我们的方案,函数声明的顺序决定了它们在vtbl中顺序,正如我们在之前所讨论的,为了摆脱语言和编译器的约束,组件和client并不通过符号进行链接,而通过对这些函数在vtbl中声明顺序的统一认识来实现链接的。所以,接口函数声明顺序的不变性在我们的方案中是至关重要的。而这一点是COM被攻击的缺陷之一。
 
再回到QueryInterface这个公共接口函数。有一点非常重要,当一个组件实现了多个接口时,Client可以通过任意一个接口的QueryInterface查询到其它接口,拿上面的例子来说,我们可以通过Component的interface_common接口查询到interface_A和interface_B,可以通过interface_A查询到interface_common和interface_B,也可以通过interface_B查询到interface_common和interface_A。当然各个接口也可以通过各自的QueryInterface查询到自己,尽管这一点从语义上是无用的。所以,最简单的理解就是,一个组件对它所有接口的QueryInterface的实现是等价的,而等价的极致就是相同。
 
当我们使用C++来实现时,这一点是被保证的。我们看一下上面例子的对象模型。

     Object
  |--------------|       iface_A_vtbl     Function
  | iface_A_vptr |----->|--------|     |----------------|
  |--------------|      |  p_QI  |---->| QueryInterface |
  | iface_B_vptr |--|   |--------|  |->|----------------|
  |--------------|  |   |  p_A1  |  |
  |   Data       |  |   |--------|  |
  |--------------|  |   |  p_A2  |  |
                    |   |--------|  |
                    |               |
                    |  iface_B_vtbl |
                    |-->|--------|  |
                        |  p_QI  |--|
                        |--------|
                        |  p_B1  |
                        |--------|

在C++中,当你在组件中实现QueryInterface时,其所有接口的vtbl中的QueryInterface指针都会指向这个实现。如果使用C来实现一个COM组件,则需要由程序员来保证这一点。

10、引用计数
 
由于组件仅仅暴露出接口,client不了解一个组件的实现细节,所以组件的创建由库来完成。比如,每个组件都有一个唯一标示,我们称之为CID。而库提供一个如下形式的函数:
 
interface_common* CreateCOMInstance(CID id);
 
而客户端则可以调用这个函数来创建一个组件,例如:
 
interface_common* iface = CreateCOMInstance(A_COM_ID);
 
当一个组件不再需要时,应该将其删除。此时面临两个问题:
1)由谁删除?
2)如何删除?
 
如果由Client来删除,那么如何删除?按照惯例,如果使用C++,则可以使用delete,如果使用C,则可以调用free。
 
我们先考虑一下C++,删除代码可能如下:
delete iface;
 
由于C++在调用delete时,会自动调用对象的析构函数,如果析构函数不是虚的,即没有用virtual修饰,则上面的delete语句根本无法调用对象真正的虚构函数。因为iface指针的类型是基类类型,而指向的却是一个继承类的对象。
 
你也许会马上告诉我:"把析构函数在基类中声明为virtual的不就可以了吗?",我会更快的回答你:"你忘了我们的目标"。
 
Don Box对这个问题的答案是,由于不同的编译又可能将虚析构函数指针放在vtbl的不同位置。且不说我对他这个理由的真实性的怀疑(因为,根据《C++ Object Model》,任何虚函数指针会按照被声明的顺序放在vtbl中,这是最简单的方案,我也想不到不用这种方案能够带来的利益),最重要的是,他同样没有揭示更本质的原因:我们构建的是一种跨越语言,跨越编译器的二进制标准的可重用方案。而virtual和delete是多么C++。
 
OK,我们不用C++的方式,既然所有的编程语言都支持函数调用,那我们在Client端想C那样调用free总可以了吧?
 
NO!!(限于篇幅,后面省略2E+10000个叹号)。原因是,由于组件是由库创建的,而这个组件很有可能是从某个特殊的内存管理器中申请的,也就是说,它未必是通过new或malloc来申请的。不妨设想一下:Client调用CreateCOMInstance创建了一个组件,其间CreateCOMInstance从一个自定的Memory Manager中申请了一块用于容纳组件对象的内存,Client在使用了这个组件之后,通过free函数把它还给了操作系统提供的Memory Manager,随后的景象是:你的程序轰然而倒,比世贸双厦的坍塌还要壮观。
 
解铃还需系铃人,既然对象是由Library创建的,最安全稳妥的方式当然是由Library来释放。随后就是第二个问题,如何释放?
 
一个直接了当的思路就是,Library可以提供一个对应的函数FreeCOMInstance。这肯定可以避免上面的问题,但从使用的角度,对Client会有很多不便。
 
考虑一下下面这段程序:
void func(void)
{
   Interface_Common* iface = CreateCOMInstance(CID_COM);
   Interface_A* iface_a = iface->QueryInterface(IID_A);
   ...
}
在上面的程序中,iface和iface_a指向同一个对象,你必须要很小心的来使用它们,因为一旦你在某个位置对其中一个指针对一个指针进行了释放操作,随后你对另外一个指针的操作将是非法的,如果你不注意这一点,你的程序将会在一个不确定的位置突然崩溃。如果你是一个有经验的C/C++程序员,你一定会经常被类似的问题困扰。
 
这个问题是编程领域的经典问题,早就被先驱们发现,并给出了不同的解决方案。最彻底的方案莫过于干脆实现一种新的支持Garbage Collection的语言,如Java,C#等。如果仍然要使用C/C++以及其它使用指针的传统编程语言,最常用的方案莫过于"引用计数"。
 
所谓"引用计数",指的是对于同一个对象,有多少个指针在指向(引用)它;一个指针指向它的时候,必然有指向它的原因,在这个指针没有dereference它的时候,贸然的释放这个对象所占用的内存时很危险的,只有当没有任何指针指向它时,释放它才是安全的。反之,如果没有指针指向它,而它所占用的资源又得不到释放时,就会造成内存泄漏。
 
尽管Java和C#实现了GC,但由于我们要实现的是跨语言的重用模型,所以"引用计数"对我们来说是一个不错的选择。为了让其成为COM的一个公共特性,我们我们要在接口基类中添加两个函数:AddRef和Release。再加上之前的QueryInterface,COM接口基类则为:
 
struct InterfaceBase
{
    virtual InterfaceBase* QueryInterface(IID) = 0;
    virtual void AddRef(void) = 0;
    virtual Release(void) = 0;
};
 
当一个组件被任何指针Reference时,都要调用AddRef一次;反之,当一个指针任何时候被Dereference之前,都要调用Release一次。对于组件自身而言,一般来讲应该实现一个计数器,当这个计数器为零时,应该对自身实现内存释放。

结论 

这就是COM技术基石的技术原理。当然COM的内容并不止与此,但那已经不是本文的范畴了。

 
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值