课件

10.3 面向对象设计.pdf

面向对象设计 Object-Oriented Design

关于对象设计

在软件设计的过程中,我们始终要应对需求日新月异的变化、对设计创新的持续追求,以及对软件质量持续提升的要求,在整个过程中我们可以采用很多种不同的方法来应对这些问题,面向对象方法就是其中之一。

然而真相是面向对象方法本身并不能保证你的设计成为优秀的设计,你可以用非面向对象的方法建立一个非常好的设计,也可以用了面向对象的方法但生成了一个不良的设计,平庸的依然平庸,但是用面向对象方法最主要应对的问题,就是数据封装减小模块间的依赖性,让我们有更好的、可以重用的设计单元。

面向对象设计过程

面向对象的设计过程:

  1. 进行适当的领域分析
  2. 撰写问题描述,确定系统的开发任务
  3. 基于问题描述抽取需求
  4. 开发用户界面原型
  5. 识别对象类(面向对象设计的基础)
  6. 定义每个类的职责
  7. 确定类之间的交互关系
  8. 建立系统的设计模型

在这个过程中最主要的一个步骤就是识别对象类。下面介绍一种识别对象类的方法。

面向对象思维方式的核心理念

面向对象思维方式最核心的理念主要包括:

  • 把接口和实现区分开
  • 从具体到抽象
  • 定义最小接口原则

区分接口与实现最主要的目的就是实现接口的标准化,使得我们能够在接口不变的情况下,对实现的部分进行演化和修改。

image.png
比如在上图中如果要撰写一个数据库的访问类,最主要的功能就是打开数据库,如果定义了一个统一的接口Open,它的参数只有我们要打开的数据库的名字,而在内部的实现过程中我们既可以打开一个Oracle的数据库,也可以打开一个SQL数据库,那么我们就能够保证无论底层的实现采用的是哪种数据库,我们都能够用统一的方法来访问数据库。

image.png
确保接口与实现的分离、定义标准的接口,保证了我们程序结构的稳定性,使我们可以动态的替换用户的实现代码和底层的数据库实现,保证我们的程序在演化的过程中有相对稳定的结构。

设计抽象的接口

实例

image.png
以叫出租车这样的一个服务为例,一个抽象的接口给予用户最简单的访问功能的方式。比如如果我们在使用打车服务时,只需要告诉出租车司机“师傅请送我去机场”这样一个简单的信息,那么用户使用这个服务就会非常简单。

但是如果我们向用户暴露了过多的实现细节的话,接口就会显得非常的纷繁复杂,如下图所示:
image.png
同样的去机场的过程,我们就需要不断地和服务的对象之间发生交互,告诉他向左转向右,当我们的目的地发生变化时,这个接口的定义也会发生变化,因此不够抽象的接口会增加系统的访问和交互的复杂性

抽象的接口

由此我们可以看出,一个定义抽象的接口是要向用户暴露尽可能少的实现细节,让用户知道的关于类的内部实现的细节越少越好,这里把握的原则就是:

  • 只给用户看他所必须的部分
  • 只给用户看和使用公开的信息
  • 只从用户的业务需求来考虑,而不暴露实现的和设计的细节

总结起来就是最小用户负担原则

确定用户

在定义类的职责的过程中,一定要采用一种面向服务的原则(Services Principle),确定这个类对外提供服务的用户是谁,只有这样我们设计出来的系统才能够有明确的职责划分,谁是提供服务的一方,谁是使用服务的一方,服务好坏与满足与否的标准是什么。

image.png
比如在刚才出租车的例子中,司机是提供服务的一方,客户是使用服务的一方,二者之间是有不同的,出租车司机是为了赚更多的钱,而用户则是要花费更少的开销,因此我们要在类的设计中考虑二者之间的这一个平衡。

确定对象行为

确定对象行为时,通过之前用例分析以及对类职责的分析,定义了一部分设计的细节,但是在我们对接口进行抽象的时候,这些对象的行为定义是有可能发生变化的

识别环境约束

因为这个时候识别了更多的环境对功能的约束,包括实现一个操作的前置条件、后置条件、意外情况等等。

公共接口的识别

比如在用户使用出租车对象的时候,他需要的功能是上车、告知司机终点、到达之后下车、付钱,这些就是这一个类对象要完成的公共的那些行为,因此要把它定义在接口上。从用户的这一端看,用户在用出租车的时候,他的脑子里一定是要有出行的地点、还要能够召唤出租车、还要能够付钱,通过对这些公共接口的识别,就得到了第一步关于类行为的定义。

确定实现细节

除了这些公共的接口以外的内容,都可以把它看作是和实现相关的。这也就是通过这个过程我们了解到如何去区分接口和实现。

服务的用户他无须关注实现的细节,他只需要知道要完成预期的功能,我需要提供什么,所以他只关注的是要定义哪些方法,以及这些方法它的参数有哪些。至于编码实现的部分就是和实现细节相关的。

通过分离接口和实现,当我们修改实现的时候,接口是无须修改的,这样我们可以用不同的方式来实现用户的期望。

接口是站在系统的外部,从用户的角度来看待对象的定义。如果我们把对象看成是一个水果的话,实现是包含在对向内部的果核和果肉,而接口则是果皮。

在实现中需要描述对象的内部状态,并编码实现这些状态的迁移。

4项OO设计原则

开闭原则(Open/Closed Principle, OCP)

该原则最能反映面向对象设计目的。该原则最初由Bertrand Meyer提出,主要思想是软件实体在扩展性方面应该是开放的,而在更改性方面应该是封闭的。也就是说要尽量使得模块是可扩展的,在扩展时不需要对源代码进行修改。

为了实现开闭原则,就要尽可能使用多的接口进行封装,采用抽象机制,运用多肽技术。

比如,下图所示的两个类图:
image.png
设计1是将输出设备直接定义为与具体某种输出设备接口关联的形式,设计2在输出和具体设备之间定义了一个抽象接口,名为Printer,通过引入该抽象接口作为中间层次,使得当软件实体出现新的设备类时,不需要修改Output这个对象的源代码,从而实现在扩展性方面开放,而无需更改源代码。因此,我们倡导满足开闭原则的代码其结构是类似设计2这样的结构。

Liskov替换原则 (Liskov Substitution Principle, LSP)

最早由Liskov于1987年在OOPSLA会议上提出。主要思想是子类可以替换父类出现在父类能出现的任何地方

违反LSP的例子:
image.png
问题:如果将Square作为Rectangle的子类,那如何定义Square类的setWidth和setHeight方法?
将正方形建模为长方形的子类并不是一个好的设计。因为定义的继承关系是针对类的行为而言,而不是字面的理解,从测试类的形式上看两者行为不一样。

❓如何知道子类的行为符合父类的要求?
契约式设计 (Design by Contract)是一个比较好的解决方法,明确了解外部环境对子类父类的前置条件、后置条件的要求。为了满足Liskov替换原则,设计时要求:

  • 子类中方法的前置条件不能强于父类中相应方法的前置条件;
  • 子类中方法的后置条件不能弱于父类中相应方法的后置条件;

Liskov 替换原则要求子类行为宽入严出

依赖倒置原则 (Dependency Inversion Principle, DIP)

依赖倒置原则指的是依赖关系应该是尽量依赖抽象的接口或抽象类,而不是依赖于具体类。

image.png
上图不符合依赖倒置原则所倡导的尽量依赖抽象接口。

image.png
面向对象中的依赖强调在顶层具体的客户类之间、具体类之间建立抽象的接口、抽象类定义,这样便能确保无论应用场景发生变化还是底层实现发生变化,都能保证程序中间的结构相对稳定。

接口分离原则(Interface Segregation Principle, ISP)

接口分离原则是说在设计时采用多个和特定客户类(client) 有关的接口要比采用一个通用的接口要好。
image.png
接口分离原则是一个建议。使用通用接口的好处是对于客户端来说,我只需要知道一个接口的信息即可,使用了分离接口后,意味着每一个接口的修改它的波及范围比较小。所以这是一个权衡利弊的过程。

好的系统设计的特征

好的系统设计有着公共的特征:

  • 用户友好
  • 易于开发人员理解
  • 安全可靠
  • 可扩展
  • 可移植
  • 可伸缩
  • 可重用

概况而已即简单:好的系统看起来简单、使用起来简单、实现起来简单、理解起来简单、维护起来也简单。

那么不好的系统,或者逐渐老化的系统的特征则是与好的系统特征相反:
image.png

  • 修改难
  • 非常脆弱,牵一发而动全身
  • 移植难
  • 代码难以重用
  • 设计粘性、环境粘性都很强

系统软件总是有个老化的过程,这是由于需求的变更、技术的更迭导致的,如果是个好的良构的系统,它可让老化的周期变得更长,维持稳定,运行时间也久一点。

建立好的、良构的设计,且采用面向对象设计方法时,有一些注意事项:

  • 不同类中相似方法的名字应该相同
  • 遵守已有的约定俗成的习惯
  • 尽量减少消息模式的数目。只要可能,就使消息具有一致的模式,以利于理解。
  • 设计简单的类。类的职责要明确,应该从类名就可以较容易地推断出类的用途。
  • 定义简单的操作、方法
  • 定义简单的交互协议
  • 泛化结构的深度要适当(三到四层已经是很多了)
  • 把设计变动的副作用减至最少