UML
UML Tutorial (tutorialspoint.com)
UML 介绍
- 所谓UML(Unified Modeling Language,统一建模语言),一种用来对软件密集系统进行可视化建模的语言。是一种为面向对象系统的产品进行说明、可视化和编制文档的一种标准语言,是非专利的第三代建模和规约语言。UML是面向对象设计的建模工具,独立于任何具体程序设计语言
UML做到了什么,让人们愿意为之喝彩?
- 答案就是,它统一了各种方法对不同类型的系统、不同开发阶段以及不同内部概念的不同观点,从而有效的消除了各种建模语言之间不必要的差异。它是一种通用的建模语言,可以为许多面向对象建模方法的用户广泛使用
常用工具:StarUML 、EA 等
- 详细设计(LLD)
- 概要设计(HLD)
- 需求分析文档(SRS)
- 基本设计(BD)
- 详细设计(DD)
- 功能设计(FD)
HLD:概要设计说明书,编制的目的是说明对程序系统的设计考虑,包括程序系统的基本处理流程、程序系统的组织结构、模块划分、功能分配、接口设计、运行设计、安全设计、数据结构设计和出错处理设计等,为程序的详细设计提供基础
LLD:详细设计说明书,编制目的是说明一个软件系统各个层次中的每一个程序的设计考虑,如果一个软件系统比较简单,层次很少,本文件可以不单独编写,有关内容合并入概要设计说明书
DD:详细设计,是软件工程中软件开发的一个步骤,是对概要设计的一个细化,详细设计每个模块实现算法,所需的局部结构
流程: SRS->AD->HLD->DD
- AD 文档 的接口是 提供对外的接口
- HLD 文档 的接口是 内部的接口
- DD 文档 是接口的实现逻辑
九大图在各个阶段的使用:(BD:基本设计,FD:功能设计,DD:详细设计)
九大图之间的关系:
UML的分类
UML图有哪些?
- UML图分为结构图和行为图
- 结构图分为类图、轮廓图、组件图、组合结构图、对象图、部署图、包图
- 行为图又分活动图、用例图、状态机图和交互图
- 交互图又分为序列图、时序图、通讯图、交互概览图
结构型的图(Structure Diagram)
- 类图(Class Diagram)
- 对象图(Object Diagram)
- 构件图(Component Diagram)
- 部署图(Deployment Diagram)
- 包图(Package Diagram)
行为型的图(Behavior Diagram)
- 活动图(Activity Diagram)
- 状态机图(State Machine Diagram)
- 顺序图(Sequence Diagram)
- 通信图(Communication Diagram)
- 用例图(Use Case Diagram)
- 时序图(Timing Diagram)
面向对象基础设计原则
面向对象设计原则概述
- 衡量软件设计质量的首要标准是该设计是否能满足软件的功能需求。不过,仅仅满足功能性需求的设计并不见得是好的设计。除了功能需求以外,还有很多衡量软件设计质量的标准,例如:
- 可读性:软件的设计文档是否轻易被其他程序员理解。可读性差的设计会给大型软件的开发和维护过程带来严重的危害
- 可复用性:软件系统的架构、类、组件等单元能否很容易被本项目的其它部分或者其它项目复用
- 可扩展性:软件面对需求变化时,功能或性能扩展的难易程度
- 可维护性:软件维护(主要是指软件错误的修改、遗漏功能的添加等)的难易程度
- 上述衡量标准之间存在紧密的关联。例如,可读性好的软件维护起来就会容易一些,扩展性好的软件复用时往往也会非常简单
但是,这些衡量标准大都比较抽象,程序员在开发过程中很难把握。相对而言,以下两个更为具体的衡量标准——内聚度和耦合度——就更直接和实用一些。事实上,如果一个软件的内聚度和耦合度都符合要求,它也就自然具备了比较好的复用性、可扩展性和可维护性
- 内聚度:表示一个应用程序的单个单元所负责的任务数量和多样性。内聚与单个类或者单个方法单元相关。好的软件设计应该做到高内聚。理想状态下,一个代码单元应该负责一个内聚的任务,也就是说一个任务可以看作是一个逻辑单元。一个方法应该实现一个逻辑操作,一个类应该代表一种类型的实体。内聚原则背后的主要原因是重用:如果一个方法或一个类只负责一个定义明确的事情,那么在不同的上下文环境中,它就能更好地被再次使用。遵循该规则的另一个优点是,当一个应用程序的某些方面需要做出改变时,我们能够在相同单元中找到所有相关的部分。如果一个系统单元只负责一件事情,就说明这个系统单元有很高的内聚度;如果一个系统单元负责了很多不相关的事情,则说明这个系统单元是内聚度很低。内聚度很高的系统单元通常很容易理解,很容易被复用、扩展和维护。有一个简单的方法可以粗略判断系统单元的耦合度:如果一个方法可以用简单的“动词+名词”的形式来命名(例如,loadFile()、getName()),或者如果一个类可以用准确的名词来命名(例如,Employee、Student),那么这样的类或者方法就是内聚度较高的系统单元;反之,如果类或者方法的名字必须包含“和”、“或”等字样才能准确反映其功能特性的话,这些类或方法的内聚度就一定不高
耦合度:在前一章我们已经学习了类和类之间的继承和关联关系。耦合度表示类之间关系的紧密程度。耦合度决定了变更一个应用程序的容易程度。在紧密耦合的类结构中,更改一个类会导致其它的类也随之需要做出修改。显然,这是我们在类设计时应该避免的,因为微小的修改会迅速波动影响到整个应用程序。此外,找到需要修改的所有的地方是必须的,实际上就使得修改变得困难并且耗费时间。而在松散耦合的系统中,我们可以更改一个类,不需要修改其它类,而应用程序仍然能够正常工作
- 概括起来,较低的耦合度和较高的内聚度,也即我们常说的“高内聚、低耦合”是所有优秀软件的共同特征
那么,在面向对象的软件设计时,如何做到高内聚、低耦合呢?这就需要我们在设计时遵循一定的设计原则
- 面向对象软件设计大师们在长期的软件设计实践中,总结出七条软件设计的基本原则,包括:单一职责、开闭原则、里氏替换原则、依赖倒转原则、接口隔离原则、组合/聚合复用原则、迪米特法则。遵循这七条原则来设计类,将提高软件系统的质量,做到高内聚、低耦合,从而实现较高可扩展性、可复用性、可维护性。这些面向对象的设计原则是面向对象设计的基本指导思想。下表列出了这些面向对象设计原则的概念以及相对重要性
这些设计原则并不是孤立存在的,它们相互依赖,相互补充
概括地讲,面向对象设计原则仍然是面向对象思想的体现。例如,单一职责原则与接口隔离原则体现了封装的思想;开放封闭原则体现了对象的封装与多态;里氏替换原则是对对象继承的规范;至于依赖倒转原则,则是多态与抽象思想的体现。在充分理解面向对象思想的基础上,掌握基本的设计原则,并能够在项目设计中灵活运用这些原则,就能够改善我们的设计,尤其能够保证可重用性、可维护性与可扩展性等系统的质量属性。这些核心要素与设计原则,就是我们设计的对象法则,它们是理解和掌握后面我们将要学习的设计模式的必备知识
下面是对上述每种设计原则的详细讲解:
1、单一职责原则
单一职责原则(Single Responsibility Principle,SRP)是指:所有的对象都应该有单一的职责,它提供的所有的服务也都仅围绕着这个职责。换句话说就是:一个类而言,应该仅有一个引起它变化的原因,永远不要让一个类存在多个改变的理由
要理解单一职责原则,首先我们要理解什么是类的职责。类的职责是由该类的对象在系统中的角色所决定的。举例来讲,教学管理系统中,老师就代表着一种角色,这个角色决定老师的职责就是教学。而要完成教学的职责,老师需要讲课、批改作业,而讲课、批改作业的行为就相当于我们在程序中类的方法,类的方法和属性就是为了完成这个职责而设置的
类的单一职责是说一个类应该只做一件事情。如果类中某个方法或属性与它所要完成的职责无关,或是为了完成另外的职责,那么这样的设计就不符合类的单一职责原则。而这样的设计的缺点是降低了类的内聚性,增强了类的耦合性。由此带来的问题是当我们使用这个类时,会把原本不需要的功能也带到了代码中,从而造成冗余代码或代码的浪费
例如,考虑下图所示的设计。Rectangle(矩形)类有两个方法,方法draw()用于在屏幕上绘制一个矩形,方法area()用于计算矩形的面积。有两个不同的应用程序都要使用Rectangle类,一个是几何计算系统,Rectangle类会在几何形状计算方面为它提供帮助;一个是绘图系统,它需要使用Rectangle类在屏幕上绘制矩形
这种设计违反了单一职责原则,因为Rectangle类具有两个职责:一个职责提供画矩形几何图形的功能,一个职责提供了计算矩形面积的功能
违反单一职责原则会导致一些严重的问题。首先,因为几何计算系统要使用Rectangle类,而Rectangle类又要使用GUI包。所以,在部署安装几何计算系统时,我们必须将GUI包中的类代码一起部署安装。其次,如果因为某些原因,绘图系统的改变导致了Rectangle类的改变,那么这个改变就强迫我们重新构建、测试和部署几何计算系统。如果忘记了这一点,那么几何计算系统可能会以不可预测的方式失败
一个较好的设计是把Rectangle类的两个职责分离到下图所示的两个不同的类中,一个类提供计算面积的职责,一个类提供画矩形的职责。现在矩形绘制方式的改变就不会对几何计算系统造成影响
一个类如果有多个职责,也有多个改变它的理由。反之,如果你能想到一个类存在多个使其改变的原因,那么这个类就存在多个职责。例如上面提到的矩形类,要改变图形界面中矩形的外观要改动它,要改变矩形的几何运算也要改到它。因此这个矩形类要被拆成两个
单一职责原则提出了对对象职责的一种理想期望。对象不应该承担太多职责,正如人不应该一心分为二用。唯有专注,才能保证对象的高内聚;唯有单一,才能保证对象的细粒度。对象的高内聚与细粒度有利于对象的重用。一个庞大的对象承担了太多的职责,当客户端需要该对象的某一个职责时,就不得不将所有的职责都包含进来,从而造成冗余代码或代码的浪费
单一职责原则还有利于对象的稳定。对象的职责总是要提供给其他对象调用,从而形成对象与对象的协作,由此产生对象之间的依赖关系。对象的职责越少,则对象之间的依赖关系就越少,耦合度减弱,受其他对象的约束与牵制就越少,从而保证了系统的可扩展性
单一职责原则并不是极端地要求我们只能为对象定义一个职责,而是利用极端的表述方式重点强调:在定义对象职责时,必须考虑职责与对象之间的所属关系。职责必须恰如其分地表现对象的行为,而不至于破坏和谐与平衡的美感,甚至格格不入。换言之,该原则描述的单一职责指的是公开在外的与该对象紧密相关的一组职责。例如,在媒体播放器中,可以在MediaPlayer类中定义一组与媒体播放相关的方法,如Open()、Play()、Stop()等。这些方法从职责的角度来讲,是内聚的,完全符合单一职责原则中“专注于做一件事”的要求。如果需求扩充,需要我们提供上传、下载媒体文件的功能,那么在设计时,就应该定义一个新类如MediaTransfer,由它来承担这一职责;而不是为了方便,草率地将其添加到MediaPlayer类中
2、开闭原则
开闭原则(Open-Close Principle,简称OCP)是指一个软件实体(类、模块、方法等)应该对扩展开放,对修改关闭
遵循开闭原则设计出来的模块具有两个基本特征:
- 对于扩展是开放的(Open for extension):模块的行为可以扩展,当应用的需求改变时,可以对模块进行扩展,以满足新的需求
- 对于更改是封闭的(Closed for modification):对模块行为扩展时,不必改动模块的源代码或二进制代码
- 这两个特征看起来是相互矛盾的。扩展模块的行为通常需要修改该模块的源代码,而不允许修改的模块通常被认为是具有固定的行为
那么,如何在不修改模块源代码的情况下去修改它的行为呢?或者怎样才能在无需对模块进行改动的情况下就改变它的功能呢?
实现开闭原则的关键在于抽象化。在Java中,抽象化的具体实现就是使用抽象类或接口。然而,到底该抽象化什么呢?到底该将什么东西抽象为抽象类或者接口呢?
在实际面向对象设计阶段,抽象化可能出现在两种情况下。一种情况是针对多个领域类的抽象化,一种情况针对单个领域类的抽象化
- 在面向对象分析阶段,我们得到的领域模型中会存在多个具有相同行为的领域类。在设计阶段,我们可以使用抽象类或者接口,将一组对象的共同行为抽象到抽象类或者接口中,而将不同行为的实现封装在子类或者实现类中。接口或抽象类是不能实例化的,因此对修改就是关闭的;而添加新功能只要实现接口或者继承抽象类,从而实现对扩展开放
- 1.使用抽象类。在设计类时,对于拥有共同功能的相似类进行抽象化处理,将公用的功能部分放到抽象类中,而将不同的行为封装在子类中。这样,在需要对系统进行功能扩展时,只需要依据抽象类实现新的子类即可。在扩展子类时,不仅可以拥有抽象类的共有属性和共有方法,还可以拥有自定义的属性和方法
- 2.使用接口。与抽象类不同,接口只定义实现类应该实现的接口方法,而不实现公有的功能。在现在大多数的软件开发中,都会为实现类定义接口,这样在扩展子类时必须实现该接口。如果要改换原有的实现,只需要改换一个实现类即可
比如开发一个发工资程序。老板要为公司中的年薪制员工(用Salary类表示)、按小时付费员工(用Hourly类表示)、合同工(用Contractor类表示)发工资,还要为他们邮寄支票。不同类型的员工薪酬计算的方法有所不同。通过面向对象分析技术,分析上面问题域中的名词,我们很容易得到Boss、Salary、Hourly、Contractor几个领域类
- 根据开闭原则,为了实现对修改关闭,对扩展开放,我们设计出一个抽象类Employee,将Salary类、Hourly类和Contractor类共有的行为(邮寄支票mailCheck())放在该抽象类中,将不同的行为(计算薪酬computePay())在Employee中用抽象方法定义,具体的实现放到Employee的子类Salary、Hourly和Contractor中,其设计类图下图所示
- 于是,Boss类就依赖于抽象类Employee,而不依赖于具体的实现类Salary、Hourly、Contractor等
- 当添加新的员工类型,出现新的子类时,或者薪酬计算方式变更时,Boss类的代码就不会受到影响,从而实现对修改关闭。同时,具体的子类可以完全替换抽象父类Employee的行为
- 当新添加一个员工类型时,不会对Boss类的代码产生任何影响,从而实现了对扩展开放
使用抽象化技术的另一种情况是针对单个领域类,将单个领域类中可能会发生变化的行为进行封装,也就是找出类中可能需要变化之处,把它们封装成抽象类或者接口,从而将变化点与不需要变化的代码分离。如果每次新的需求一来,都会使一个领域类的某个行为的代码发生变化,那么我们就可以确定,这部分的代码需要被抽象出来,和其它稳定的代码有所区分。把会变化的部分取出并封装出抽象类或接口,以便以后可以轻易地改动或扩充此部分,而不会影响不需要变化的其它部分。封装变化点的好处在于,将类中经常变化的部分和稳定的部分隔离,有助于增加复用性,并降低系统耦合度
- 例如,假如我们从分析阶段得到一个领域类Guitarist代表吉他演奏家。吉他演奏家的行为包括可以挑选演奏的曲目(setupMusic),可以对吉他校音(tuneInstrument),可以演奏曲目(play),类图下图所示
- 对于Guitarist类来说,setupMusic() 和 tuneInstrument() 相当稳定,但是play()方法却并非稳定的。一首歌曲可以有几种不同的演奏风格:古典风格、民谣风格、佛拉明戈风格、摇滚风格等。这就意味着根据演奏风格的不同,play()方法有所不同。因为根据演奏风格,类的行为会有所改变,我们就需要将这个行为抽象出来,将它封装到另一个类中。下图是封装并隔离变化点后的类图
这里,我们在Guitarist类和GuitarStyle抽象类之间使用了关联,这就允许Guitarist使用继承自抽象类GuitarStyle的具体类。我们已经将play()方法抽象出并封装到另一个类中,这样就将肯定会改变的演奏方式行为与Guitarist类的其它稳定行为隔离了。通过封装变化点,将肯定要发生变化的内容(演奏风格)抽象出来成为一个单独的类,从而实现了对修改关闭,对扩展开放
开闭原则是面向对象设计的核心所在。遵循这个原则可以带来灵活性、可重用性和可维护性。其它设计原则(里氏替换原则、依赖倒转原则、组合/聚合复用原则、迪米特法则、接口隔离原则)是实现开闭原则的手段和工具
3、里氏替换原则
在Java中,支持抽象和多态的关键机制之一是继承。正是使用了继承,我们才可以创建实现父类中抽象方法的子类。那么,是什么规则在支配着这种特殊的继承用法呢?最佳的继承层次的特征又是什么呢?在什么情况下会使我们创建的类层次结构掉进不符合开闭原则的陷阱中呢?这就是里氏替换原则要解决的问题
里氏替换原则(The Liskov Substitution Principle,LSP)是由Barbara Liskov女士于1988年提出的,其定义为:“如果对于类型S的每个对象O1存在类型T的对象O2,那么对于所有定义了T的程序P来说,当用O1替换 O2并且S是T的子类型时,P的行为不会改变”。通俗地讲,就是:在一个软件系统中,子类应该能够完全替换任何父类能够出现的地方,并且经过替换后,不会让调用父类的客户程序从行为上有任何改变
里氏替换原则是使代码符合开闭原则的一个重要的保证,同时,它体现了:
Ø 类的继承原则:里氏替换原则常用来检查两个类是否为继承关系。在符合里氏替换原则的继承关系中,使用父类代码的地方,用子类代码替换后,能够正确的执行动作处理。换句话说,如果子类替换了父类后,不能够正确执行动作,那么他们的继承关系就是不正确的,应该重新设计它们之间的关系
Ø 动作正确性保证:里氏替换原则对子类进行了约束,所以在为已存在的类进行扩展,来创建一个新的子类时,符合里氏替换原则的扩展不会给已有的系统引入新的错误
下面我们用示例来阐述。 “正方形不是长方形”是一个理解里氏替换原则的最经典的例子。在数学领域里,正方形毫无疑问是长方形,它是一个长宽相等的长方形。所以,我们开发的一个与几何图形相关的软件系统中,让正方形继承自长方形是顺利成章的事情,于是我们有了如下图所示的设计
- 代码清单1列出了长方形类Rectangle的实现代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/*代码清单1 Rectangle.java
* 矩形类的实现代码,用于演示LSP
*/
package ch02.LSP;
public class Rectangle {
private double length;
private double width;
public double getLength() {
return length;
}
public void setLength(double length) {
this.length = length;
}
public double getWidth() {
return width;
}
public void setWidth(double width) {
this.width = width;
}
}
- 代码清单2列出了正方形类Square的实现代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/*代码清单2 Square.java
* 正方形的实现代码,用于演示LSP
*/
package ch02.LSP;
public class Square extends Rectangle {
public void setWidth(double width) {
super.setLength(width);
super.setWidth(width);
}
public void setLength(double length) {
super.setLength(length);
super.setWidth(length);
}
}
- 假如已有的系统中有代码清单3所示的业务逻辑:长乘以宽必须等于20。当我们使用父类Rectangle调用该业务逻辑时,代码能顺利通过。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/*代码清单3 TestRect.java
* 测试程序,用于演示LSP
*/
package ch02.LSP;
public class TestRect {
public static void main(String[] args) {
TestRect tr = new TestRect();
Rectangle r = new Rectangle();
tr.g(r);
}
public void g(Rectangle r) {
r.setWidth(5);
r.setLength(4);
if (r.getWidth()*r.getLength()!=20) {
throw new RuntimeException();
}
}
}
如果我们将代码清单3的第8、9行替换成如下代码,使用子类Square替换父类Rectangle,调用相同的业务逻辑,运行时就会抛出一个RuntimeException异常。
1
2
Rectangle s = new Square();
tr.g(s);
由此,我们得出结论:父类Rectangle不能被子类Square替换,如果进行了替换就得不到预期结果。因此,Square类和Rectangle类之间的继承关系违反了里氏替换原则,它们之间的继承关系不成立,正方形不是长方形。(注意:这里的错误并不是说继承有问题,而是不同类型时候计算不一样导致的问题!)
“正方形不是长方形”,正方形是长方形也不是长方形,这样结论似乎就是个悖论。产生这种混乱的原因有两个:
原因一:对类的继承关系的定义没有搞清楚
- 面向对象的设计关注的是对象的行为,它是使用“行为”来对对象进行分类的,只有行为一致的对象才能抽象出一个类来。我们说类的继承关系就是一种“is-a”关系,实际上指的是行为上的“is-a”关系,可以把它描述为“表现为,act as”
正方形在设置长度和宽度这两个行为上,与长方形显然是不同的。长方形的行为:设置长方形的长度的时候,它的宽度保持不变,设置宽度的时候,长度保持不变。正方形的行为:设置正方形的长度的时候,宽度随之改变;设置宽度的时候,长度随之改变。所以,如果我们把这种行为加到父类长方形的时候,就导致了正方形无法继承这种行为。我们“强行”把正方形从长方形继承过来,就造成无法达到预期的结果
原因二:设计要依赖于用户需求和具体环境
- 继承关系要求子类要具有基类全部的行为。这里的行为是指落在需求范围内的行为
这里我们以另一个理解里氏替换原则的经典例子“鸵鸟非鸟”来做示例。生物学中对于鸟类的定义是“恒温动物,卵生,全身披有羽毛,身体呈流线形,有角质的喙,眼在头的两侧。前肢退化成翼,后肢有鳞状外皮,有四趾”。从生物学角度来看,鸵鸟肯定是一种鸟,是一种继承关系。但是根据上一个“正方形非长方形”的例子,鸵鸟和鸟之间的继承关系又可能不成立。那么,鸵鸟和鸟之间到底是不是继承关系如何判断呢?这需要根据用户需求来判断
现在鸟类有四个对外的行为,其中两个行为分别落在A和B系统需求中,如下图所示
A需求期望鸟类提供与飞翔有关的行为,即使鸵鸟跟普通的鸟在外观上就是100%的相像,但在A需求范围内,鸵鸟在飞翔这一点上跟其它普通的鸟是不一致的,它没有这个能力,所以,鸵鸟类无法从鸟类派生,鸵鸟不是鸟
B需求期望鸟类提供与羽毛有关的行为,那么鸵鸟在这一点上跟其它普通的鸟一致的。虽然它不会飞,但是这一点不在B需求范围内,所以,它具备了鸟类全部的行为特征,鸵鸟类就能够从鸟类派生,鸵鸟就是鸟
所有子类的行为功能必须和使用者对其父类的期望保持一致,如果子类达不到这一点,那么必然违反里氏替换原则。在实际的开发过程中,不正确地滥用继承关系是非常有害的。伴随着软件开发规模的扩大,参与的开发人员也越来越多,每个人都在使用别人提供的组件,也会为别人提供组件。最终,所有人的开发的组件经过层层包装和不断组合,被集成为一个完整的系统。每个开发人员在使用别人的组件时,只需知道组件的对外裸露的接口,那就是它全部行为的集合,至于内部到底是怎么实现的,无法知道,也无须知道。所以,对于使用者而言,它只能通过接口实现自己的预期,如果组件接口提供的行为与使用者的预期不符,错误便产生了。里氏替换原则就是在设计时避免出现子类与父类不一致的行为
里氏替换原则目的就是要保证继承关系的正确性。那么,对于违反里氏替换原则的继承关系,我们该如何修正呢?
如果父类A和子类B之间的关系违反了里氏替换原则,那么A和B就不适合设计为继承关系。我们就要重新设计二者之间的关系。设计方案有两种,需要根据具体情况进行选择:
- 创建一个新的抽象类或者接口,作为两个具体类的基类。将具体类A和B的共同行为转移到C中,从而解决A和B行为不一致的问题
- 将B到A的继承关系改为委托关系。具体参考组合/聚合复用原则
对于“正方形非长方形”问题,既然二者之间的继承关系违反了里氏替换原则,我们就应该重新设计二者之间的关系。我们可以采用第一种方案,正方形和长方形的共同行为(getLength()、getWidth()方法)抽象并封装转移到一个抽象类或者接口中,比如一个“四方形”接口或者抽象类,然后让正方形和长方形分别实现四方形接口或者继承四方形抽象类,如下图所示
- 一般来说,只要有可能,就不要从具体类继承。如图2.9所示的类图就给出了一个继承形成的等级结构的典型例子。从图可以看出,所有的继承都是从抽象类开始,而所有的具体类都没有子类。也就是说,在一个由继承关系形成的等级结构中,树叶节点都应当是具体类,树枝节点都应该是抽象类或者接口
- 里氏替换原则实现了开闭原则中的对扩展开放。实现开闭原则的关键步骤是抽象化,父类与子类之间的继承关系就是一种抽象化的体现。因此,里氏替换原则是实现抽象化的一种规范。只要能做到子类可以完全替代其父类的行为,那么新增加的具体子类在重写父类的行为时,不会对客户代码产生任何不良的影响(即保证原来的抽象类仍代表父集合的共性),从而实现对扩展开放。违反里氏替换原则意味着违反了开闭原则,反之未必。里氏替换原则是使代码符合开闭原则的一个重要保证
4、依赖倒转原则
依赖倒转原则(Dependency Inversion Principle,简称DIP)是指将两个模块之间的依赖关系倒置为依赖抽象类或接口。具体有两层含义:
- 高层模块不应该依赖于低层模块,二者都应该依赖于抽象
- 抽象不应该依赖于细节,细节应该依赖于抽象
依赖倒转原则用于指导我们如何正确地消除模块间的依赖关系,同时它也是我们以后要学习到的Spring、Hibernate等JavaEE框架设计的核心原则
所谓依赖是指如果一个模块A使用另一个模块B,我们称模块A依赖模块B。在应用程序中,有一些低层次的类,这些类实现了一些基本的或初级的操作,我们称之为低层模块;另外,有一些高层次的类,这些类封装了某些复杂的逻辑,这些类我们称之为高层模块。高层次模块要完成自己封装的功能,就必须要使用低层模块,于是高层模块就依赖于低层模块
高层模块依赖于低层模块的现象,在传统的结构化程序设计中,是非常常见的。因为结构化程序设计就是采用由上到下、逐层分解的策略,把大型和复杂的软件系统分解成若干个人们易于理解和易于分析的子系统。这里的分解是根据软件系统的逻辑特性和系统内部各成分之间的逻辑关系进行的。在分解过程中,被分解的上层就是下层的抽象,下层为上层的具体细节。这样,就造成高层抽象模块依赖低层模块;抽象层依赖具体层。但在实际系统中,抽象层是相对稳定的,而低层模块却是经常变动的。因为高层模块依赖于低层模块,一旦低层模块发生改变,高层模块也会受到影响。为了保持系统的稳定,应该使低层模块依赖于高层模块。因此,结构化程序设计的方法是不正确的
那么,如何让低层模块依赖于高层模块呢?我们知道,高层模块肯定要使用低层模块提供的服务,不可能不让二者之间完全不存在依赖关系。但是,在面向对象设计中,类和类之间依赖关系可以分为两种类型:
- 具体耦合关系:发生在两个具体的(可实例化的)类之间,经由一个类对另一个具体类的直接引用造成
- 抽象耦合关系:发生在一个具体类和一个抽象类(或接口)之间,使两个必须发生关系的类之间存有最大的灵活性
如果高层模块直接调用低层模块提供的服务,那么就是具体耦合关系,这样高层模块依赖于低层模块就不可避免。但是,如果我们使用抽象耦合关系,在高层模块和低层模块之间定义一个抽象接口,高层模块调用抽象接口定义的方法,低层模块实现该接口。这样,就消除了高层模块和低层模块之间的直接依赖关系。现在,高层模块就不依赖于低层模块了,二者都依赖于抽象。同时也实现了“抽象不应该依赖于细节,细节应该依赖于抽象”
依赖倒转原则的本质就是要求将类之间的关系建立在抽象接口的基础上的。通过上面的方式,将错误的依赖关系倒转过来,使具体实现类依赖于抽象类和接口。这就是依赖倒转原则中“倒转”的由来
以抽象方式耦合是依赖倒转原则的关键。抽象耦合关系总要涉及具体类从抽象类继承,并且需要保证在任何引用到基类的地方都可以改换成其子类,因此,里氏替换原则是依赖倒转原则的基础
依赖倒转原则带来的一个启示是:针对接口编程,而不是针对实现编程。也就是说,当客户要使用一个接口的实现类功能时,应该针对定义这些功能的接口编程,而不是针对该接口的实现类编程
例如,假如我们需要开发一个程序描述不同类型的几何图形,并计算这些几何图形的面积和周长。我们知道,不同的几何图形计算周长和面积的方法不一样。根据开闭原则,我们应该封装变化点,把要变化的部分封装起来。于是,很容易得到一个设计:用类Point代表一个点,用接口Shape定义所有几何图形的共同特征——面积和周长,如果要创建不同的几何图形,就要创建具体类来实现Shape接口。这意味着具体类必须实现Shape接口的所有抽象方法。这样就得到如下图所示的类图
- 到现在为止,我们的设计是满足开闭原则的,没有任何问题。如果要计算各个几何图形的周长和面积,我们可以为每个不同的几何形状编写一个单独的应用程序,在应用程序中使用几何图形的具体实现类,如下图所示
这样做有什么问题呢?很显然,我们写了三个不同的程序做同一件事情。如果要添加另一个几何图形,比如菱形(Rhombic),就必须编写两个新的类,一个实现Shape接口的Rhombus类,一个RhombusApp类。这样做效率显然很低,也违反了依赖倒转原则(高层模块RectangleApp等依赖于低层模块Rectangle)
那么我们如何纠正呢?回忆我们在Java面向对象编程中所学的多态机制,我们完全可以面向父类型——接口编程,执行时候根据动态绑定机制,动态决定执行哪个具体类的方法
在计算几何图形周长和面积的应用中,既然已经有了很多实现Shape接口的几何图形类,我们就可以只创建一个程序,完成所有几何图形周长和面积的计算。代码清单4所示的ShapeTest程序演示了这种面向接口编程的方法,在程序的main()方法中创建一个Shape类型的集合,用不同的几何形状填充集合,然后对集合进行遍历,计算周长和面积
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
/*代码清单4 ShapeTest.java
* 演示面向接口编程
*/
package ch02;
import java.util.*;
public class ShapeTest {
public static void main(String [] args) {
List<Shape> figures = new ArrayList<Shape>();
figures.add(new Rectangle(10, 20));
figures.add(new Circle(10));
Point p1 = new Point(0.0, 0.0);
Point p2 = new Point(5.0, 1.0);
Point p3 = new Point(2.0, 8.0);
figures.add(new Triangle(p1, p2, p3));
Iterator<Shape> iter = figures.iterator();
while (iter.hasNext()) {
Shape nxt = iter.next();
System.out.printf("面积 = %8.4f 周长= %8.4f\n",
nxt.computeArea(), nxt.computePerimeter());
}
}
}
在上述代码中,我们面向接口而不是面向实现编程,针对接口Shape编程,而不是针对接口的具体实现类Circle、Rectangle、Triangle等编程,程序的灵活性、可扩展性更强
依赖倒转原则实现了开闭原则中对变更封闭。因为客户代码依赖于抽象类或者接口,而非具体类。抽象类或者接口代表了父集合的共性,它通常是比较稳定的(不会变化),因而可以在具体子类变化时,保证客户代码不受影响,从而实现对变更封闭
5、组合/聚合复用原则
- 组合/聚合复用原则(Composite/Aggregation Reuse Principle,CARP)是指要尽量使用组合/聚合而非继承来达到复用目的。另一种解释是在一个新的对象中使用一些已有的对象,使之成为新对象的一部分;新的对象通过向这些对象委托功能达到复用这些对象的目的
- 在面向对象的设计中,有两种方法可以实现对已有对象重用的目的,即通过组合/聚合,或者通过继承。那么,这两种不同的复用方式在可维护性方面有什么区别呢?
1)组合/聚合复用
- 我们知道组合/聚合都是关联关系的特殊种类,二者都是体现整体与部分的关系,也就是两个类之间的是“has-a”关系,它表示某一个角色具有某一项责任。由于组合/聚合都可以将已有的对象加入到新对象中,使之成为新对象的一部分,因此新对象可以调用已有对象的功能,从而实现对象复用
例如,一台计算机,是由CPU、内存、输入设备、输出设备和外存等组装而成。计算机对象为整体,CPU、内存、输入设备、输出设备和外存等为部分,它们是聚合关系。如果一台计算机没有打印功能,可以加入一个打印机,使打印机成为计算机的一部分,从而重用打印机的打印功能。换一种角度来看,如果需要计算机有打印的责任,那么就可以将该责任委托给作为部分的打印机
- 使用组合/聚合实现复用有如下好处:
- 新对象存取成分对象的唯一方法是通过成分对象的接口
- 这种复用是黑箱复用,因为成分对象的内部细节是新对象所看不见的
- 这种复用所需的依赖较少
- 每一个新的类可以将焦点集中在一个任务上
- 这种复用可以在运行时间内动态进行,作为整体的新对象可以动态地引用与部分对象类型相同的对象。也就是说,组合/聚合是动态行为,即运行时行为。可以通过使用组合/聚合的方式在设计上获得更高的灵活性
- 当然,这种复用也有缺点。其中最主要的缺点就是系统中会有较多的对象需要管理
- 一般来说,如果一个角色得到了更多的责任,就可以使用组合/聚合关系将新的责任委派到合适的对象上
2)继承复用
- 继承是面向对象语言特有的复用工具。由于使用继承关系时,新的实现较为容易,因父类的大部分功能可以通过继承的关系自动进入子类;同时,修改和扩展继承而来的实现较为容易。于是,在面向对象设计理论的早期,程序设计师十分热衷于继承,好像继承就是最好的复用手段,于是继承也成为了最容易被滥用的复用工具。然而,继承有多个缺点:
- 继承复用破坏封装,因为继承将父类的实现细节暴露给子类。由于父类的内部细节常常是对于子类透明的,所以这种复用是透明的复用,又称“白箱”复用
- 如果父类发生改变,那么子类的实现也不得不发生改变
- 从父类继承而来的实现是静态的,也就是编译时行为,不可能在运行时间内发生改变,没有足够的灵活性
正是因为继承有上述缺点,所以应首先使用组合/聚合,其次才考虑继承,达到复用的目的。并且在使用继承时,要严格遵循里氏替换原则。有效地使用继承会有助于对问题的理解,降低复杂度,而滥用继承会增加系统构建、维护时的难度及系统的复杂度
- 要正确的选择组合/聚合和继承,必须透彻的理解里氏替换原则和Coad法则。里氏替换原则前面学习过,Coad法则由Peter Coad提出,总结了一些什么时候使用继承作为复用工具的条件。只有当以下的Coad条件全部被满足时,才应当使用继承关系:
- 子类是父类的一个特殊种类,而不是父类的一个角色,也就是区分“has-a”和“is-a”。只有“is-a”关系才符合继承关系,“has-a”关系应当用组合/聚合来描述
- 永远不会出现需要将子类换成另外一个类的子类的情况。如果不能肯定将来是否会变成另外一个子类的话,就不要使用继承
- 子类具有扩展父类的责任,而不是具有置换(重写)或注销掉父类的责任。如果一个子类需要大量的置换掉父类的行为,那么这个类就不应该是这个父类的子类
- 只有在分类学角度上有意义时,才可以使用继承。不要从工具类继承
- 错误的使用继承而不是组合/聚合的一个常见原因是错误的把“has-a”当成了“is-a”
- “is-a”代表一个类是另外一个类的一种
- “has-a”代表一个类是另外一个类的一个角色,而不是另外一个类的特殊种类
- 我们看一个例子。如果我们把“人”当成一个类,然后把“雇员”、“经理”、“学生”当成是“人”的子类,如下图所示。这种设计的错误在于把“角色”的等级结构和“人”的等级结构混淆了。“经理”、“雇员”、“学生”是一个人的角色,一个人可以同时拥有上述角色。如果按继承来设计,那么如果一个人是雇员的话,就不可能是经理,也不可能是学生,这显然不合理
- 正确的设计是有个抽象类“角色”,“人”可以拥有多个“角色”(聚合),“雇员”、“经理”、“学生”是“角色”的子类,如下图所示
- 此外,只有两个类满足里氏替换原则的时候,才可能是“is-a”关系。也就是说,如果两个类是“has-a”关系,但是设计成了继承,那么肯定违反里氏替换原则
6、接口隔离原则
通过对前面几个设计原则的讨论,我们会发现接口是一个好东西,因为使用接口,可以使代码灵活性和可维护性更强。但是,在设计接口时,我们还必须遵循接口隔离原则
接口隔离原则(Interface Segregation Principle,简称ISP)是指客户不应该依赖它们用不到的方法,只给每个客户它所需要的接口。换句话说,就是不能强迫用户去依赖那些他们不使用的接口
接口隔离原则实际上包含了两层意思:
- 接口的设计原则:接口的设计应该遵循最小接口原则,不要把用户不使用的方法塞进同一个接口里。如果一个接口的方法没有被使用到,则说明该接口过胖,应该将其分割成几个功能专一的接口,使用多个专门的接口比使用单一的总接口要好
- 接口的继承原则:如果一个接口A继承另一个接口B,则接口A相当于继承了接口B的方法,那么继承了接口B后的接口A也应该遵循上述原则:不应该包含用户不使用的方法。反之,则说明接口A被B给污染了,应该重新设计它们的关系
如果用户被迫依赖他们不使用的接口,当接口发生改变时,他们也不得不跟着改变。换而言之,一个用户依赖了未使用但被其他用户使用的接口,当其他用户修改该接口时,依赖该接口的所有用户都将受到影响。这显然违反了开闭原则,也不是我们所期望的
下面我们举例说明怎么设计接口或类之间的关系,使其不违反ISP原则
假如有一个门(Door),有锁门(lock)和开锁(unlock)功能。此外,可以在门上安装一个报警器而使其具有报警(alarm)功能。用户可以选择一般的门,也可以选择具有报警功能的门。分析需求,找出其中的名词,我们不难得到三个候选类:门(Door)、普通门(CommonDoor)、有报警功能的门(AlarmDoor)。我们该如何设计这三个候选类之间的关系呢?
最简单的设计就是将Door作为接口,在Door接口里定义所有的方法,让CommonDoor和AlarmDoor作为该接口的实现类,从而强制这两个类实现Door接口中的所有方法,如下所示。但这样一来,依赖Door接口的CommonDoor却不得不实现未使用的alarm()方法。很显然,这个Door接口有点肥胖,内聚性太差,违反了接口隔离原则
太肥胖的接口
那么,好吧,将报警功能从Door中分离出来,封装成一个Alarm接口。在Alarm接口定义alarm()方法,在Door接口定义lock()和unlock()方法,Door接口继承Alarm接口,如下图所示。现在一个接口根据功能分成了两个接口,貌似设计要更好一些。但是,可惜的是:跟第一种方法一样,依赖Door接口的CommonDoor却不得不实现未使用的alarm()方法,也就是说接口Door被接口Alarm“污染”了,这种设计同样违反了接口隔离原则
接口污染
很显然,太肥胖的接口以及接口污染都会造成用户依赖于他们不用的方法,从而违反了接口隔离原则。我们不应该强迫用户依赖于他们不用的方法。那么,到底如何做才能实现这一点呢?有两种方式:
通过多重继承分离接口。多重继承可以有两个方式,第一种方式是同时实现两个接口,属于多重接口继承;第二种方式是实现一个接口,同时继承一个具体类,实际上也是一种多重继承。现在我们继续本例的设计,这次我们为了避免太肥胖的接口以及接口污染,我们在Alarm接口中定义alarm()方法,在Door接口中定义lock()和unlock()方法,这两个接口之间无继承关系。CommonDoor实现Door接口。而AlarmDoor根据多重继承的实现方式,分为两种方案
第一种方案为:AlarmDoor类同时实现Door和Alarm接口,如下图所示
- 通过多重继承分离接口方式一
- 第二种方案为:继承CommonDoor,并实现Alarm接口,如下图所示。这两种设计方案都将Door接口和Alarm接口分离了,避免了肥胖的接口和接口污染,都遵循了接口隔离原则,但是第二种方案更具有实用性
- 通过多重继承分离接口方式二
- 第三种方案为:通过委托分离接口。在这种方法里,AlarmDoor实现了Alarm接口,同时把功能lock和unlock委托给CommonDoor对象完成。这种设计遵循了接口隔离原则。实际上,这种方法是对第三种方法的第二个方案应用了组合/聚合复用原则,将AlarmDoor和CommonDoor的继承关系转换为聚合关系
- 通过委托分离接口
- 接口隔离原则从对接口的使用上,为我们对接口抽象的颗粒度建立了判断基准:在为系统设计接口的时候,使用多个专门的接口代替单一的胖接口
7、迪米特法则
我们知道,面向对象的系统是由很多对象组成的,对象和对象之间交互,形成了整个系统。既然对象和对象之间存在交互,那么对象和对象之间就必然存在一定依赖关系。依赖即耦合,太多的类耦合在一起相互依赖,就会导致系统的可维护性变得糟糕起来。那么,如何才能避免这种情况呢?这就要求我们在设计中处理类和类之间的交互时,遵循迪米特法则
- 迪米特法则(Law of Demeter,简称LOD),又称为“最少知识原则”,它的定义为:一个软件实体应当尽可能少的与其他实体发生相互作用。这样,当一个模块修改时,就会尽量少的影响其他的模块,扩展会相对容易。迪米特法则是对软件实体之间通信的限制,它对软件实体之间通信的宽度和深度做出了要求。迪米特的其它表述方式为:
- 1.只与你直接的朋友们通信
- 2.不要跟“陌生人”说话
- 3.每一个软件单位对其他的单位都只有最少的知识,而且局限于那些与本单位密切相关的软件单位
例如,军队里面有元帅、军官和士兵,元帅认识军官,军官认识自己管辖的士兵。元帅要攻击敌军,他不必直接对士兵下命令,只需要下命令给自己认识的军官,由军官将指令转发给自己所辖士兵即可。用迪米特法则解释,元帅和军官、军官和士兵是“朋友”,元帅和士兵是“陌生人”,元帅只应该与自己直接的“朋友”——军官说话,不要跟“陌生人”——士兵说话
- 那么,如何界定朋友圈和陌生人呢?迪米特法则指出,做为“朋友”的条件为:
- 1.当前对象本身(this)
- 2.被当做当前对象的方法的参数传入进来的对象
- 3.当前对象的方法所创建或者实例化的任何对象
- 4.当前对象的任何组件(被当前对象的实例变量引用的任何对象)
任何一个对象,如果满足上面的条件之一,就是当前对象的“朋友”;否则就是“陌生人”
迪米特法则指出:就任何对象而言,在该对象的方法内,我们只应该调用属于上述“朋友圈”对象的方法。也就是说:如果两个类不必彼此直接通信,那么这两个类就不应当发生直接的相互作用。如果其中的一个类需要调用另一个类的某一个方法的话,可以通过第三者转发这个调用。不要同陌生人说话,也就是不要调用陌生人的方法
- 代码清单5是一个汽车类,展示遵循迪米特法则来调用方法的各种做法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/*代码清单2.5 ShapeTest.java
* 演示面向接口编程
*/
public class Car {
Engine engine; //这是类的一个组件,我们能够调用它的方法。
public void start(Key key) { //做为参数传递进来的对象key,其方法可以被调用
Doors doors = new Doors(); // 在方法中创建了一个新的对象,其方法可以被调用
boolean authorized = key.turns(); //调用被当作参数传递进来的对象的方法
if (authorized) {
engine.start(); //调用对象组件的方法
updateDashboardDisplay(); //调用同一对象内的本地方法
doors.lock(); //可以调用创建或实例化的对象的方法
}
}
public void updateDashboardDisplay() {
//更新显示
}
}
- 迪米特法则是一种面向对象系统设计风格的一种法则,尤其适合做大型复杂系统设计指导原则。但是也会造成系统的不同模块之间的通信效率降低,使系统的不同模块之间不容易协调等缺点。同时,因为迪米特法则要求类与类之间尽量不直接通信,如果类之间需要通信就通过第三方转发的方式,这就直接导致了系统中存在大量的中介类,这些类存在的唯一原因是为了传递类与类之间的相互调用关系,这就毫无疑问的增加了系统的复杂度。解决这个问题的方式是:使用依赖倒转原则,这要就可以是调用方和被调用方之间有了一个抽象层,被调用方在遵循抽象层的前提下就可以自由的变化,此时抽象层成了调用方的朋友






























