2.1 对象模型的演进

面向对象开发不是从无数使用早期技术和失败的软件项目的灰烬中自发产生的。它不是与早期方法断然决裂的。实际上,它建立在以前技术的最佳思想之上。本节将讨论我们行业中工具的演进,以帮助理解面向对象的基础和出现。

当回过头去看一看相对简单,但又多姿多彩的软件工程的历史时,肯定会注意到如下两个巨大的趋势:

■ 关注点从小规模编程向大规模编程转变;

■ 高级程序设计语言的演进。

最新的工业级软件系统与几年前它们的前辈相比,更大也更复杂。这种复杂性的增长推动了大量有用的软件工程应用研究,特别是在分解、抽象和层次结构等方面。开发表达能力更强的程序设计语言为这些进步提供了补充。

2.1.1 程序设计语言的换代

Wegner根据语言的功能和产生的时间,将一些较为流行的高级语言进行了分类[2]。(这并不是一份所有程序设计语言的清单。)

■ 第一代语言(1954—1958)

img

■ 第二代语言(1959—1961)

img

■ 第三代语言(1962—1970)

img

■ 代沟(1970—1980)

人们发明了许多不同的语言,但很少存活下来。但是,下面的语言值得一提。

img

让我们扩展一下Wegner的分类。

■ 面向对象兴盛(1980—1990,但幸存下来的语言不多)

img

■ 框架的出现(1990—现在)

出现了许多语言活动、新版本和标准化工作,导致了程序设计框架。

img

在这一系列的语言换代之中,每种语言支持的抽象机制发生了变化。第一代语言主要用于科学和工程应用,这个问题领域的词汇几乎全是数学。因此,开发出了像FORTRAN I这样的语言,让程序员能写出数学公式,从而不必面对汇编语言或机器语言中的一些复杂问题。所以,第一代高级程序设计语言标志着向问题空间靠近了一步,向底层计算机远离了一步。

在第二代语言中,重点是算法抽象。那时候,计算机变得越来越强大,计算机产业经济意味着更多的问题可以自动化,特别是在商业应用中。这时,关注的焦点主要在告诉计算机做什么:先读入这些个人记录,接下来进行排序,然后打印这份报告。同样,这一代的程序设计语言让我们向问题空间又靠近了一步,向底层计算机又远离了一步。

在20世纪60年代后期,特别是半导体和集成电路技术发明之后,计算机硬件的成本迅速下降,而处理能力几乎呈指数上升。这时可以解决更大的问题,但需要操作更多类型的数据。因此,像ALGOL 60和稍后的Pascal这样的第三代语言演进到支持数据抽象。这时,程序员可以描述相关数据的意义(它们的类型),并让程序设计语言强制确保这些设计决策。这一代高级程序设计语言再一次让我们向问题空间靠近了一步,向底层计算机远离了一步。

20世纪70年代开展了大量的程序设计语言研究活动,结果导致产生了数千种不同的程序设计语言和方言。在很大程度上,编写越来越大的程序的愿望凸显了早期语言的不足。因此,人们发展了许多新的语言机制来解决这些局限。极少语言幸存下来(最近的教科书中还提到Fred、Chaos或Tranquil等语言吗?),但是它们引入的许多概念被早期语言的继承者们吸收了。

我们最感兴趣的,是所谓的“基于对象”和“面向对象”的语言。基于对象和面向对象的程序设计语言,为软件的面向对象分解提供了最好的支持。这些语言的数量(以及原有语言的“对象化”变种的数量)在20世纪80年代和90年代早期大量增加。自1990年以来,在一些商业程序设计工具提供商的支持下,一些语言成为了主流OO语言(如Java和C++)。程序设计框架(如J2EE、.NET)通过提供组件和服务,简化了常见的、琐碎的编程任务,为程序员提供了很大的支持。它们的出现极大地提高了生产效率,展示了组件复用这个容易被忘记的承诺。

2.1.2 第一代和第二代早期程序设计语言的拓扑结构

先来考虑一下每一代程序设计语言的结构。图2-1演示了第一代和第二代早期程序设计语言的结构。所谓“拓扑结构(topology)”,指的是这种语言的基本物理构成单元,以及这些部分是如何连接的。从图中看到,像FORTRAN和COBOL这样的语言,所有应用的基本物理构成单元是子程序(对于使用COBOL的人来说,称为段落)。

用这些语言编写的应用展现出相对较平的物理结构,只包含全局数据和子程序。图2-1中的箭头表明了子程序对不同数据的依赖关系。在设计时,设计者可以在逻辑上将不同类型的数据分开,但是在这些语言中没有任何机制来强制确保这些设计决策。程序中某个部分的错误可能给系统的其他部分带来毁灭性的连带影响,因为全局数据结构对于所有子程序都是可见的。

在对大型系统进行修改时,很难维持原有设计的完整性,并且常常会引入混乱:即使在一段短时间的维护之后,这些语言编写的程序常常会包含子程序间的大量交叉耦合、对数据含义的假定及复杂的控制流,从而对整个系统的可靠性造成威胁,降低解决方案的整体清晰性。

img

图2-1 第一代和第二代早期程序设计语言的结构

2.1.3 第二代后期和第三代早期程序设计语言的结构

在20世纪60年代中期,程序被认为是问题和计算机之间重要的中间点[3]。“首次软件抽象,现在所谓的‘过程化’抽象,直接来自于这种实际的软件观点……子程序在1950年之前就被发明了,但是作为一种抽象,那时候并没有被完全接受……相反,最初它们被看作是一种节省劳力的机制……但是很快,子程序就被认为是抽象程序功能的一种方式。”[4]

意识到子程序可以作为一种抽象机制,这产生了三个重要结果。首先,人们开始发明一些语言,支持各种参数传递机制。其次,奠定了结构化程序设计的基础,表明在语言上支持嵌套的子程序,并在控制结构和声明的可见性范围方面发展了一些理论。第三,出现了结构化设计方法,为试图构建大型系统的设计提供了指导,利用子程序作为基本构建块。因此,毫无悬念,如图2-2所示,第二代后期和第三代早期语言的结构基本上是前一代语言的主题变奏。这种结构关注早期语言的一些不足之处,具体来说就是需要对算法抽象有更强的控制。但是,它仍然未能解决大规模程序设计和数据设计的问题。

img

图2-2 第二代后期和第三代早期程序设计语言的结构

2.1.4 第三代后期程序设计语言的结构

从FORTRAN II开始,在大部分第三代后期程序设计语言中,出现了另一种重要的结构机制,并发展为对不断增长的大规模编程问题的关注。大规模编程项目意味着大型的开发团队,因此需要独立地开发同一个程序的不同部分。这种需求的解决方案是能够独立编译的模块,这在早期的概念中只是一种随意的数据和子程序的容器,如图2-3所示。模块很少被看作是一种重要的抽象机制,在实践中,它们只是用于对最有可能同时改变的子程序分组。

img

图2-3 第三代后期程序设计语言的结构

这一代语言中的大多数虽然支持某些模块化结构,但是很少有规则要求模块间接口的语义一致性。为一个模块编写子程序的开发者可能假定它会通过三个不同的参数调用:一个浮点数、一个包含10个元素的数组和一个代表布尔标记的整型。在另一个模块中,对这个子程序的调用可能使用了不正确的参数,违反了这一假定:一个整数、一个包含5个元素的数组和一个负数。类似地,一个模块可能使用一块公共数据,把它当作自己的一样,而另一个模块可能违反这些假定,直接操作这块数据。不幸的是,因为这些语言中的大部分对数据抽象和强类型的支持都不太好,这样的错误只有在执行程序时才能被检测出来。

2.1.5 基于对象和面向对象的程序设计语言的结构

数据抽象对于把握复杂性是很重要的。“通过过程可以实现的抽象在本质上很适合描述抽象操作,但并不太适合描述抽象的对象。这是一个严重的缺陷,在许多应用中,要操作的数据对象的复杂性在很大程度上决定了问题的复杂性。”[5]这种认识有两个重要结果。首先,出现了数据驱动的方法,它为面向对象的语言提供了一种解决数据抽象问题的方法。其次,出现了关于类型概念的理论,这种理论最终在像Pascal这样的语言中得到了实现。

这些思想的自然成果首先出现在Simula语言中,经过改进,也导致了一些语言的出现,如Smalltalk、Object Pascal、C++、Ada、Eiffel和Java。这些语言被称为基于对象的语言或面向对象的语言,原因稍后解释。图2-4展示了小型和中型应用中这种语言的结构。

这类语言的构建块是模块,它表现为逻辑上的一组类或对象,而不像早期语言那样是子程序。换言之,“如果过程和函数是动词,数据是名词,那么面向过程语言的程序就是围绕动词组织的,面向对象的程序就是围绕名词组织的”[6]。出于这个原因,小型或中型面向对象应用的物理结构表现为一个图,而不像面向算法的语言那样通常是一棵树。另外,基本上很少或没有全局数据。数据和操作放在一个单元中,系统的基本逻辑构建块不再是算法,而是类或对象。

到目前为止,我们已经超越了“大型编程(programming-in-the-large)”,必须面对“巨型编程(programming-in-the-colossal)”。对于非常复杂的系统,我们发现类、对象和模块提供了基本的抽象手段,但这还不够。幸运的是,对象模型在规模上可以扩展。在大型系统中,一组抽象可以构建在另一组抽象层之上。在任何一个抽象层上,都可以找到某些有意义的对象,它们可以协作实现更高层的行为。如果我们仔细看一组对象的实现,会看到另一组协作的抽象。这正是第1章中描述的复杂性的组成方式,图2-5展示了这种结构。

img

图2-4 使用基于对象或面向对象编程语言的小型或中型应用的结构

img

图2-5 使用基于对象或面向对象编程语言的大型应用的结构