Part1: COM是一个更好的C++
起初C++得益于Bell实验室。在那里诞生了第一个C++开发产品——CFRONT,且公布了许多关于C++的核心工作。大多数C++经典书籍都出版于80年代后期以及90年代早期。在这段时间内,许多C++开发人员(包括几乎每一本重要C++书籍的作者)都在UNIX平台上工作,并且利用当时的编译器和链接器技术建立起许多独立的应用。这一代开发人员所用的的环境基本上奠定了C++社团思考的方向。
这段话(包括Part1的标题以及整个 Part1的思路)都取自Don Box的成名之作《Essential COM》的第一章。首先,我们用2006年今天的视角回顾一下Don Box其人。
Don Box 是一位著名的教育家,被公认为组件对象模型 (COM) 领域的权威人物;他是简单对象访问协议 (SOAP) 规范的制定者之一,也是“COM,我的爱”一词的发明者。最近,他作为架构师加入了 Microsoft .NET Developer and Platform Evangelism Group。Box 是以下三本畅销书的作者:Essential COM、Effective COM 和 Essential XML。他还撰写了一系列以审视 .NET 战略为内容、名为 Essential .NET 的书籍。在其早期的职业生涯中,Box 与他人共同创立了DevelopMentor Inc.。此公司是一家组件软件思想库,致力于为开发人员提供 COM、Java 和 XML 使用方面的培训。作为一名广受欢迎的公众演说家,Box 以善于吸引世界各地的观众,拥有深邃的技术洞察力以及令人目瞪口呆的惊人之举而著称—一项惊人举措是在 2001 年于巴塞罗那举行的 TechEd 上,他在满是肥皂泡沫的浴缸里主持一个有关 SOAP 的讨论。在业余时间,Box 喜欢与 MSDN Magazine 的撰稿人同仁在 Band on the Runtime(它演奏有关各种编程主题的歌曲)打发时光。此外,Box 还负责创建了一个时尚栏目“与Don Boxers同行”。
回到90年代中期的业界,当时Java刚刚初露峥嵘,其情形很像现今Python和Ruby的处境。什么反射,代码动态生成库,AOP,IOC,声明性编程等等这些强大的概念和工具是当时的应用开发程序员想都不敢想的。
在那个年代,面向对象的思想刚刚经过一场深刻的洗牌。在分析和设计方面,以Booch、Ivar、James三友之间的方法学的不断论战直到最后UML的出现。在这个层面上似乎市场历经混乱之后已经进入黄金般的成熟期。以C++和Object Pascal为中坚,两者成为当时绝对的主流语言,围绕着这两门语言,各大厂商的开发工具之战更是硝烟弥漫。微软的Visual C++,Borland的C++ Builder, Diphi都是当时桌面应用中的翘楚。
这里,我们不是想深刻讨论那段如火如荼的开发工具大战。我们只想顺着那段轨迹来寻着我们心中的答案:为什么历史会这样发展,而不是别的?将来有会是怎样呢?
复用
作为面向对象的语言,其一个很重要且很基本的特征就是允许用户自定义类型,并且这些类型可以在别的环境下被重复使用。这也就是我们现在很熟悉的类库和框架的基本原则。
因为C++的广泛使用,C++的类库市场诞生了。但那个时候的C++库一直都以源代码的形式分发。并且许多库都假定用户把其源代码当作最根本的文档,这样一种白盒复用的方式,有其合理的部分,但往往也使客户应用和类库之间过分耦合。
类库的用户把实现代码加入到他们的系统工程中,然后用他们的C++编译器编译自己所用到的一部分子集,这样,类库的可执行代码成为了客户应用中不可分割的一部分。
更糟的是,如果我们有多个应用采用了同一个类库,那么当最终用户同时安装了这多个应用的话,那么在磁盘空间中,会有这同一个类库的多份拷贝。最坏的情况,莫过于同时运行这些应用,那么存在多份拷贝的不只是磁盘中,而且是在你的虚拟内存中!!!
这样的情况下,类库厂商的发布更新也几乎变得不再可能,因为他只能寄希望于所有使用老版本类库的客户应用都能采用同新的版本并重新编译。
至此,这样的类库已经完全丧失了模块化的特征。
组件
为了解决上面的问题,我们可以采用一种组件技术。作为一个在原生操作系统上执行的语言,该语言的组件技术自然要依赖于其底层的操作系统,在Windows上,其内存共享技术,主要分三大块:内存映射文件,动态链接库(DLL),堆。这三者分别适合大小不同的文件和对象。有关其详细的介绍,可以看看Jeffry Richer的经典著作《Windows 核心编程》。
(把时空拉回现代,我们可以发现基于虚拟机上的语言,在组件技术方面已经摆脱了底层OS的束缚,有了全新的选择。比如Java中的打包技术(jar、war、ear)就是一个很鲜明的例子。)
这样,我们把类库放到DLL中,这是从C++类走向可替换、有效的可重用组件的重要一步。
C++与移植性
然而问题远没有结束,恰恰相反,令人头疼的事情才刚刚开始。C++的基本弱点之一便是:C++缺少二进制一级的标准。
明确点说就是,在众多厂商的编译器和连接器之间是缺乏兼容性的。举个例子:为了在C++中支持操作符重载和函数重载,各个编译器都采用了自己特有的“名字改编”(或叫“名字碎片”)机制。这种厂商特有的方案限制的客户编译器的选择。
一个权宜之计就是,可以使用extern“C”把函数引出为全局,从而不受特定方案的影响。但并不是所有函数都能到处为全局函数。
另一个好些点子就是在客户应用所采用的链接器上作文章,使用模块定义文件(Module Definition File,DEF文件)把引出符号化名为不同的引入符号。这样可以使任何一个编译器能够获得“与DLL在链接层次上的兼容性”。
但无论引入再多高深玄妙的糊墙术,C++的缺少二进制标准,这限制了语言的特征在跨越DLL边界时的应用。这也意味着,简单的从DLL中引出C++成员函数,还不足以创建“厂商独立的组件软件”。
封装性和C++
假定有人克服了编译器和链接器的问题,那么也先别急着高兴,或者焦虑将来钱怎么才能花的完,我们先来看看另外一拦路虎:在C++中建立二进制组件的下一个障碍则与封装有关。
C++通过private和pubilc关键字确实支持了语法上的封装性,但是,C++草案标准并没有定义二进制层次上的封装性。C++的编译模型要求客户的编译器必须能够访问与对象的内存布局有关的所有信息,这样才能构造类的实例,或者调用类的非虚成员函数。这些信息包括对象的私有成员和公共成员的大小和顺序。
来个例子吧:假设一个类库的1.0版要求每个实例4个字节,那么针对1.0版本的客户应用在使用该类库时会分配4个字节的内存,并传给类的构造函数。然而该类库的2.0版的构造函数、析构函数和方法都假定客户为每个实例分配了8个字节的内存,并且毫无保留的写入所有这8个字节。但,不幸就这么发生了。你更改的这后四个字节实际上是属于别人的。后果会是什么?或许只能求神拜佛,以慰心神。
针对这种情况,一个通用的解决反感是,每次新的版本问世,就把DLL改成其他的名字。这也正是MFC采用的策略。不幸的如同谎言间的互相支撑,直到最后如雪球般巨大一样。“DLL地狱”这个令微软头疼不已的问题也就由此引发。
从根本上讲,版本问题的根源在于C++的编译模型,这个模型不能支持独立二进制组件的设计。C++的编译模型要求客户必须知道对象的布局结构,从而导致了客户和对象的可执行代码之间的二进制耦合关系。通常,二进制耦合对于C++非常有好处,因为这使编译器可以产生非常高效的代码。但不幸的是,这种紧密耦合性使得在不重新编译客户的情况下,类的实现无法被替换。
由于这种二进制耦合性,以及编译器和链接器的不兼容性。“简单的吧C++类定义从DLL中引出”并不能提供合理的二进制组件结构。
分离
封装的概念是“一个对象的外观(接口)同其实际工作方式(实现)分离开来”。C++的问题在于这条原则并没有被应用到一个二进制层次上,因为C++类既是接口也是实现。这里的解决方案是分离!!!
在C++中,Don Box和Scott Meyers管它叫作句柄类!这在C++中是一个很好的解决编译模型问题的模式(细节见《More Effective C++》),按照模式的术语,则可以叫它代理模式。
其核心要点不过:在句柄类中持有一个实现类的指针(因为是指针,其大小固定,所以即便实现类的二进制大小发生变化,但丝毫不会影响句柄类的内存布局),然后把所有暴露在接口中的函数都委托转发到实现中去执行。
这样一个句柄类(或叫接口类)就好像在用户和实现之间加入了一道二进制防火墙,客户与对象之间的所有通信都要通过接口类才能进行。这就相当于强加了一个简单的二进制协议。并且这个协议并不依赖于C++实现类的任何细节。
这种做法的弱点在于,对于性能非常关键的应用,每个方法增加两个函数调用(一个调用岛接口,另一个潜逃调用到实现部分)的开销并不理想。而且,句柄类虽然解决了封装性的问题,但并没有完全解决编译器、链接器兼容性的问题。
兼容性
兼容性问题起源于不同的编译器对于下面两个方面有不同的考虑方案:
Ø 如何在运行时表现语言的特征
Ø 在链接时刻如何表达符号名字
为了保持独立性,我们必须保证C++接口类所强加的二进制防火墙只使用与编译器无关的语言特性。
第一步,我们要做的是确定语言的哪些方面既有统一的实现形式(即对所有的编译器都一致)。我们可以得出下面三点:
Ø 复合类型在运行时的表现形式对于不同的编译器往往会保持不变
Ø 所有的编译器都强制使用同样的顺序(从右到左,或者从左到右)传递函数参数,并且堆栈的清理也必须按照统一的方式进行。
Ø 某个给定平台上的所有C++编译器都实现了同样的虚函数调用机制。
这里对我们意义非凡的是第三点。在C++中,虚函数的运行时实现采用了vptr和vtbl的形式。几乎每一个当前正在使用的、算的上软件产品的C++编译器都用到了vptr和vtbl的基本概念。对于vtbl的布局结构存在两种基本技术:一种是CFRONT技术,另一种则是adjustor thunk技术。幸运的是,在给定的一种平台上,往往会有一种占主导地位(win32编译器使用adjustor thunk技术,二Solaris编译器使用CFRONT风格的vtbl),同样幸运的是,这两种vtbl格式都不会影响到程序员必须要编写的C++源代码,因为vtbl只是内部产生的代码。关于这两项技术的详细阐述可以去看看Stan Lippman的《Inside the C++ Object Model》。
如此,我们可以对前面的句柄类作出以下修改,来解决兼容性的问题:把整个接口类变成一个抽象基类,所有的成员函数都定义为纯虚函数,不能包含数据成员,且接口类不能从多个其他接口派生。对应的C++实现类必须从接口类继承,并且重载每个纯虚函数,以及实现这些函数。这种继承关系将导致对象的内存结构是接口类(实际上接口类只不过市vptr/vtbl)的内存结构的二进制超集。因为在C++中,派生类和基类之间的“is-a”关系应用在二进制层次上,如同应用在面向对象设计的模型层次上一样。
接下来就是关于实现类构造的问题:如果让终端用户去构造它,那么这等于绕过了 接口的二进制封装。从而破坏了使用接口类的基本意图。一种合理的技术是让DLL引出一个全局函数,由它代表客户调用new操作符。这个函数必须以extern “C”的方式被引出来,因此任何一个C++编译器都可以访问这个函数。
最后一个有待于进一步克服的障碍与对象的析构函数有关。
如果接口类的析构函数不是虚函数,这意味着对delete操作符的调用并不会动态的找到最终派生类的析构函数,并从最外层类型向基类型递归的销毁对象。
可是,即便把接口类的析构函数做成虚函数,依然会破坏接口类的编译器独立性,因为虚析构函数在vtbl中的位置随着编译器的不同而不同。
故,一个可行的解决方案是显示的增加一个Delete方法,作为接口类的另一个纯虚函数,并且让派生类在这个方法中删除自身。这样做可以导致正确的析构过程。
至此,接口类的虚函数总是通过保存在vtbl中的函数指针被间接调用,客户程序不需要在开发时候链接这些函数的符号名。唯一需要通过名字显示链接的入口函数是创建实现类的全局函数,因而也避免了符号名改编方式的冲突。
扩展性
如果某天,我们需要在句柄类中添加新的操作,那该怎么办呢?
我们可以利用vtbl布局结构的知识,只是简单的把新的方法追加在现有接口定义的尾部。这样可以正常工作。在老版本接口上编译得到的客户完全忽略vtbl中后来增加的内容。但这样改变二进制接口定义会引起“客户代码再次执行时产生运行时错误”。
因而,我们需要寻求一个解决方法:就是允许实现类暴露多个接口。
我们有两个途径:一、设计一个接口使它继承另一个相关的接口,或者让实现类继承多个不相关的接口。最终客户都可以用C++的运行时类型识别(RTTI)在运行时确定其运行时型别。
但,令人惋惜的是,RTTI是一个与编译器极为相关的特征。C++草案工作文档规定了RTTI的语法和语义,但每个编译器厂商对RTTI的实现是独有的,也是私有的。
因此,我们需要平衡处理dynamic_cast的语义,不适用实际与编译器相关的语言特征。从每一个接口显示的暴露一个广为人知的方法,这个方法完成与dynamic_cast语义等价的功能。
既然所有接口都需要暴露这个RTTI的方法和Delete方法,我们可以很自然的把它提升到一个基接口中。然后所有其他的接口都从这个接口继承。
资源管理
有关单个对象支持多个接口还有一个问题:如何记录下哪个指针是与哪个对象联系在一个的。并且每个对象只能调用一次Delete方法。
上面的问题可以有用户去解决,也可以把管理对象生命周期的责任推给对象实现部分。而且原则上看,允许客户显示的删除一个对象,这种做法会泄露另一个实现细节:对象是被分配在堆上的事实。
因此我们的解决方案是让每个对象都维护一个引用计数。这样替换掉前面我们讨论的Delete方法。基类的接口就应当有三个方法。一个是用于RTTI的,另外两个则分别完成引用计数种的递增和递减。有了这些方法之后,现在该接口的所有客户必须遵守下面两条要求:
Ø 当接口指针被复制的时候,要求调用引用计数递增的方法。
Ø 当接口指针不再有用时,要求调用引用技术递减的方法。
故此,每个指针都被看作揖个具体独立生命周期的实体,所以客户并不需要把哪个指针与哪个对象联系起来。此外,对引用计数的操作,我们也可以选择采用C++智能指针来隐藏其实现。
回顾
我们探讨了把一个C++类发布成可重用的二进制组件。
第一步,以DLL形式发布这个类。
第二步,采用句柄类,把实现封装到二进制防火墙中,解决了C++的封装性问题(即编译模型的二进制耦合性)
第三步,采用抽象基类,使防火墙以vptr和vtbl的形式出现,从而解决C++编译器、链接器兼容性的问题。
第四步,使用RTTI类似的结构解决扩展性的问题。
第五步,采用引用计数来实现资源管理。
至此,我们也就设计了一个组件对象模型(COM,Component Object Model)。
事实上,在COM中IUnknown同我们讨论中的基接口有同样的目的。在该接口中,提供了三个方法:QueryInterface、AddRef、Release。它们分别完成RTTI和引用计数的操作。
Part2: CLR是一个更好的COM
在2000年的PDC开发者大会上,Microsoft就宣称COM编程模型将消亡,取代它的将是CLR。
这段话(包括Part1的标题以及整个 Part1的思路)都取自Don Box的最近的一部著作《Essential .NET》的第一章。
COM的不足
COM即是编程模型,也是平台技术。作为编程模型,它是很棒的。封装,多态,接口与实现分离这些思想与Design Patterns是不谋而和的。
但作为平台技术,它则不是那么令人满意。多数问题都能追溯到组件间约定的本质上。使得COM的约定技术对表示语义并不是最优的有最重要的两点。
Ø 与COM约定的描述有关
Ø 与约定本身有关
对于约定描述,Mircosoft定义和支持的COM交换格式不是一个,而是两个:接口定义语言(IDL),类型库(TLB),并且这两种格式不是同构的,也就是其中一种格式表示的结构对另一种格式没有什么意义。因而,对于约定描述来说,无法确定哪种格式是“权威的”或者“标准的”。
此外,COM没有描述组件的依赖性。
还有COM的约定描述格式缺乏扩展性。在这方面,MTS做出了很有意义的尝试,这样基于声明式的特性后来也成了EJB的基础。这可谓AOP的早期应用了。但由于缺乏统一的描述格式,在VC和VB中分别采用IDL和TLB,从而导致扩展性的解决也无从进展。最后,索性不破不立,不如定义一种新的约定格式。
从约定本身来看,它是物理的,或者是二进制的。整个COM对组件间的调用方式有着严格的二进制下的控制。
这样的约定,一个很明显的缺陷就是:过度关注细节,只有这样才能确保正常工作。而且这也给COM组件本身的版本控制带来了很大的难题。
CLR
CLR作为.NET的核心技术之一出现了。它的出现也正是为了解决那些在COM中的问题。
在.NET下可以通过元数据(metadata)来描述组件之间的约定。而且通过定制特性(attribute),CLR元数据可以达到清晰容易的可扩展性。
在CLR中,组件约定被描述为类型的逻辑结构而无需关心其内存布局、堆栈约定、虚函数表。通过约定的虚拟化,在很大程度上降低了COM二进制约定所带来的不稳定性。
由于CLR的类型定义是逻辑的,而不是物理的。故,CLR的约定并没有暗示访问字段或方法的精确的代码顺序。而且,CLR通过名字和签名引用字段与方法,而不是偏移量。
因为约定的物理方面在组件编译时都不知道,因此,需要引进某种机制,延期这些位移量的解析,直到代码实际部署。为了实现这种可能,CLR的组件几乎不包含机器代码。准确的说,基于CLR的组件采用公共中间语言(CIL)来表示这些组件。
当CIL到本机代码的翻译完成后,任何数据类型或方法的实际内存表示形式都将被用于生成本机的机器代码。
CIL生成的本机代码同样受益于高性能的物理耦合方式,这也是C++和COM所采用的方式。然而C++和COM在形式阶段就考虑这样的物理耦合。而CLR在CIL到本机代码翻译发生之前,不会解析这种物理绑定的细节。
编程模型的演进
我们不妨回顾一下从DOS的平台到Windows NT的演变,最初,一些开发者对从物理内存与中断转向虚拟内存与线程感到太慢或者太受限制。
而今,CLR的约定本性决定了它适合于一种独立于任务或编程语言的编程模型。它比COM更精炼,尤其强调类型为中心。
CLR的编程思想就是:一切都是类型、对象或值。CLR鼓励摒弃显示的内存管理和线程管理。
迁移到托管环境是一种进步!!!一直以来,当程序员面临生产率和可控制性的选择时,随着时间的推移,能够让他们拥有更高的生产率的技术是更可能胜出的!!!
Part3: JVM vs. CLR
通过前面两部分,我们希望大家能够有一个知识结构的梳理。从C++到COM再到CLR,它们都是什么,为什么会有这样的演进。
或者我们可以这样来看待整个发展:
C++是一个直接运行在原生OS上的语言,用它编写的代码直接编译成本机代码,然后通过链接器连接之后,在C++运行时的支持下直接跑在OS上。想要一个C++类作为组件可以在别的环境下重用,它自身的编译器、链接器兼容性问题和二进制耦合性是无法回避的。
COM本身不是一种语言,正如Don Box所言,COM是一个编程模型,它描述了发布成独立的组件的一些物理规范。COM组件本身可以采用多种编程语言编写。此外,COM也是一种平台技术(提供有运行时环境),各种语言编写的组件通过约定描述,或是IDL或是TLB来实现交互,这些约定采用二进制形式的物理约定。
MTS的出现(在COM+中附加的部分)解决了操作可扩展性的问题,并且作为AOP的早期实用产品,提出了很好的基于声明的编程模型和基于RPC的拦截调用模型。
JVM,为了实现Java语言的平台无关从而引入的虚拟化层。在解决平台无关的同时,它也解决了C++中发布组件时的一些痼疾。
EJB的出现,在声明性编程上很好的参考了MTS的机制,然而EJB的目标不只是一个组件模型,它的野心远不止于此。在分布式模型上,它和微软后来的DCOM相对。
CLR,作为.NET下的加载执行引擎,它就是.NET下相当于JVM的虚拟机。然而出于商业操作的不同,JVM要了平台无关,CLR要了多语言支持。其实,抛开语言本身不谈,在添加了虚拟机这样的层次结构之后,不论是要平台无关或是多语言支持都是不难办到的。Linux/Unix下不是就有.NET的Mono吗?JVM上现在不是可以跑Python、可以跑Ruby吗?语言本身被编译为中间代码或字节码,通过虚拟机层次的抽象,语言本身不用对其物理表示细节做出任何假设和规定。我们可以刻划出明确的语言运行时特征。这也正是C#、Java等在虚拟机上跑的语言同C++等原生语言最本质的区别。
很平和的讲,CLR参照了JVM和COM各自的优缺点,来得比后两者简洁、富于表达力。在以后的文章中,我们会继续更加深入到这两个平台中,一一探讨在面对各个基本概念和问题的各自解决方案。