本节书摘来自华章计算机《基于模型的软件开发》一书中的第2章,第2.1节,作者:[美]H. S.莱曼(H. S. Lahman)著, 更多章节内容可以访问云栖社区“华章计算机”公众号查看。
2.1 基本理念
OO范式较之以前的软件开发方法更加复杂精密。从硬件计算的角度我们并没有直观感受,因此需要一种独特的思想。该范式也是由很多不同的、独立的概念整合而成。在这里我们将明确这些与第二部分和第三部分密切相关的基本概念。
2.1.1 可维护性
在20世纪70年代,关于软件开发的实证研究很多,从中可以归纳出以下两个惊人的结论:
大部分软件公司70%的工作量用于维护。
用于修正现有特性的工作量是最初开发工作量用的5~10倍。
这中间显然存在问题。20世纪70年代后期这一现象演变成了软件危机,软件工程学科被引入来处理这些问题。当时,软件工程主要致力于修复结构化开发方法以解决问题。如果每一个人都用对了SD,那么所有的问题都将解决。
在1980年之后,OO范式的主要目标变成了提高应用的可维护性。OO技术的大师们发现了问题的根源(在第1章中有所论述),并确定修改结构化开发方法是不可行的。因为有一些系统性的问题,例如:功能分解、与硬件计算模型紧密连接等,这些并不能通过“创可贴”式的修复方式来解决。毫无疑问,OO技术的大师们将OO范式视为一条走出可维护性荒野的通路。因此OOA/D方法学追求保证可维护软件的开发。
可维护性是OOA/D的主要目标。
有人曾转述Vince Lombardi的观点——可维护性是唯一的目标。原始开发的生产率几乎处于目标列表中的最底层,另一方面,维护的生产力在列表中的位置是很靠前的。可靠性同样名列前茅,因为传统的维护需要这么多工作量的主要原因在于软件的变更非常复杂,会引入缺陷。现代OOA/D的重点全部集中在了提供鲁棒性更好、可维护且可靠的应用上。
作为一种必然的结果,OO范式的一个基本假设是:在其生命周期中,软件产品的需求是易变的。如果不是出于这个假设,那么在科学编程领域,OO范式应该是很差的一种选择。
2.1.2 问题域抽象
没有人喜欢变化。
软件开发人员不喜欢变化,因为这意味着他们需要维护遗留代码而不是迎接全新挑战。恰好,软件用户也不喜欢变化,因为一旦发生变化,他们就需要适应新软件,使现存基础设施(例如:过程、实践、策略、标准、技术,还有其他日常操作)上的中断最小化。
如果软件结构与用户域结构相似,变化以新需求的形式应用于软件时,比较容易被接受。
这是OO范式的一个基本假设。基本上,可以假设所有变化均起源于用户域,而且必须在为现有软件定义新需求之前处理。用户将通过阻力最小的方式去适应这些用户域的变化。因此,在将变化完全反映到软件之前,用户已经完成了很多工作,以求在整体上适应这些变化。如果软件结构能够准确反映用户域的结构,那么用户就可以完成开发人员的部分工作。
这些关于变化的概念可以解释为什么OO技术中最鲜明的一个特性是问题域抽象。没有任何一种软件构造方法学对抽象的运用达到OO范式的程度,并且OO范式是唯一一种强调在非计算域运用抽象技术的。
OO范式的主要目标是在软件结构中模拟用户域结构。
用户域很少直接对应计算域,因而用户域不以硬件计算模型为基础。它们在概念上极其丰富多样,也很复杂,复杂程度远远超过典型的计算环境。这种复杂性、多变性、多样性打消了任何软件对其进行真实模拟的念头。因此,我们需要某些方法简化用户视图,并将重点放在能解决当前特定问题的那些非常重要的结构化元素上。
在这个过程中要使用的设计工具就是抽象。抽象在OOA/D中无处不在,是获取用户域结构的主要工具。稍后,我们在本章中将重新回到问题域抽象,对其机制进行概述,然后在本书其他部分对其进行进一步阐述。
2.1.3 OOA、OOD和OOP
在第1章中值得注意的是,结构化开发引入了软件开发过程中的两个新步骤,即分析和设计。其目的在于提供用户域与计算域之间的“阶梯”,在将用户需求转为软件实现的过程中,尽可能地减少信息的损失。OO范式提供了相似的“阶梯”,只是在分析上的着力点有所不同。
在结构化开发中,分析是高层次软件设计和问题分析的一种综合。很多结构化开发的作者将其定位为问题分析,即决定要解决的真正问题是什么。OO范式认为收集和分析需求时就应当进行问题分析。问题分析只是需求规格说明的前提,因为除非知道真正的问题是什么,否则我们不能为一个解决方案指定需求。因此面向对象的分析OOA完全不同于结构化设计的分析。
OOA为问题的功能性需求提供了一个独立于特定计算环境的完整解决方案。
在OO范式中,OOA呈现用户视图的解决方案,因为问题域抽象所表达的内容是根据用户域结构产生的。OOA基于用户域表达,因此它只能解决功能需求。(解决非功能需求,例如:尺寸、性能、安全等需求都必须依靠特定解决方案的实现方式)。同样的,OOA独立于应用实现的特定计算环境。因此OOA才是表示问题域解决方案的真正“阶梯”,更容易反映用户需求。因为我们所使用的表示法基于与计算域相同的基本数学运算,所以用户视图很容易整合进计算方案中。
OOD(Object Oriented Design,面向对象的设计)是对OOA解决方案的进一步完善,为特定计算环境在战略层面上解决了非功能需求问题。
面向对象的设计和结构化开发的设计略有不同,但是区别不大。OOD是OOA解决方案的完善,在战略层次上对非功能需求进行明确的处理。它描述了解决方案在高层次的设计视图,该解决方案与本地计算环境的基本特征相适应。
OOP(Object Oriented Programming,面向对象的编程)则是对OOD的进一步完善,可以在3GL层次上提供所有需求的战术解决方案。
面向对象的编程解决战术层面上的需求(例如,特定语言、网络协议、类库、技术等需求)。
值得注意的是,OOA、OOD和OOP在定义上比SD下的类似概念更加严格。更重要的是,三者有明确的区分点,即应对功能需求或非功能需求、针对用户域或计算域。这对于管理复杂的大型应用十分有利。
2.1.4 主题
所有的OO产物,包括子系统、对象和职责,均代表某些问题域中的可识别主题,而主题这个词很难定义。字典仅仅告诉我们定义是有待考虑和讨论的东西,具体含义依旧模糊。但是,这个简单的定义确实捕获了在思考抽象的主题是什么时的想法。它还捕捉到了在开发抽象过程中团队达成共识的通用实践。但对于含义丰富的概念来说,这个定义还是太简短了。
主题定义和界定了一件正在被抽象的事情,这件事情可以是有形的或者无形的。
就其通用性来讲,这个定义较之字典中定义,更加关注OO范式。问题是,定义必须能够很容易地映射到潜在的、无限多的用户问题域中。至少这个定义引入了抽象需要有明确的界限这一重要概念,这对功能隔离、封装和关注点分离来说都是十分重要的。在这一概念与具体对象相关时,为了能够提供对这个概念更深入的了解,从以下特征来对主题进行描述是十分有用的。
具有与当前问题相关的一组内聚的内在职责。
由一组特定的规则和策略刻画。
在一个特定的抽象层次上进行定义。
很容易被问题域专家所识别。
问题域专家能够很好地对其边界进行定义。
在抽象层次的上下文中逻辑不可分(在下面的内容中我们会更多地对逻辑不可分性进行说明)。
我们不会为主题进行建模或者编写代码,主要是因为在定义时有难度和二义性。相反,我们使用了解事情的义务或者做事情的义务这种措辞来进行描述。如果接触子系统、对象或者职责的人对于主题没有清晰一致的了解(例如:它是什么?),那么就必然会有麻烦。在应用中,如果能够为一件事情提供仅一个精细制作的外部文档,那么该文档应该就是主题。
2.1.5 关注点分离
实际上在OO范式中这是一个非常重要的概念。关注点分离的基本思想是在复杂性的管理中采取分而治之的方式。基本上,我们在管理复杂性的过程中把复杂的事情分解成更易于处理的小问题。这绝对不是OO范式的原创,结构化开发中至关重要的功能分解采用的是同样的思路。OO范式为表提供的是分解和各种逻辑不可分的视图之间特有的结合,逻辑不可分性由主题不同的抽象层次来表现。
相对于结构化开发,操作序列二维的、分层的功能分解中,OO范式能为我们提供更多管理关注点分离的工具。从实践角度出发,在OO范式中,关注点分离包括以下四个基本要素。
1)内聚性:关注点是一个内聚的概念,有着明确的范围和界限。因此在大多数企业中,“库存管理”是一个内聚概念。
2)主题:在问题域中,我们可以把关注点与具体的、可识别的主题关联。有一个包含规则、策略、数据等在内的主体与“库存管理”主题相关联。虽然是概念化的,但关注点更容易识别,任何领域的专家都能理解主题的语义。
3)封装:我们可以用标准的OO技术对这些关注点进行封装。就“库存管理”而言,规模决定了在应用层次还是子系统层次进行封装。
4)去耦合:我们让主题相互独立并减少主题之间工作原理细节的依赖。
值得注意的是,在方法学层面上,关于OOA,OOD和OOP的定义是关注点分离的一个范例。OOA和OOD之间的不同将对功能需求和非功能需求隔离开,同时也隔离开了对用户域与计算域的关注。关注点分离适用于所有层次的职责。正如本书以下内容所阐述的,职责必须是内聚的、逻辑不可分的、内在的。换句话说,它们描述了一种特定的关注点分离。
2.1.6 抽象层次
早在OO范式出现之前,产品分层的思想早已开始遵循抽象层次的理念了。低层次呈现的是细节化的具体行为,而高层次反映的是一般的、抽象的广义行为。结构化开发的功能分解提供了一个类似的抽象层次顺序的概念。
如图2-1所示,在软件构造中,自3GL程序模型以来出现了有一系列抽象观点,如图2-1所示。自上而下,随着图灵机细节的补充,抽象的程度由高到低,从OOA到OOPL代码中的类属性也倾向于从抽象的问题域,例如用户,到具体的计算域,例如字符串。
这些阶段代表了不同的视图。OOA表示解决方案的用户域并且只解决功能需求问题,而OOD的重点则是解决计算域中的非功能需求问题。同样,问题的加载器视图虽然缺乏用户语义,但是提供了一个贴近硬件计算模型且易于在软件中实现的视图。由于OO范式在很大程度上以抽象为基础,因此我们应该将这一点作为一种优势。恰好,OO范式常常利用抽象层次来辅助完成很多工作,从定义整体的应用结构到管理依赖。
图2-1 典型OO开发过程中的抽象层次:图中自上而下抽象递减,计算域细节递增。每一个阶段提供实际计算机指令的一个模型。这些模型均包含下一个低抽象层次问题解决方案的规格说明。与此同时,每一个阶段的输出都是上一个阶段模型规格说明的解决方案
也许最重要的应用在于定义整体的程序结构。虽然最初的OO范式提供了很多工具和思想,但是其中MBD比其他任何OO方法论都更加精确地提炼了抽象的概念。关于MBD本书第6章节将加以详述。眼下只需要知道一个子系统定义了一个抽象层次,所有用于实现这个子系统的对象都应该在这一层次抽象。
2.1.7 问题域抽象
OO方法学的核心是在某些用户域对于其中实体的标识和抽象。这些实体必须是易于标识的,要么是具体的,例如猫、房子、人,要么是可概念化的,例如合同、法律、义务。当然,大部分用户域中的实体都是很复杂的,包含各种属性或者特征,但是在用户眼中这些特征与实体的相关性也是明显的。
问题域抽象的主要任务是识别当前问题域中的实体及其相关属性。
我们称这种实体的抽象为对象。因此对象的基本概念,即一些可识别实体及其相关属性,是问题域抽象的直接结果,用于对用户域结构进行模拟。
如果我们在未删减的字典中查找抽象,会找到三种不同的定义。第一个定义是提炼特征;第二个定义是对于相似具体事物共同本质的一般归纳;第三个定义为总结。OO范式中,抽象的概念包含了以上所有定义。最后,抽象会通过以下五种方式改变现实。
1)消除特征:真正的实体通常具有大量特征。通常,大部分特征与目前问题不相关。打个比方,当一个捕狗人面对低声咆哮的狗,了解狗是否经过训练只会分散捕狗人的注意力而已。
2)不强调特征:另一种简化抽象的方式就是不强调特征,而不是将特征全部删除。用于在相同门类中识别不同动物的标准可能是相当复杂的(例如,恒温的、卵生的等)。如果不在乎分类方案的细节,我们有单个属性即可,例如:生物类型,并举例“鸭嘴兽”或“土豚”。这样做时,我们不强调整个分类方案的上下文而仅仅强调单个类型描述符。
3)概括归纳特征:有时,个别情况下的特征细微变化对于目前问题不重要。在这种情况下,我们希望在应用上概括归纳特征以消除不同,即“求同存异”。例如,我们要打印生活在美国南部的各种蛇类的图片,并不用把它们分为三大类来进行介绍,其实只要告诉人们哪些有毒哪些没毒就足够了。
4)强调:抽象可以帮助我们选择部分用户问题域实体并有针对性地加以强调。一个过程的度量,例如缺陷率,通常会存在很大范围的值,如果进行统计过程控制,我们关心的只是某个值是否在预定义的范围之内。在这种情况下,我们可以为缺陷率样本设置一个布尔属性,布尔值表示可控或者不可控。当然,我们依然会看到缺陷率的值,但是对于度量非常重要的一点被强调了。
5)合并特征:抽象不仅可以应用于对象,还可以应用到这些由抽象本身组成的特征上。在这里,扩展泛化的概念可以捕获多个特征,作为单个抽象特征。最经典的例子是复数的数学概念,其中包含了实部和虚部的部分。于是,大部分问题解决方案仅需用复数描述即可,而不用再去单独分别处理其中的实部和虚部。在这种情况下,将复数抽象成OOA/D模型中的单个标量“值”是非常恰当的。
关于抽象的最后一个问题是它从何而来。最简单的答案是:源于问题域。抽象代表问题域中的实体。接下来的问题是:什么是实体?下面的定义会非常有用。
实体:实体是一个真实事物,虽然可能是无形的,但它在特定的问题域中很容易被专家们识别。
我们进行抽象的实体可能是存在于问题域的具体事物、概念、角色或是其他任何东西。最关键的是,问题域大师认为它是内聚的、可识别的事物。
所有的实体及对象的抽象都具有唯一标识。
根据以上概念有一个推论,即专家们认为所有的实体都是独特的。实体必须是唯一可识别的,每一个对象都从问题域中抽象出这样一个实体。这样,这些标识可以方便地映射到更加严谨的集合论中,这些理论是构成OO范式和UML的基础。
当我们有满满一箱6-32X1规格螺钉头存货的时候该怎么办?每个螺钉都给一个名字?这很显然是不可能。当然特定的螺钉仍有标识,使它能轻易地从箱中准确取出。数据库管理系统将为每一个“螺钉”提供人为标识以解决这个问题(例如,一个自动编号的键)。OO范式采用了一种更加微妙的方式来处理标识问题,即“关系”。因此,对象识别是以它们的关联为基础的。例如,我们可以将汽车分解成不同的对象,如车身等。如果该汽车被一个唯一的汽车牌照所标识,那么车身对象则可以通过与这个特定的、有明确标识的汽车的唯一关联进行唯一的标识。
2.1.8 封装
需要攻克的第一个问题是如何隐藏实现,这样就能保证不被其他程序单元干扰了。SD是通过API来解决这个问题的,但是这通常只适用于大型程序单元。OO范式是通过封装方法来完成实现隐藏的,因此所有实现都封装于操作界面之下。OO范式使得封装概念成了基本概念,从而可以适用于所有的抽象。
一个抽象过程包含两个视图:对内描述抽象如何工作,对外描述抽象是关于什么的。
抽象与封装的结合完成了语义(一个实体根据合约负责了解什么或者做什么)与实现(实体如何了解或者如何做)的分离。接口提供外部视图,而隐藏于接口背后的内容提供内部视图。因此,接口微妙地完成了一个从提供模型边界到隐藏细节的机制转换。
2.1.9 内聚性
第1章指出SD无法为应用元素的内聚性提供恰当的指导,因为它没有系统化的内聚性方法。OO范式提供了一个非常系统的方法,并且非常明确地将内聚性作为一个设计工具来使用。具体来说,OO范式是依靠问题域提供内聚的。因此对象也是内聚的,因为对象提供的很多相关属性都是问题域中一个特定实体所特有的。OO分析模型的评价者总是会问:“一个域专家是否能够很轻易地识别出该对象且能够仅仅根据名称对其进行描述?”同样,对象属性均是单独内聚的,因为它们是对问题域中一个特定的问题空间实体各种属性的呈现,包括本质的、逻辑不可分的、分别可标识的属性。
内聚映射到问题域中的不同的实体及其特有的属性。
在问题域中,依靠内聚去识别差别(实体)和逻辑关系(描述它们的属性)是OO范式的一个独特贡献。
2.1.10 逻辑不可分性
OO范式的另一个特性是强调逻辑不可分性。在传统功能分解中,只有位于分解树低层的叶子功能是不可分的。在实践中,这些功能通常是3GL语言一级的运算符,例如“+”和“-”。因为分解终止于无料可分。
OO中逻辑不可分性的概念更加灵活,因为它取决于问题的抽象层次和个别子系统主题。不同的OO方法学可识别不同层次的逻辑不可分性。MBD可识别四种基础层次的逻辑不可分性。
1)子系统:子系统是一个以功能封装为基础的应用的大规模元素,与单个大型问题域主题有关。子系统可通过与一组对象的协作进行实现。
2)对象:对象从子系统主题的问题域中抽象单个可识别实体。一个对象负责了解和完成相应的事情。对象通过一定的方式抽象,与包含对象的子系统主题的抽象层次一致。
3)对象职责:对象职责描述了有关当前问题知识和行为的单个基础单元。对象职责同样定义在包含它的对象主题的抽象层次之上。
4)过程:描述动态行为职责需要通过基本的计算操作去描述问题域的规则和策略。这些可以从算术运算符一直延伸到定义于目前问题域之外的复杂算法问题。过程是对状态数据的一次或多次内聚性操作,这些数据在含有行为职责的抽象层次上逻辑不可分。
OO中的逻辑不可分性意味着抽象是自包含的、内在的和内聚的,因此在给定的抽象层次上没有进一步细分的意义。正因为如此,在任何解决方案上下文中,抽象始终可以从整体上慎重考虑。OO范式将抽象封装在接口下,以便隐藏更复杂的实现。
眼下,灵活的逻辑不可分性的力量可能并不那么明显,你也许会困惑于它是如何运用在如此庞大的事务中的。想要看到它的作用,需要有条理地、系统地了解更多的方法学。下面这个示例能更清楚地说明这一点。作者曾经编写过一个非常大的设备驱动程序(约三百万行代码)。在一个高层次的子系统存在单个对象知识属性,在这个抽象层次上,这些属性是以标量数据值表示的。在该子系统中,我们需要在一个很高的层次上处理控制流,其他细节可以忽略。尽管在子系统这个低抽象层次上,这个“值”会扩展到十余个类、上千个对象以及数量级为109个独立数据元素。在处理高层次控制流时,要把如此复杂的情况作为单个标量知识属性来进行考虑,这样做的重要性怎么强调都不为过。
对逻辑不可分性的灵活定义允许我们从根本上“扁平化”功能分解树,而不必面对冗长的分解链。同样,我们还可以通过改变点(行为职责)和点之间的连接方式,便捷地创建不同的解决方案。后面的章节将对此举例说明。
2.1.11 通信模型
串行是图灵世界的本质,指令被排好序,一次执行一个。实际情况是,分布式或并行处理不再局限于R-T/E。在数据处理系统中进行批处理的简单岁月早已远去。今天的软件可以实现交互式、分布式、并行、互操作等。今天的问题在本质上讲是异步的。人们一旦建立一个庞大的、复杂的应用,就不得不去处理其中的异步问题。
在OOA/OOD中,行为的职责是能够异步访问的。
正因为如此,假设对一个行为职责的触发可以独立于任何其他行为职责的执行。另外,在触发时间和实际响应时间之间可能有任意延迟。换句话说,我们不能指望一个行为职责能在其他行为执行后立即按照某种预订顺序连续执行。
关于此部分我们将在第三部分关于动态描述的章节中进行详述。目前需要知道的是为什么会出现这种情况。原因在于,异步通信模型是最通用的行为描述模型。一个解决方案如果能解决异步假设下的问题,就能解决任何实现环境中的问题,不论是串行、同步、异步还是并行。当然,这可能不容易实现,可能需要大量基础设施,但是我们总是可以在不改变解决方案逻辑的情况下实现解决方案。相关证明超出了本书的范围,但是如果有人试图在真正异步或并发环境中实现这些串行/同步的解决方案,上述结论就不一定正确了。如果从同步描述开始并试图在一个固有的异步环境实现,如分布式系统,为了正确实现可能不得不改变解决方案的逻辑。
实现一个异步通信模型并不容易,R-T/E人员就可以作证。但是,一旦模型开始运作,它往往稳定且容易修改。更重要的是,形成解决方案时OOA作者不需要担心并发等细节问题。无论解决方案在何处实现,OOA模型都可以正常运作。
当需要的时候总是从源头直接访问知识。
这句话的意思是,当行为职责需要考虑数据时,应该直接去找数据的主人以便获得最新的值。实际上,这意味着知识访问是同步的(即,请求行为责任将暂停,直到相应的知识可获取)。如果异步行为通信是为了保持开发人员头脑清楚,那么以上假设是必要的。
消息数据是及时体现当下情况的知识。
这个推论主要指在形式很好的OO应用中消息很少拥有数据包,因为行为职责可以直接访问所需数据。这与传统程序对比明显——传统过程上下文往往是有争议的。如果采用异步行为通信模型,由于可能存在延迟,不能假定消息中传递的数据一定具有时效性。所以在消息中包含知识的唯一原因是,数据与解决方案控制流的特定点需要进行绑定。一个很好的例子是,快照数据来自多个传感器,需要在同一个采样时间片进行整合处理。
既然知道实现将会同步,我们何必要自寻烦恼呢,因为我们今天掌握的,到了明天也许就不真实了。如果运用同步模型开发了OOA解决方案,并且部署环境下未来的一些变更将子系统或者部分对象移动至其他平台上,我们希望变更相对容易实现。那么什么是我们不希望的呢?那就是重构控制流。如果最初我们就执行了正确的OOA解决方案,实现环境改变时就不会出现大问题。
令人格外高兴的是,当功能需求发生变更时,我们将发现OOA模型中新增的规范令模型鲁棒性更佳、更易于修改。原因之一是数据访问范围限定在需要该数据的方法之内。过程为计算域提供了一个非常方便的范围边界。这简化了数据完整性的问题,因为在一定方法范围内可以看到什么数据会被访问,什么时候被访问。但是如果传递数据,就必须关注数据什么时候以及在什么地方被获取,这扩展了数据完整性关注的范围,从调用堆栈扩展到数据被获取的任何地方。