首页 新闻 论坛 群组 Blog 文档 下载 读书 Tag 网摘 搜索 开源 FAQ 第二书店 博文视点 程序员
频道: 研发 数据库 中间件 信息化 视频 .NET Java 游戏 移动 服务: 人才 外包 培训
    图书品种:235680
       
热门搜索: ASP.NET Ajax Spring Hibernate Java

2.2  面向对象设计

前面介绍了,在解决方案的设计过程中指定对象的重要性。但如何在第一步确定对象?一些技术有助于为具体的解决方案选择对象,这是本书及将来的教程都要考虑的主题。由于篇幅所限,本书不对此作详细讨论。本节将简要介绍两种重要的设计技术——抽象和信息隐藏,接着讨论面向对象设计和功能分解。

2.2.1  抽象与信息隐藏

1. 过程抽象

在设计一个方法,作为问题解决方案的一部分时,每个方法最初都表示为一个箱子(box),箱子指出方法做什么,但不说明如何做。每个箱子都不知道其他箱子执行任务的方式,只知道其他箱子完成什么任务。例如,若解决方案的一部分排序一些数据,则用一个箱子来表示排序算法,如图2-2所示。其他箱子都知道这个箱子可用来排序,但不知道具体的排序方式。这样,解决方案的各个组件就彼此分离开来。

图2-2  排序算法的细节对解决方案的其他部分是隐藏的

过程抽象分离了方法的功能和实现方式。在用编程语言实现方法前,通过抽象技术明确地描述了各个方法。例如,该方法的假设条件有哪些?要执行什么操作?这些描述将阐明解决方案的设计,使我们能着眼于高级功能,不会因实现细节而分散注意力。另外,恰当地运用这些原理,可修改解决方案的某一部分,而不显著地影响其他部分。例如,在上例中,可在不影响解决方案其余部分的情况下更改排序算法。

随着问题求解过程的推进,这些箱子将逐步完善,直到最终用Java代码实现其操作。一旦编写了一个方法,只要知道这个方法的功能和参数,就可以使用这个方法,不必了解其算法的具体细节。假如一个方法的文档记录正确无误,则只要了解其声明和初始的描述性注释,就可以使用该方法,不需要查看其实现代码。

对团队项目而言,过程抽象是必需的。在团队环境中,常常使用其他人编写的方法,但不需要了解这些方法使用的算法。不研究代码,真能使用此类方法吗?答案是肯定的。事实上,在使用Java API的方法时(如前述的Math.sqrt),就是这么做的。

2. 数据抽象

现在,考虑一个数据集合以及这个数据集合上的一组操作。可能的操作有:将新数据添加到集合中,从集合中删除数据,或查找一些数据。数据抽象注重操作执行什么任务,而不考虑如何实现操作。解决方案的其他模块知道它们可执行哪些操作,但不了解数据存储方式以及操作执行方式。

例如,数组究竟是什么?有人说,数组指Java数组在计算机上的实现方式。但换一个角度看,数组含义为:可在不了解数组实现方式的情况下使用数组。尽管不同的系统可能按不同的方式实现数组,但这些差别对编程人员是透明的。举例来说,无论如何实现数组years,总可以通过years[index]=1492;语句,将值1492存储到数组的index位置。随后可以用System.out.println(years[index]);语句输出该值。换言之,可在不了解数组实现细节的情况下使用数组,就像在不了解Math.sqrt方法实现细节的情况下使用Math.sqrt方法一样。

数据抽象是本书的主要内容。为了抽象地分析数据(即考虑对数据执行哪些操作,而不是如何执行这些操作),应当定义“抽象数据类型”(abstract data type,简写ADT)。ADT指一个数据集合及这个数据集合上的一组操作。只要知道ADT的规范,就可以使用ADT的操作,不必了解实现操作的细节和数据存储方式。

我们用数据结构实现ADT;数据结构可以用编程语言定义,它的作用是存储数据集合。例如,可将一些数据存储在Java整型数组中,存储在对象数组中,或存储在数组构成的数组中。

在问题求解过程中,抽象数据类型支持算法,而算法是抽象数据类型的组成部分。在设计解决方案时,应协作开发算法和ADT。解决问题的全局算法给出要在数据上执行的操作,这些操作又给出ADT以及在数据上执行操作的算法。不过,解决方案的开发也可能以相反的方向进行。我们设计的ADT可影响解决问题的全局算法的策略。换言之,了解哪些数据操作易于执行,哪些数据操作难以执行,对如何处理问题有很大的影响。

由上述讨论可知,通常很难明显区分“算法问题”和“数据结构问题”。常有这样的情况,从一种观点分析程序,觉得是数据结构支持精巧的算法;而从另一观点分析同一程序,又觉得是算法支持精巧的数据结构。

3. 信息隐藏

由前述可知,抽象过程可指定为各个模块编写规范,来描述其外部(或公共)接口。另外,通过抽象,可以标识应该对外部隐藏的细节,这些细节不应放在规范中,而应为私有。“信息隐藏”原理要求,不仅要在模块中隐藏此类细节,还要确保其他模块不能损坏这些隐藏细节。

信息隐藏限制了处理方法和数据的方式。模块的使用者不需要考虑实现细节,而模块的实现者不需要考虑其用法。

2.2.2  面向对象的设计

要获取面向对象的设计方案,一种方法是开发组合了数据和操作的对象,以表示现实生活中的实体或抽象过程。这种面向对象的模块化方法将生成一个具有行为的对象集合。

我们可以将周围的事物看作对象。早上唤醒您的闹钟便封装了时间以及相关操作,如“设置闹钟”。封装意指装箱或封闭,因而封装是隐藏内部细节的技术。方法封装了操作,而对象封装了数据和操作。尽管请求闹钟执行某些操作,也看不到其工作原理,可见的只有操作结果。

假设要编写程序,在计算机屏幕上显示时钟。为简化起见,下面分析一个不具有响铃功能的数字式时钟,如图2-3所示。为设计模块化的解决方案,首先标识问题中的对象。

图2-3  数字式时钟

标识对象的方法有若干种,可根据实际情况选用。一种简单的方法是考虑问题描述中的名词和动词。名词指示对象,其操作由动词指定。例如,可将时钟问题描述为:

该程序将维护一个数字式时钟,该时钟以小时和分钟显示时间。小时指示项和分钟指示项都是数字设备,显示值的范围分别为1~12和0~59。程序应能通过设置小时和分钟指示项来设置时间,时钟应通过更新这些指示项来维护时间。

即使没有详细的问题描述,也能判断出:时钟(clock)本身是一个对象。时钟执行的操作有:

●       设置时间

●       推进时间

●       显示时间

小时指示项和分钟指示项(indicator)也是对象,二者很相似。各指示项执行的操作有:

●       设置值

●       推进值

●       显示值

实际上,这两个指示项是同类型的对象。类型相同的一组对象称为类。因此,需要指定的不是具体的对象,而是对象的类。实际上,本程序需要一个clock(时钟)类和一个indicator(指示项)类。clock对象是clock类的一个实例,本程序还包含两个indicator对象;indicator对象是indicator类的实例。

第4章将进一步讨论封装,并介绍它与Java类的关系。后续各章将分析各种ADT及其Java类的实现方式。重点是数据抽象和封装。这是基于对象的编程方法。

除封装外,面向对象的编程(object-oriented programming,简写OOP)还有另外两个重要的原理:

(1) 封装:对象组合数据和操作。

(2) 继承:类可从其他类中继承属性。

(3) 多态:对象可在执行时确定需要什么操作。

类可以从其他类中继承属性。例如,一旦定义了clock类,就可以设计alarm clock类,该类继承了clock类的属性,还添加了提供响铃的操作。clock部分已经完成,因此,可以很快创建alarm clock类。通过继承,可以重用以前定义的类,后者对前者做适当的修改,二者的作用不同,但有关联。

继承可能使编译器无法确定在某种情况下需要哪种操作。但OOP的多态性(字面意思为:有多种形式)支持在执行时确定适当的操作。换言之,某个操作的输出取决于执行操作的对象。例如,在Java中,若对数值操作数使用+操作符,则执行加操作;若对字符串操作数使用+操作符,则执行连接操作。当然,在这个简单情况下,编译器可以确定+的正确含义;在一些情况下,只有在执行时才能确定操作的含义,此时,必须使用多态。

第8章将进一步讨论继承和多态。

2.2.3  功能分解

一般地,面向对象的方法最初仅涉及设计中的数据方面,但设计出实现对象操作的方法也是很重要的。前面想将方法放在高聚合的类中—— 方法应表示要在对象中执行的单个任务。功能分解(也称为自上而上的设计)有助于把对象中的复杂任务分解为更便于管理的、目的单一的任务和子任务。

功能分解的原理为:逐步细化任务,以完成任务。分析一个简单的例子:假设要查找一个测试分数集合的中间值。图2-4是一个结构图,显示了解决该问题的方法的层次结构,以及方法之间的交互。最初,各方法仅描述了需要解决的问题,但缺少细节。然后,将方法划分为更多的较小方法,来细化各个方法。结果是一个方法的层次结构,再用类似的方式细化各个小方法;与最初的方法相比,后面较小的方法解决更小的问题,而且包含如何解决问题的更多细节。继续这个细化过程,直至层次结构底部的方法足够简单,可将其直接转换为能解决非常小的独立问题的Java方法。

由图2-4知,可将解决方案分解为3个独立任务:

●       读取测试分数

●       排序分数

●       得到平均分数

图2-4  显示方法层次的结构图

在本例中,如果由这3个方法执行任务,那么,只要按顺序调用它们,就可以得到正确的平均分数;不必关心各个方法如何执行任务。

在开始开发各个方法时,要为方法划分为子任务。例如,可将“读取测试分数”的任务分为以下两个子任务:

●       提示用户输入分数

●       将分数放入数组

以类似的方式为这两个子任务开发方法,继续问题解决过程。最后,可以用伪码指定算法的细节。

2.2.4  一般设计原则

在设计问题求解方案时,一般使用面向对象的设计(OOD)、功能分解(FD)、抽象和信息隐藏技术。下面的设计原则总结了导出模块化解决方案的方法。

(1) 使用OOD和FD生成模块化解决方案。换言之,协作开发抽象数据类型和算法。

(2) 为主要涉及数据的问题使用OOD。

(3) 使用FD,为对象的操作设计算法。

(4) 使用FD,为强调算法(而不是数据)的问题设计解决方案。

(5) 在设计ADT和算法时,侧重“做什么”,而不是“如何做”。

(6) 考虑将以前编写的软件组件包含到设计方案中。

2.2.5  使用UML为面向对象的设计建模

统一建模语言(UML)是表达面向对象设计的一种建模语言。UML提供了基于图表和文本描述的规范,在表示解决方案的整体设计,包括类的规范和类彼此交互的各种方式时,图表非常有效。一个解决方案常常会涉及许多类,因此表示类之间交互的能力是UML的长处之一。

本书主要讨论类本身的设计,所以这里只介绍类图和相关的语法。类图指定了类的名称、数据成员和操作。图2-5显示了前面Clock类的类图。最上面的一部分包含类的名称,中间的部分包含表示类中数据的数据成员,下面的部分包含操作。注意这个图很一般,它并没有指出类是如何实现的,通常只表示出了类的概念化模型,独立于编程语言。

Clock

hour

minute

second

setTime()

advanceTime()

displayTime()

    图2-5  Clock类的UML图

除了类图之外,UML还提供了一种基于文本的表示法,来表示类的数据成员和操作。这种表示法可以合并到类图中,但通常用得不多,因为它会把类图弄乱。本书使用这种基于文本的表示法来描述类,因为它比类图提供的规范更完整。

数据成员的UML语法如下:

可见性 名称:类型=默认值

式中:

●       “可见性”是+(public)或–(private),第三个选择是#(protected),详见第9章。

●       “名称”是数据成员的名称。

●       “类型”是数据成员的数据类型。

●       “默认值”是数据成员的初始值。

从类图中可以看出,至少要提供名称。默认值只在适当的时候使用。有时还可以省略数据成员的类型,让实现代码提供。本书使用下面的名称来表示一般的形参类型:integer表示整数值,float表示浮点值,boolean表示布尔值,string表示字符串值。注意这些名称不一定匹配对应的Java数据类型,因为这个表示法是独立于编程语言的。

用基于文本的表示法表示图2-5中的数据成员,如下所示:

-hour: integer

-minute: integer

-second: integer

从信息隐藏的概念可知,数据成员hour、minute和second都声明为私有。

操作的UML语法涉及的内容较多:

visibility name (parameter-list):return-type {property-string}

式中:

●       visibility与数据成员的“可见性”相同

●       name就是操作的名称

●       parameter-list包含用逗号分隔开的形参,其语法如下:

direction name: type = defaultValue

式中:

direction表示形参是用于输入(in)、输出(out)还是输入输出(inout)。

name表示形参

type就是形参的数据类型

defaultValue是在未提供实参的情况下使用的值。

●       return-type是操作结果的数据类型。如果操作没有返回值,这一项就是空白

●       property-string表示应用于操作的属性值。

与数据成员的类图一样,操作的类图至少也要提供操作的名称。为了使用户准确理解类的功能,有时还包含形参列表。

属性字符串有许多值,但本书感兴趣的是属性query,它表示操作不修改类中的任何数据。

用基于文本的表示法表示Clock类中的操作,如下所示:

+setTime(in hr: integer, in min: integer, in sec: integer)

-advanceTime()

+displayTime(){query}

这里把操作setTime和displayTime指定为公共,操作advanceTime指定为私有。DisplayTime函数还指定了属性query,它表示不修改任何数据,该函数仅用于显示数据。

UML类图还提供了另一个表示法来说明类之间的关系。假设要建立一个银行系统应用程序,其规范如下:

设计一个银行系统,客户可以开设储蓄存款账户和支票账户。要提供给银行的信息有名称和金额。这两类账户都可以查询余额、存款和取款。一个客户可以有多个账户。每个客户的名称和地址存储在系统中,每个账户都有一个账号。储蓄存款账户可赚取利息,当余额低于最低金额时,就用支票账户支付费用。当客户要求查询余额时,要反映出这些调整。

为了表示出银行系统的各个方面,需要设计几个类,如图2-6所示,其中包括Bank类、Account类和Customer类。这些类之间的关系用一根直线表示,并指定关系的基数性。例如,一个客户可以有一个或多个账户,就用“1…*”(一对多)表示。类之间的关系还有不同的类型。例如,Savings和Checking账户类都派生于Account类,它们继承了Account类的数据成员和操作。继承用一个指向父类的箭头表示。注意Savings和Checking类还有自己的getBalance方法,它重写即替换了父类的getBalance方法,对费用和利息进行必要的计算。一个类还可以在定义中包含另一个类的实例,从而建立与这个类的关系。在银行系统的例子中,银行包含一个或多个账户。这种关系称为包含,用包含类旁边的方块表示。继承和包含详见第9章。

图2-6  银行系统的UML图

2.2.6  面向对象方式的优点

在使用面向对象的编程(OOP)方式时,程序的设计时间可能会增加。另外,OOP技术产生的解决方案通常比解决手边问题所必需的方案更具普遍性。但是OOP的这种额外工作常常是值得的。

在解决问题时使用面向对象的设计,需要标识出所涉及的类。即标识出每个类的作用以及它与其他类的交互方式。这可以导出每个类的规范,标识出操作和数据。接着考虑每个类的实现细节,包括使用自上而下的设计,便于操作的开发。一次只考虑一个类,会比较容易实现。

实现了类之后,就必须在两个不同的层次上测试它。首先,必须测试类的操作。这通常需要编写一个小程序,调用各个操作,测试所得的结果是否匹配为该操作提供的规范。以这种方式测试完每个类后,就应测试在什么情形下类能联合工作,解决更大的问题。

在解决方案中标识所涉及的类时,常常需要一组相关的类。这个设计阶段比较费时,特别是没有现成的类作为基础时,就更是如此。一旦实现了类(祖先类),每个新类(子孙类)的实现就快得多,因为可以重复使用祖先类的属性和操作。例如,如前所述,定义了Clock类后,就可以设计一个AlarmClock类,它继承了Clock类的属性,并添加了提供闹铃的属性。如果没有Clock类作为基础,AlarmClock类的实现就费时得多。在将来的程序中,可以重复使用以前实现的类,在派生于现有类的新类中,可以不包含或包含一定的修改。这种类的重用可以大大缩短面向对象设计所需要的时间。

对于软件生命周期的其他阶段,例如程序的维护和验证阶段,OOP还有一个优点。对祖先类进行的修改会影响它的所有子孙类。如果不使用继承,就需要对许多模块进行相同的修改。另外,通过增加子孙类的方式来给程序添加新功能,不会影响它们的祖先类,因此不会在程序的其他部分引入错误。即使祖先类很早就编写和编译好了,也可以添加一个子孙类,来修改祖先类的初始操作。

查看所有评论(0)条】

最近评论



正在载入评论列表...
热点评论