2.3 对象模型要素
Jenkins和Glasgow指出,“大部分程序员使用一种语言,并只使用一种编程风格。他们使用的编程方式是所用的语言强加给他们的。通常,他们没有机会换一种方式来思考问题,因此难以看到选择更适合手上问题的编程风格所带来的好处”[40]。Bobrow和Stefik将编程风格定义为“一种组织程序的方式,它基于某种编程概念模型和一种适合的语言,目的是使得用这种风格编写的程序很清晰”[41]。他们进一步指出,存在5种主要的编程风格,这5种风格及它们使用的抽象如下。
(1)面向过程 算法;
(2)面向对象 类和对象;
(3)面向逻辑 目标,通常以谓词演算的方式表示;
(4)面向规则 如果-那么规则;
(5)面向约束 不变的关系。
没有一种编程风格是最适合所有类型的应用的。例如,面向规则的编程可能最适合设计知识库,而面向过程的编程可能最适合设计计算密集的操作。根据我们的经验,面向对象风格最适合的应用范围最广,实际上,这种编程风格通常作为架构框架,被其他编程风格所使用。
每一种编程风格都基于它自己的概念框架。对于所有面向对象的东西,概念框架就是对象模型。这个模型有四个主要要素:
(1)抽象;
(2)封装;
(3)模块化;
(4)层次结构。
所谓“主要”,指的是如果一个模型不具备这些元素之一,就不是面向对象的。
对象模型有三个次要要素:
(1)类型;
(2)并发;
(3)持久。
所谓“次要”,指的是这些要素是对象模型的有用组成部分,但不是本质的。
如果没有这个概念框架,你可能在使用Smalltalk、Object Pascal、C++、Eiffel或Ada这样的语言编程,但是你的设计可能看起来像是FORTRAN、Pascal或C应用。更重要的是,你不太可能把握手上问题的复杂性。
2.3.1 抽象的意义
抽象是我们人类处理复杂性的基本方式。Dahl、Dijkstra和Hoare指出,“抽象来自于对真实世界中特定对象、场景或处理的相似性的认知,并决定关注这些相似性而忽略不同之处”[42]。Shaw将抽象定义为“对一个系统的一种简单的描述或指称,强调系统的某些细节或属性同时抑制另一些细节或属性。好的抽象强调了对读者或用户重要的细节,抑制了那些至少是暂时的非本质细节或枝节”[43]。Berzins、Gray和Naumann建议:“只有当一个概念可以独立于最终使用和实现它的机制来描述、理解和分析时,我们才说这个概念是抽象的”[44]。结合这些不同的观点,我们将抽象定义如下:
“抽象描述了一个对象的基本特征,可以将这个对象与所有其他类型的对象区分开来,因此提供了清晰定义的概念边界,它与观察者的视角有关。”
抽象关注一个对象的外部视图,所以可以用来分离对象的基本行为和它的实现。Abelson和Sussman将这种行为/实现的分界称为抽象壁垒[45],它是通过应用“最少承诺”原则来达成的。根据这个原则,对象的接口只提供它的基本行为,此外别无其他[46]。我们还想使用另一个原则,我们称之为“最少惊奇”原则,这个原则是指抽象捕捉了某个对象的全部行为,不多也不少,并且不提供抽象之外的惊奇效果或副作用。
抽象关注某个对象的基本特征,它与观察者的视角有关
对于给定的问题域决定一组正确的抽象,就是面向对象设计的核心问题。因为这个问题如此重要,所以我们将用第4章一整章的篇幅来讨论它。
“从那些准确地为问题域实体建模的对象到那些实际上没有什么理由存在的对象,存在着一系列的抽象。”[47]按最有用到最没有用的次序,这些抽象是:
■ 实体抽象:一个对象,代表了问题域或解决方案域实体的一个有用的模型;
■ 动作抽象:一个对象,提供了一组通用的操作,所有这些操作都执行同类的功能;
■ 虚拟机抽象:一个对象,集中了某种高层控制要用到的所有操作,或者这些操作将利用某种更低层的操作集;
■ 偶然抽象:一个对象,封装了一组相互间没有关系的操作。
我们追求构建实体抽象,因为它们直接对应着给定问题域的词汇。
客户对象是使用其他对象(称为服务器对象)的资源的对象。可以通过考虑对象提供给其他对象的服务来总结一个对象的行为,以及它可能施加在其他对象上的操作。这种视角迫使我们集中关注对象的外部视图,导致了Meyer所谓的“编程契约模型”[48]:每个对象的外部视图定义了一份契约,其他对象可以依赖这份契约,而该对象则需要通过它的内部视图来实现这份契约(常常需要与其他对象协作)。这份契约因此建立了客户对象可以对服务器对象做出的所有假定。换言之,这份契约包含了对象的职责,即它的可靠的行为。
单独来看,构成这份契约的每个操作都有一个唯一的签名,包含它所有的正式参数和返回值。我们把客户对象可以调用的整个操作集,以及这些操作合法的调用顺序,称为它的“协议”。协议表明了对象的动作和反应的方式,从而构成了抽象的完整静态和动态外部视图。
抽象思想的核心是不变性的概念。“不变量(invariant)”是某种布尔(真或假)条件,它的值必须保持不变。对于对象的每个操作,我们可以定义“前置条件(precondition)”(操作假定的不变量)和“后置条件(postcondition)”(操作满足的不变量)。违反一个不变量将破坏一个抽象相关的契约。如果违反了前置条件,就意味着客户没有完成它那部分的职责,因此服务器不能可靠地执行。类似地,如果违反了后置条件,就意味着服务器没有完成它那部分的职责,所以客户不能再信任服务器的行为。出现异常表明某个不变量没有满足或不能满足。某些语言允许对象抛出异常,这样就可以中止处理,向其他对象报告问题,然后这些对象就可以捕捉异常并处理问题。
顺便提一下,术语“操作”、“方法”和“成员函数”是从三种不同的编程文化中发展而来的(分别是Ada、Smalltalk和C++)。它们基本上指的是同样的东西,所以我们互换使用这些术语。
所有的抽象都有静态和动态的属性。例如,一个文件对象会占用特定存储设备上的一些空间,它有一个名字,也有内容。这些都是静态属性。这些属性的值是动态的,和对象的生命周期有关:一个文件对象可能变大或变小,它的名字可能改变,它的内容也可能改变。在面向过程风格的编程中,改变的动作和对象的动态值是所有程序的中心部分,当子程序被调用,语句被执行时,事情就发生了。在面向规则风格的编程中,当新的事件触发了规则,事情就发生了,它又可以进一步触发其他规则,如此等等。在面向对象风格的编程中,当我们操作一个对象时,事情就发生了(例如,向一个对象发出一个消息)。因此,调用一个对象的操作引发该对象的某种反应。我们可以有意义地执行一个对象上的哪些操作,以及该对象如何反应,构成了这个对象的全部行为。
抽象的例子
让我们通过一些例子来说明这些概念。关于如何发现给定问题的正确抽象,将在第4章讨论。
在一个溶液栽培的种植园中,作物是在营养液中栽培的,没有沙、碎石或其他土壤。维护正常的温室环境是一件精细的工作,这取决于栽培作物的种类和它的生长阶段。人们必须控制各种因素,如温度、湿度、光照、pH 值和营养液的浓度。在一个大型种植园中,拥有一个自动化的系统并不少见,这个系统持续监控并调整这些因素。简单地说,一个自动化园丁的目标是有效地种植作物,让多种作物健康地生长,并尽量减少人工干预。
这个问题的一个关键抽象是传感器。实际上,存在几种不同类型的传感器。任何影响产量的因素都必须测量,所以必须有空气温度、水温、湿度、光照、pH 值和溶液浓度的传感器和其他一些传感器。从外部来看,温度传感器仅仅是一个对象,它知道如何测量某个具体位置的温度。什么是温度?它是一种数值,具有一定的范围和一定的精度,采用华氏、摄氏或开氏温标,只要选一种最适合我们问题的温标就行。什么是位置?它是种植园中某个可标识的地方,我们希望测量那里的温度。我们假定这样的位置不多。对于温度传感器来说,重要的不是它具体处于什么位置,而是它有一个位置,并有一个唯一的标识符,用来区分它和所有其他的传感器。现在我们可以问:温度传感器的职责是什么?我们的设计决策是,传感器负责知道某个位置的温度,并在被询问时报告温度。更具体地说,一个客户可以对温度传感器执行哪些操作?我们的设计决策是,客户可以对它定标,并询问当前的温度。(参见图2-6。注意,这种表示方法与UML 2.0中的类的表示方法有点类似。第5章中将有准确的表示方法。)
到目前为止,我们描述的抽象都是被动的,某个客户对象必须操作一个空气温度传感器对象,以确定它当前的温度。但是,存在另一种合理的抽象。它也许更适合,也许不太适合,这取决于我们可能做出的更大范围的系统设计决策。具体来说,让温度传感器不是成为被动的,而是成为主动的,这样它就不是由其他对象来激活,而是当它所在位置的温度改变了一定值时,激活其他对象。这种抽象与我们的第一种抽象几乎一样,只是它的职责有了一点小小的变化:传感器现在负责在温度变化时报告温度,而不只是在被询问的时候。这种抽象必须提供怎样的新操作呢?
这种抽象比第一种抽象要复杂一点(参见图2-7)。这种抽象的客户可能调用一个操作来设定临界的温度范围。当传感器所在位置的温度上升或下降超过设定的范围时,它就负责报告。当函数被调用时,传感器提供它的位置和当前的温度,这样客户对象就有了足够的信息,可以根据情况做出反应。
图2-6 温度传感器的抽象
图2-7 主动式温度传感器的抽象
主动式温度传感器如何执行它的职责,这是它的内部视图的功能,外部客户并不关心。这些东西成为这个类的秘密,由这个类的私有部分以及成员函数的定义共同实现。
让我们来考虑另一种抽象。对于每种作物,必须有一份培育计划,描述温度、光照、营养液和其他条件应该如何随时间的变化而变化。培育计划是一种合理的实体抽象,因为它是问题域词汇的一部分。每种作物都有它自己的培育计划,但是所有作物的培育计划的形式都是一样的。
培育计划负责记录与培育作物有关的所有令人感兴趣的动作,对应到什么时间应该采取什么动作。例如,在某种作物生长的第15天,我们的培育计划可能是在16小时内将温度维持在78°F,在这期间开灯14小时,然后在这一天的其他时间内将温度降到65°F。我们也可能希望在这天的中间添加某种额外的营养液,并保持微酸的pH值。从培育计划外部的视角来看,客户必须能够建立起计划的细节,修改计划,并查询计划,如图2-8所示。(注意,抽象有可能随项目的生命周期而演进。随着细节的出现,“建立计划”这样的职责可能变成多项职责,如“设定温度”、“设定pH值”等。当收集到更多的客户需求知识,设计变得成熟,考虑到实现的方式时,就会出现这种情况。)
我们的决定仍然是不要求培育计划自己执行它的计划:我们将这一点作为另一种抽象的职责(如一个计划控制器)。通过这种方式,我们在系统的不同逻辑部分之间创造了一种清楚的“关注点分离”,这样就减小了单个抽象的概念规模。例如,可能有一个对象位于人机界面的边界上,将人的输入转变成计划。这个对象建立起培育计划的细节,所以必须能够改变培育计划对象的状态。必须有一个对象来执行培育计划,它必须能够读出特定时间的计划细节。
图2-8 培育计划的抽象
这个例子指出,没有对象是孤立的,每个对象都与其他对象协作,实现某些行为。[1]这些对象之间如何协作的设计决策,定义了每种抽象的边界,从而也定义了每个对象的职责和协议。
对象与其他对象协作,实现某种行为
2.3.2 封装的意义
虽然我们在前面曾将培育计划的抽象描述为一种时间/动作的映射,但是它的实现不一定必须是一张表或映射表这样的数据结构。实际上,选择哪种表示方式对培育计划的客户契约并不重要,只要这种表示方式能实现这个契约就行。简单地说,对象的抽象应该优先于它的实现决定。当选择了一种实现之后,它就应该作为这种抽象后面的秘密,对绝大多数客户隐藏。
封装隐藏了对象实现的细节
抽象和封装是互补的概念:抽象关注的是对象可以观察到的行为,而封装关注这种行为的实现。封装通常是通过信息隐藏来实现的(不只是数据隐藏)。信息隐藏是将那些不涉及对象本质特征的秘密都隐藏起来的过程。通常,对象的结构是隐藏的,其方法的实现也是隐藏的。“复杂系统的每一部分都不应该依赖于其他部分的内部细节。”[50]抽象“帮助人们思考他们做什么”,而封装“让程序可以借助最少的工作进行可靠的修改”[51]。
封装在不同的抽象之间提供了明确的边界,因此导致了清晰的关注点分离。例如,再来看植物的例子。要从最高的抽象层理解光合作用的工作原理,我们可以忽略一些细节,如根的职责或细胞壁的化学作用。类似地,在设计数据库应用时,标准的实践是编写程序时不关心数据的物理表示,而是仅仅依赖于说明数据逻辑视图的方案[52]。在这些例子中,在一个抽象层次上的对象看不到较低抽象层次的实现细节。
“要让抽象能工作,必须将实现封装起来。”[53]在实践中,这意味着每个类必须有两个部分:一个接口和一个实现。类的接口描述了它的外部视图,包含了这个类所有实例的共同行为的抽象。类的实现包括抽象的表示以及实现期望行为的机制。通过类的接口,我们能知道客户可以对这个类的所有实例做出哪些假定。实现封装了细节,客户不能对这些细节做出任何假定。
综上所述,我们这样定义封装:
“封装是一个过程,它分隔构成抽象的结构和行为的元素。封装的作用是分离抽象的概念接口及其实现。”
Britton和Parnas将这些被封装的元素称为抽象的“秘密”。
封装的例子
为了说明封装的原理,让我们回到水培园艺系统。这个问题域中的另一个关键抽象是加热器。加热器是一种相当低层的抽象,因此我们可能决定,对这个对象可以执行的有意义的操作只有三种:打开它、关闭它、查看它是否在工作。
关注点分离
我们没有让加热器负责维持一个固定的温度,而是把这个职责赋予了另一个对象(如加热控制器),它必须与一个温度传感器和一个加热器协作才能实现这个高级行为。把这个行为称为高级行为是因为,它基于温度传感器和加热器的简单语义基础之上。它增加了某种新的语义,即“滞后现象”,这防止了加热器在温度接近边界条件时被快速打开和关闭。通过决定分离这部分职责,我们可以让每种抽象变得更为内聚。
关于加热器类,所有客户都要知道的是它提供的接口,即它在执行客户请求时会履行的职责(参见图2-9)。
来看看加热器的内部,我们会看到完全不同的情况。假设系统工程师决定将控制每个温室的计算机放在温室以外的某个地方(也许是为了避免恶劣的环境),并通过串行通信电缆将计算机连接到传感器和执行器上。加热器类的一种合理实现,可能是利用一个电磁继电器来控制每个物理加热器,继电器由电缆发送的消息控制。例如,为了打开加热器,可能发出一条特殊的命令字符串,后面跟上一个数字,指明具体是哪个加热器,后面再跟上一个数字,表明是打开加热器。
图2-9 加热器的抽象
假定出于某种原因,系统工程师决定使用内存映射I/O来代替串行通信电缆。我们不需要改变加热器的接口,但是实现会很不一样。客户根本不会看到任何变化,因为客户只能看到加热器接口。这就是封装的要点。实际上,客户不应该关心实现是怎样的,只要从加热器接收所需的服务就行。
接下来考虑培育计划类的实现。前面曾提到,培育计划本质上是一个时间/动作映射表。也许对这种抽象最合理的表示方式是一个时间/动作字典,利用一个开放的散列表。不需要保存每个小时的动作,因为情况的变化没有这么快。可以只存放发生变化时的动作,让实现来推断两个时间点之间的情况。
在这种方式中,我们的实现封装了两个秘密:使用一个开放的散列表(它明显是解决方案域中的词汇,不是问题域中的词汇)以及利用推断来减少存储需要(否则就要存放多得多的时间/动作关系,来反映作物的整个生长季节)。这个抽象的客户不需要知道这些实现决定,因为它们实际上对这个类外部可见的行为没有影响。
明智的封装让可能改变的设计决策局部化。随着系统的演进,开发者可能发现,在实际使用中,某种操作花的时间超过了可接受的范围,或者某些对象使用的空间超过了可用的空间。在这些情况下,对象的表示方法常常会改变,这样就可以采用更高效的算法,或者通过计算而不是存储某些数据来优化空间的使用。抽象让我们既能改变表示方法,同时又不影响其客户,这就是封装的根本好处。
隐藏是一个相关的概念:在一个抽象层次隐藏起来的东西,在另一个抽象层次里可能代表了外部视图。对象的内部表示方法可能被揭示出来,但是绝大多数情况下,只有当这个抽象的创造者显式地暴露出实现,而且客户愿意接受由此带来的额外的复杂性时,才会这样做。所以,封装不能阻止开发者做蠢事。正如Stroustrup所指出的,“隐藏是为了防止事故,而不是防止欺骗”[56]。当然,没有哪种程序设计语言防止人们看到一个类的实现,尽管操作系统可能拒绝你访问包含这个类实现的文件。
2.3.3 模块化的意义
“将一个程序分割到一些不同的组件中,这可以在某种程度上减少它的复杂性……虽然从这一点上来说,分割程序是有帮助的,但是分割程序的更大理由是它在程序内部创造了一些定义良好的、有文档描述的边界。这些边界,或者叫接口,对于理解程序非常有价值。”[57]某些语言,如Smalltalk,没有模块的概念,所以类就成了分解的唯一物理单元。Java有包的概念,包中包含类。在许多其他语言中,包括Object Pascal、C++和Ada,模块是一种独立的语言结构,确保了一组独立的设计决策。在这些语言中,类和对象构成了系统的逻辑结构,我们把这些抽象放入模块中,形成系统的物理架构。特别是对于较大型的应用来说,可能有成百上千个类,使用模块对管理复杂性有很大的帮助。
“模块化将程序划分为一些模块,这些模块可以独立地编译,但又与其他模块有联系。我们将使用Parnas的定义:‘模块之间的联系是模块相互之间所做出的假定’。”[58]大多数语言将模块作为一个独立的概念,它们也区分模块的接口和它的实现。因此,可以说模块化和封装是密不可分的。
模块化将抽象打包成独立的单元
对于一个给定的问题决定一组正确的模块,这和决定一组正确的抽象的难度几乎差不多。Zelkowitz这样说肯定是正确的:“因为在设计阶段开始时我们可能不知道解决方案,分解为较小的模块可能相当困难。对于较老的应用(如写一个编译器),这个过程可能成为标准,但是对于新的应用(如防御系统或宇宙飞船的控制),这可能相当困难。”[59]
模块作为一种物理容器,我们在其中声明逻辑设计中的类和对象。这和电子工程师在设计计算机主板时的情况没有什么区别。NAND、NOR和NOT等逻辑门可以用来构造必要的逻辑,但是这些门必须用标准集成电路的方式进行物理封装。由于缺少这样的标准软件部件,软件工程师拥有更大的自由度——就像电子工程师可以控制芯片厂一样。
对于很小的问题来说,开发者可能决定将所有的类和对象都声明在同一个包中。对于稍微有点实际意义的软件来说,更好的解决方案是将逻辑上相关的类和对象放在同一个模块中,只暴露出其他模块必须看到的元素。例如,考虑一个运行在一组分布式处理器上的应用,它使用消息机制来协调不同程序之间的动作。一个大型系统,如命令与控制系统,常常有几百甚至上千种这样的消息。一种很幼稚的策略可能是在各自的模块中定义每一种消息类。结果表明,这是一个非常差劲的决定。它不仅造成了文档噩梦,同时用户也很难找到他们需要的类。而且,当决定改变时,几百个模块都要修改或重新编译。这个例子说明,信息隐藏可能会造成相反的效果[60]。随意的模块化有时候比不实现模块化还要糟。
在传统的结构化设计中,模块化主要是考虑对子程序进行有意义的分组,利用耦合和内聚的判据。在面向对象的设计中,这个问题稍有不同。我们的任务是要决定类和对象的物理打包,这与子程序是明显不同的。
经验表明,有一些技术上和非技术上的指导方针可以帮助我们实现对类和对象的明智的模块化。Britton和Parnas说:“分解为模块的总体目标是通过允许模块独立地设计和修改,从而减少软件的成本……每个模块的结构都应该足够简单,这样它就能被完全理解。应该能够在不知道其他模块的实现方法,并不会影响其他模块的行为的情况下,修改某个模块的实现。修改设计的容易程度应该能够满足需要变更的可能性。”[61]这些指导方针具有实践意义。在实践中,编译一个模块的成本相对来说是很小的:只有一个单元需要重新编译,然后重新链接应用。但是,重新编译模块接口的成本相对是较高的。特别是对于强类型的语言,开发者必须重新编译模块接口、模块实现及其他所有依赖该接口的模块。因此,对于很大型的程序来说(假定我们的环境不支持增量编译),对一个模块接口的改动可能导致长得多的编译时间。显然,开发经理不能忍受这种巨大的、“大爆炸式”的重新编译经常发生。出于这个原因,模块的接口应该尽可能小,而又满足其他用到它的模块的需要。我们的风格是将尽可能多的东西隐藏到模块的实现中。与重写大量无关的接口代码相比,增量地将声明从模块的实现移到它的接口要轻松得多、稳定得多。
开发者必须平衡两种竞争的技术考虑:封装抽象的愿望以及让其他模块看到某些抽象的需要。“可能独立变化的系统细节应该成为独立模块的秘密,模块之间存在的假定只能是那些不太可能变化的东西。每个数据结构对于一个模块来说都是私有的,它可能被这个模块中的一个或几个程序访问,但模块外的程序不能访问它。任何其他程序,如果需要保存一个模块的数据结构中的信息,只能通过调用这个模块的程序获得。”[62]换言之,努力创造出高内聚(将逻辑上相关的抽象放在一起)、低耦合(减少模块间的依赖关系)的模块。从这个角度出发,我们可以这样定义模块化:
“模块化是一个系统的属性,这个系统被分解为一组高内聚、低耦合的模块。”
因此,抽象、封装和模块化的原则是相辅相成的。一个对象围绕单一的抽象提供了一个明确的边界,封装和模块化都围绕这种抽象提供了屏障。
另外有两个技术问题可能影响模块化的决定。首先,由于模块通常是软件的基本可分割单元,可以跨应用复用,所以开发者可能以方便复用的方式对类和对象进行打包。其次,许多编译器以分段的方式产生目标代码,每个模块生成一段。因此,对单个模块的规模可能有实际的限制。考虑到子程序调用的机制,模块中声明的位置可能在很大程度上影响引用变量的局部性,从而影响到虚存系统的分页行为。较差的局部性是指发生子程序跨段的调用,导致没有命中缓存并发生换页颠簸,最终降低了整个系统的执行速度。
还有一些竞争的非技术需求也可能影响模块化决定。通常,开发团队是根据模块来分配工作的,所以建立模块边界时要尽量减少开发组织中不同部分之间的接口。经验丰富的设计师通常负责模块的接口,经验较少的开发者完成模块的实现。从更大的范围来说,同样的情形也适用于合同分包的关系。我们可以对抽象进行打包,以便快速地稳定模块的接口,在不同公司之间达成一致意见。改变这样的接口通常引起许多悲叹和愤怒(别说还有许多纸面工作要做),所以这一原因通常导致保守设计的接口。谈到纸面工作,模块通常作为文档和配置管理的单元。有10个模块,也许就有人要做10倍的纸面工作,所以不幸的情况发生了,有时候文档方面的要求会影响模块设计的决定(通常是以最为消极的方式)。安全性也可能成为问题。大多数代码可能是非保密的,但最好将那些可能需要保密的代码放到一个独立的模块中。
对付这些不同的需求很困难,但不要忽略了最重要的一点:发现正确的类和对象,然后将它们放到不同的模块中,这基本上是独立的设计决定。类和对象的确定是系统逻辑设计的一部分,而模块的确定是系统物理设计的一部分。我们不能在物理设计之前完成所有逻辑设计,反之亦然。设计决策是以一种迭代的方式进行的。
模块化的例子
让我们来看看水培园艺系统中的模块化。假定我们决定使用一个商业产品工作站,让用户通过它来控制系统的操作。在这个工作站中,操作者可以创建新的培育计划,修改老的培育计划,跟踪当前执行的培育计划。由于这里的关键抽象是培育计划,所以可能创建一个模块,目的是将所有与单个培育计划有关的类放在一起(如水果培育计划、谷物培育计划)。这些培育计划类的实现将包含在这个模块的实现中。我们可能还会定义一个模块,将所有与用户界面功能相关的代码放在一起。
我们的设计可能还包含许多其他模块。最后,必须定义某个主程序,通过它来调用这个应用。在面向对象的设计中,定义这个主程序常常是最不重要的设计决策;而在传统的结构化设计中,这个主程序是根,是把所有东西聚在一起的基石。我们认为面向对象的方式更为自然,因为正如Meyer所说的,“将实际的软件系统描述为一组服务更为合适。用单个函数来定义这些系统通常是可行的,但这种做法显得太造作……真正的系统是没有顶的。”[63]
2.3.4 层次结构的意义
抽象是个好东西,但是除了那些太简单的应用之外,所有应用中都会包含许多不同的抽象,我们不能够一下子就理解它们。通过隐藏抽象的内部视图,封装有助于管理这种复杂性。但是,这还不够。一组抽象常常构成一个层次结构,通过在设计中确定这些层次结构,可能极大地简化对系统的理解。
我们将层次结构定义为:
“层次结构是抽象的一种分级或排序。”
在复杂系统中,最重要的两种层次结构是它的类结构(“是一种”层次结构)和对象结构(“组成部分”层次结构)。
1.层次结构的例子:单继承
继承是最重要的“是一种”层次结构,前面曾提到,它是面向对象系统的基本要素。继承基本上定义了类之间的关系,在这种关系中,一个类共享了一个或多个类(分别对应于单继承或多继承)中定义的结构或行为。继承因此代表了一种抽象的层次结构,在这个层次结构中,一个子类从一个或多个超类中继承。一般来说,子类会扩展或重新定义超类中的结构和行为。
从语义上说,继承表明了“是一种”关系。例如,熊“是一种”哺乳动物,房屋“是一种”有形资产,快速排序“是一种”具体的排序算法。继承因此实现了一种“一般/具体”的层次结构,其中子类将超类的一般结构和行为具体化。实际上,这也是继承的判据:如果B不是一种A,那么B就不应该从A继承。
考虑在水培园艺系统中用到的不同类型的培育计划。前面我们描述了非常一般化的培育计划的抽象。然而,不同类型的作物需要特殊的培育计划。例如,对所有水果的培育计划一般比较相似,但与蔬菜或花卉作物的计划相比就有很大差别。由于抽象的这种分群特性,我们可以合理地定义一个标准水果培育计划,封装所有水果共同的行为,诸如何时授粉以及何时采摘等知识。可以断定,水果培育计划“是一种”培育计划。
在这种情况下,水果培育计划是更特殊的,培育计划是更一般的。谷物培育计划和蔬菜培育计划也是如此,即谷物培育计划“是一种”培育计划,蔬菜培育计划“是一种”培育计划。这里,培育计划是更一般的超类,其他的是特殊化的子类。
当发展继承层次结构时,不同类中共同的结构和行为会被迁移到共同的超类中。这就是常把继承称为一般/特殊层次结构的原因。超类代表了一般化的抽象,子类代表了特殊的抽象,会添加、修改甚至隐藏来自超类的属性和方法。通过这种方式,继承让我们以一种经济的方式表达我们的抽象。实际上,忽略“是一种”层次结构将会导致膨胀的、不优雅的设计。“没有继承,每个类都会是一个独立的单元,每个都要从头开发。不同的类相互之间没有关系,因为每个类的开发者都根据他自己的选择来提供方法。所有跨类的一致性都源自于程序员的训练有素。通过比较新的概念和已经熟悉的概念,继承让我们能够定义新的软件,这就像对新来的同事介绍某些概念一样。”[64]
抽象构成了一个层次结构
在抽象、封装和层次关系之间存在一种健康的压力。“数据抽象试图提供一个透明的边界,在这个边界之后,方法和状态是隐藏的。继承要求将这个接口开放到一定程度,允许不通过抽象来访问状态和方法。”[65]对于某一个类,通常有两种客户:调用该类实例的方法的对象以及从这个类继承的子类。Liskov因此指出,通过继承,封装可以通过三种方式被打破:“子类可能访问其超类的实例变量,可以调用其超类的私有操作,或者直接引用其超类的超类”[66]。在支持封装和继承方面,不同的程序设计语言以不同的方式进行了折中。C++和Java提供了最大的灵活性。具体来说,类的接口可以有三个部分:私有部分,声明只能够由该类本身访问的成员;保护部分,声明可以由该类及其子类访问的成员;公有部分,可以让所有客户访问。
2.层次结构的例子:多继承
前一个例子展示了单继承的应用:水果培育计划子类只有一个超类,即培育计划类。对于某些抽象,提供从多个超类的继承是有用的。例如,我们选择定义一个类来代表一种植物。对这个问题域的分析表明,花卉植物、水果和蔬菜具有一些特殊的属性,这些属性和我们的应用有关。例如,对于一种开花植物,预估它开花的时间和结籽的时间可能对我们很重要。类似地,对于所有水果和蔬菜,采摘的时间可能是抽象中的重要部分。为了体现我们的设计决策,一种办法是设计两个新类——一个花卉(Flower)类和一个果蔬(FruitVegetable)类,它们都是植物(Plant)的子类。但是,如果需要对一种既开花,又结果的植物进行建模呢?例如,种花人常常采用苹果、樱桃和李子的花。对于这种抽象,需要设计第三个类,即花卉果蔬类(FlowerFruitVegetable),它复制了来自花卉类和果蔬类的信息。
所以,这种抽象更好的表达方式是利用多继承。首先,我们创建两个类分别表示花卉植物、果蔬特有的属性。这两个类没有超类,它们是独立的。它们被称为“混入类(mixin class)”,因为它们将与其他类混合在一起,得到新的子类。
例如,我们可以定义一个玫瑰(Rose)类(参见图2-10),它继承自植物(Plant)类和花卉混入(FlowerMixin)类。所以,子类Rose的实例可以既包含来自植物类的结构和行为,也包含来自花卉混入类的结构和行为。
图2-10 从多个超类继承而得的Rose类
类似地,胡萝卜(Carrot)类可以如图2-11所示的那样。在这两种情况下,都从两个超类继承而得到子类。
图2-11 从多个超类继承而得的Carrort类
接下来,假定我们希望为樱桃树这样的植物定义一个类,它既有花也有果实,如图2-12所示的那样。
图2-12 从多个超类继承而得的CherryTree类
多继承在概念上很简单,但是它为编程语言引入了一些实际的复杂性。语言必须关注两个问题:来自不同超类的名字的冲突以及重复继承。当两个或多个超类提供了同样名字的属性或操作时,就会发生名字冲突的情况。
当两个或多个同辈超类具有共同的超类时,就会发生重复继承的情况。在这种情况下,继承的结构形成一个菱形,所以问题出现了,叶子类具有共同超类结构的一份副本还是多份副本?(参见图2-13)。某些语言禁止重复继承,某些语言单方面地选择了一条路径,而另一些语言,如C++,则允许由程序员来决定。在C++中,虚基类用于说明一个共享的重复结构,而非虚基类则导致子类中出现多个副本(通过显式的限定符来区分不同的副本)。
图2-13 重复继承问题
多继承常常被滥用。例如,棉花糖是一种糖,但显然不是一种棉花。同样,可以应用继承的判别测试:如果B不是一种A,那么B就不应该从A继承。有可能的话,应该将错误的多继承结构缩减为一个超类加上其他类的聚合。
3.层次结构的例子:聚合
“是一种”层次结构说明了一般/特殊关系,而“组成部分”层次结构则描述了聚合关系。例如,考虑种植园的抽象可以认为种植园包含了一些植物和一份培育计划。换言之,植物是种植园的“组成部分”,培育计划也是种植园的“培育计划”。这种“组成部分”关系称为聚合(aggregation)。
聚合不是面向对象开发或面向对象编程语言所特有的概念。实际上,只要是支持类似记录结构的语言都支持聚合。但是,将继承和聚合结合在一起的功能是强大的:聚合允许对逻辑结构进行物理分组,而继承允许这些共同的部分在不同的抽象中被复用。
在处理这样的层次结构时,常常提到抽象的层次,这个概念最早是由Dijkstra[67]提出来的。对于“是一种”层次结构,高层的抽象是一般的,低层的抽象是具体的。因此,我们说花卉类的高层抽象是植物类。对于“组成部分”层次结构,一个类相对于组成它的实现的类来说,处于更高的层次。因此,种植园是植物类的更高层抽象。
聚合提出了所有权的问题。我们关于种植园的抽象允许不同的时间在种植园中培育不同的植物,但换一种植物不会改变种植园作为一个整体的特征,删除一个种植园也不会摧毁它的所有植物(它们可能被移植)。换言之,种植园和它的植物的生命周期是不相关的。与此不同的是,我们认为培育计划对象与种植园对象有着固有的关系,不能够独立存在。因此,当创建一个种植园的实例时,也创建了一个培育计划实例,当删除种植园对象时,也会删除培育计划实例。
2.3.5 类型的意义
类型的概念主要来自于抽象数据类型的理论。Deutsch指出,“一个类型是关于结构或行为属性的准确描述,一组实体共享这些属性”[68]。为方便起见,我们互换地使用术语类型(type)和类(class)。[2]虽然类型和类的概念相似,我们仍然把类型作为对象模型的一个独立要素,因为类型的概念对于抽象的含义有着特别不同的强调。具体来说,我们认为:
“类型是关于一个对象的类的强制规定,这样一来,不同类型的对象不能够互换使用,或者至少它们的互换使用受到非常严格的限制。”
类型让我们表达我们的抽象概念,这样实现这些抽象概念的语言就可以执行这些设计决策。
某种编程语言可以是强类型、弱类型甚至无类型的,但都可以被称为是面向对象的。例如,Eiffel 是强类型的,这意味着类型匹配是严格保证的——除非某个操作的签名在对象的类或超类中被定义过,否则是不能调用这个操作的。
类型匹配的概念是类型概念的核心。例如,考虑物理测量单位[71]。当用距离除以时间时,得到的值代表的是速度,而不是重量。类似地,用力的单位去除以温度是没有什么意义的,但是用力去除以质量是有意义的。这些都是强类型的例子,其中我们的领域规则规定并强制保证某些合法的抽象组合。
强类型防止将抽象弄混
强类型让我们可以利用编程语言来强制保证某些设计决策,这样,当我们的系统的复杂度增长时,仍能保持特定的关联。但是,强类型也有不利的一面。从实践上来看,强类型引入了语义上的依赖关系,这样,即使是基类接口上的一个小改动,也需要重新编译所有的子类。
对于这些问题,有两种一般的解决办法。首先,可以使用一个类型安全的容器类,它只操作某一个类的对象。这种方法解决了第一个问题,即不同类型的对象被错误地混用。其次,可以使用某种运行时刻的类型标识,这解决了第二个问题,即知道当前处理的是哪一种类型的对象。但是一般来说,只有在有很好的理由时才会使用运行时刻的类型标识,因为它可能意味着削弱封装。稍后将会讨论,利用多态常常(但并非总是)可以缓解运行时类型标识的需要。
Tesler指出,使用强类型的语言有一些重要的好处:
■ 如果没有类型检查,大部分语言的程序可能在运行时以神秘的方式“崩溃”;
■ 在大多数系统中,编辑—编译—调试循环相当烦琐,所以早期的错误检查是必不可少的;
■ 类型声明有助于为程序编写文档;
■ 如果声明类型,大部分编译器可以生成更有效率的目标代码。[72]
无类型的语言提供了更大的灵活性。但是,即使是无类型的语言,正如Borning和Ingalls所说的,“在大多数情况下,程序员实际上知道消息的参数需要哪种对象,以及会返回哪种对象”[73]。在实践中,强类型语言所提供的安全性常常能够补偿不使用无类型语言所带来的灵活性损失,特别是在大规模编程中。
类型的例子:静态类型和动态类型
类型的强与弱和类型的静态与动态的概念是完全不同的。类型的强与弱指的是类型一致性,而类型的静态与动态指的是名字与类型绑定的时间。静态类型(也称为静态绑定或早期绑定)意味着所有变量和表达式的类型在编译时就固定了,动态类型(也称为延迟绑定)意味着所有变量和表达式的类型直到运行时刻才知道。一种语言可以既是强类型的也是静态类型的(Ada),既是强类型的也支持动态类型(C++、Java),或者既是无类型的也支持动态类型(Smalltalk)。
多态(polymorphism)是动态类型和继承互相作用时出现的一种情况。多态代表了类型理论中的一个概念,即一个名字(如一个变量声明)可以代表许多不同类的对象,这些类具有某个共同的超类。这个名字所代表的对象因此可以响应一组共同的操作[74]。与多态相对的是单态(monomorphism),这在强类型和静态类型的语言中都可以发现。
多态可能是面向对象语言中除了对抽象的支持以外最强大的功能,也正是它,区分了面向对象编程和较传统的抽象数据类型编程。在接下来的章节中可以看到,多态也是面向对象设计中的核心概念。
2.3.6 并发的意义
对于某些类型的问题,一个自动化的系统可能同时处理许多不同的事件。另外一些问题则可能需要很多的计算,超出了单个处理器的能力。在这些情况下,很自然要考虑利用一组分布式的计算机来实现目标,或者利用多任务。每个程序至少都有一个控制线程,但涉及并发的系统可能有许多这样的线程:某些线程是短暂出现的,而另一些线程会伴随系统执行的整个生命周期。跨多个CPU执行的系统允许真正并发的控制线程,而运行在单CPU上的系统只能够实现模拟的并发控制线程,这通常是利用某种时间片算法来实现的。
并发允许不同的对象同时行动
我们也区分重量级并发和轻量级并发。重量级进程通常是由目标操作系统独立管理的,所以它有自己的地址空间。轻量级进程通常与其他轻量级进程一起处于单个操作系统进程之内,共享同样的地址空间。重量级进程之间的通信开销很大,涉及某种进程间通信技术;轻量级进程开销较小,通常涉及共享数据。
构建一个大型软件已经够难的了,设计一个包含多个控制线程的大型软件更是难得多,因为设计者必须考虑死锁、活锁、饥饿、互斥和竞争条件等问题。“在最高层次的抽象中,通过将并发隐藏在可复用的抽象中,OOP可以减轻大多数程序员在并发问题上的负担。”[76] Black等人因此建议,“对象模型适合于分布式系统,因为隐式地定义了发布和移动的单元以及实体的通信”[77]。
虽然面向对象编程关注数据抽象,但是封装、继承和并发关注了过程抽象和同步[78]。对象是统一这两种不同观点的概念:每个对象(来自于真实世界的一个抽象)都可以代表一个独立的控制线程(一种过程抽象)。这样的对象被称为“主动的”。在基于面向对象设计的系统中,我们可以将世界概念化为一组协作的对象,其中某些是主动的,因此作为独立活动的中心。出于这个概念,我们将并发定义如下:
“并发是一种属性,它区分了主动对象和非主动对象。”
并发的例子
考虑一个名为ActiveTemperatureSensor的传感器,它的行为要求定期测量当时的温度,然后通知客户与设定值相比,温度是否改变了一定的数值。我们不解释这个类是如何实现这种行为的。实际情况是实现的秘密,但很清楚,需要某种形式的并发。
一般来说,在面向对象的设计中有三种并发方式。其一,并发是某种编程语言的内在特征,语言提供了并发和同步的机制。在这种情况下,可以创建一个主动对象,它与其他主动对象一起并发执行某些处理过程。
其二,可以使用一个类库来实现某种形式的轻量级进程。自然,这种实现是高度平台相关的,虽然这个库的接口相对来说也许是可移植的。在这种方式中,并发不是语言的内在特征(因此不会在非并发系统上增加任何负担),但通过这些标准类,并发看起来似乎是内在的特征。
第三,可以利用中断来实现并发的假象。当然,这要求我们具有某些底层硬件细节的知识。例如,在ActiveTemperatureSensor类的实现中,可能有一个硬件时钟,它定期中断应用。在中断发生的时候,所有这类传感器读取当时的温度,并根据需要调用回调函数。
不论采用哪种方法实现并发,都必须注意这样一个事实:当在一个系统中引入并发时,必须考虑主动对象之间、主动对象与纯粹串行执行的对象之间,如何同步它们的活动。例如,如果两个主动对象试图给第三个对象发送消息,必须确保使用了某种互斥手段,这样,被调用对象的状态才不会因两个主动对象试图同时更新它而被破坏。这是同时使用封装、抽象和并发时必须注意的要点。在并发的情况下,仅定义对象的方法是不够的,还必须确保这些方法的语义在多个控制线程的情况下仍然有效。
2.3.7 持久的意义
软件中的一个对象会占用一定量的空间,在一定的时间内存在。Atkinson等人指出,对象的存在有一个区间——从计算对象表达式时产生的瞬时对象,到保存在数据库中、超过程序执行时间的对象。这个对象持久的谱系包括:
■ 表达式计算的瞬时结果;
■ 过程执行时的局部变量;
■ 自有变量(像ALGOL 60中那样)、全局变量、堆中的值,它们的存在时间与它们的有效范围不同;
■ 在程序执行之间存在的数据;
■ 在程序的不同版本之间存在的数据;
■ 比程序生命期长的数据[79]。
传统编程语言通常只关注前三种类型的对象持久,后三种类型的持久通常是数据库技术关注的领域。这导致了一些文化上的冲突,有时候形成了非常奇怪的架构:程序员会弄出比较随意的方案来存放对象,这些对象的状态在程序执行之间必须被保存,而数据库设计者错误地应用他们的技术来处理一些临时对象[80]。
关于Atkinson等人的“比程序生命期长的数据”,有一种有趣的变化,即在Web应用中,在整个事务执行过程中,应用可以不连接所使用数据。在和数据源断开连接时,提供给客户应用或Web服务的数据可能发生怎样的变化?这两者之间的问题如何解决?类似微软的ActiveX Data Object for .NET(ADO.NET)这样的框架被提了出来,以帮助解决这种分布式的、断开连接的情况。
统一对象和并发的概念导致了并发的面向对象程序设计语言。与此类似,在对象模型中引入持久的概念导致了面向对象的数据库。在实践中,这样的数据库是建立在已经证明的技术之上的,如序列化、排序、层次数据库、网状数据库或关系数据库模型。但是,它为程序员提供了一种面向对象接口的抽象,数据库查询和其他操作都是通过这个接口,以对象的方式完成的,这些对象的生命周期超过了单个程序的生命周期。这种统一极大地简化了某些类型应用的开发。具体来说,它允许我们对数据库和应用的非数据部分使用同样的设计方法。
持久跨越时间或空间保存了一个对象的状态和类
某些面向对象编程语言提供了对持久的直接支持。Java提供了Enterprise Java Beans(EJB)和Java Data Object。Smalltalk有一些协议,将对象序列化到存储设备,或者从存储设备中取出并恢复对象(对于子类,要重新定义序列化的方法)。但是,将对象序列化到普通文件中只是比较初级的持久解决方案,因为这不适合处理大量对象的情况。持久可以通过一些商业面向对象数据库来实现[81]。更典型的持久方法是为关系型数据库提供一层面向对象的皮肤。开发者可以自行定义对象-关系映射。但是,这也是一项很有挑战性的任务。有一些框架简单化了这项任务,如开放源代码的Hibernate[85]。商业的对象-关系映射软件也是有的。如果已经在关系型数据库上投入了很多资金,将其替换掉会有很大风险或者代价昂贵,那么这种方法是最有吸引力的。
持久解决的不只是数据的生命周期问题。在面向对象的数据库中,不仅是对象的状态会被持久,对象的类也会超越单个应用而存在,这样,每个程序都会以同样的方式来解释这种保存的状态。随着数据库的增长,这显然使得保持数据库的完整性变得很有挑战性,如果必须改变一个对象的类,更是如此。
到目前为止,我们的讨论均与时间上的持久有关。在大多数系统中,一个对象被创建之后会使用同样的物理地址,直到它不存在为止。但是,对于在一组分布式的处理器上执行的系统来说,有时候必须考虑跨空间的持久。在这样的系统中,考虑对象从一台计算机移动到另一台计算机是有意义的,它在不同计算机上甚至会有不同的表现形式。
综上所述,我们这样定义持久:
“持久是对象的一种属性,利用这种属性,对象跨越时间(例如,当对象的创建者不存在了的时候,对象仍然存在)和空间(例如,对象的位置从它被创建的地址空间移开)而存在。”