设计模式之六大设计原则
简介
该模块主要介绍设计模式的六大原则:
- 单一职责原则(Single Responsibility Principle)
- 里氏替换原则(Liskov Substitution Principle)
- 依赖倒转原则(Dependence Inversion Principle)
- 接口隔离原则(Interface Segregation Principle)
- 迪米特法则(Law of Demeter)
- 开闭原则(Open Closed Principle)
1.单一职责原则
优点:
- 类的复杂性降低,实现什么职责都有清晰明确的定义
- 可读性提高,复杂性降低,那当然可读性提高了
- 可维护性提高,可读性提高,那当然更容易维护了
- 变更引起的风险降低,变更是必不可少的,如果接口的单一职责做得好,一个接口修 改只对相应的实现类有影响,对其他的接口无影响,这对系统的扩展性、维护性都有非常大 的帮助
注意: 单一职责原则提出了一个编写程序的标准,用“职责”或“变化原因”来衡量接口或 类设计得是否优良,但是“职责”和“变化原因”都是不可度量的,因项目而异,因环境而异。
2.里氏替换原则
在面向对象的语言中,继承是必不可少的、非常优秀的语言机制,它有如下优点:
- 代码共享,减少创建类的工作量,每个子类都拥有父类的方法和属性;
- 提高代码的重用性;
- 子类可以形似父类,但又异于父类,“龙生龙,凤生凤,老鼠生来会打洞”是说子拥有 父的“种”,“世界上没有两片完全相同的叶子”是指明子与父的不同;
- 提高代码的可扩展性,实现父类的方法就可以“为所欲为”了,君不见很多开源框架的 扩展接口都是通过继承父类来完成的;
- 提高产品或项目的开放性。
自然界的所有事物都是优点和缺点并存的,即使是鸡蛋,有时候也能挑出骨头来,继承 的缺点如下:
- 继承是侵入性的。只要继承,就必须拥有父类的所有属性和方法;
- 降低代码的灵活性。子类必须拥有父类的属性和方法,让子类自由的世界中多了些约 束;
- 增强了耦合性。当父类的常量、变量和方法被修改时,需要考虑子类的修改,而且在 缺乏规范的环境下,这种修改可能带来非常糟糕的结果——大段的代码需要重构。
Java使用extends关键字来实现继承,它采用了单一继承的规则,C++则采用了多重继承 的规则,一个子类可以继承多个父类。从整体上来看,利大于弊,怎么才能让“利”的因素发
挥最大的作用,同时减少“弊”带来的麻烦呢?解决方案是引入里氏替换原则(Liskov Substitution Principle,LSP),什么是里氏替换原则呢?它有两种定义:
-
第一种定义,也是最正宗的定义:
If for each object o1 of type S there is an object o2 of type T such that for all programs P defined in terms of T,the behavior of P is unchanged when o1 is substituted for o2 then S is a subtype of T.(如果对每一个类型为S的对象o1,都有类型为T的对 象o2,使得以T定义的所有程序P在所有的对象o1都代换成o2时,程序P的行为没有发生变 化,那么类型S是类型T的子类型。)
-
第二种定义:
Functions that use pointers or references to base classes must be able to use objects of derived classes without knowing it.(所有引用基类的地方必须能透明地使用其子类的 对象。)
第二个定义是最清晰明确的,通俗点讲,只要父类能出现的地方子类就可以出现,而且 替换为子类也不会产生任何错误或异常,使用者可能根本就不需要知道是父类还是子类。但 是,反过来就不行了,有子类出现的地方,父类未必就能适应。
里氏替换原则为良好的继承定义了一个规范,一句简单的定义包含了4层含义。
- 子类必须完全实现父类的方法
- 子类可以有自己的个性
- 覆盖或实现父类的方法时输入参数可以被放大
- 覆写或实现父类的方法时输出结果可以被缩小
3.依赖倒转原则
依赖倒置原则(Dependence Inversion Principle,DIP)这个名字看着有点别扭,“依赖”还“倒置”,这到底是什么意思?依赖倒置原则的原始定义是:
High level modules should not depend upon low level modules.Both should depend upon abstractions.Abstractions should not depend upon details.Details should depend upon abstractions.
翻译过来,包含三层含义:
- 高层模块不应该依赖低层模块,两者都应该依赖其抽象
- 抽象不应该依赖细节
- 细节应该依赖抽象
更加精简的定义就是“面向接口编程”——OOD(Object-Oriented Design,面向对象设计)的精髓之一。
依赖倒置原则的本质就是通过抽象(接口或抽象类)使各个类或模块的实现彼此独立, 不互相影响,实现模块间的松耦合,我们怎么在项目中使用这个规则呢?只要遵循以下的几 个规则就可以:
-
每个类尽量都有接口或抽象类,或者抽象类和接口两者都具备
这是依赖倒置的基本要求,接口和抽象类都是属于抽象的,有了抽象才可能依赖倒置。
-
变量的表面类型尽量是接口或者是抽象类
很多书上说变量的类型一定要是接口或者是抽象类,这个有点绝对化了,比如一个工具 类,xxxUtils一般是不需要接口或是抽象类的。还有,如果你要使用类的clone方法,就必须 使用实现类,这个是JDK提供的一个规范。
-
任何类都不应该从具体类派生
如果一个项目处于开发状态,确实不应该有从具体类派生出子类的情况,但这也不是绝 对的,因为人都是会犯错误的,有时设计缺陷是在所难免的,因此只要不超过两层的继承都 是可以忍受的。特别是负责项目维护的同志,基本上可以不考虑这个规则,为什么?维护工 作基本上都是进行扩展开发,修复行为,通过一个继承关系,覆写一个方法就可以修正一个 很大的Bug,何必去继承最高的基类呢?(当然这种情况尽量发生在不甚了解父类或者无法 获得父类代码的情况下。)
-
尽量不要覆写基类的方法
如果基类是一个抽象类,而且这个方法已经实现了,子类尽量不要覆写。类间依赖的是 抽象,覆写了抽象方法,对依赖的稳定性会产生一定的影响。
-
结合里氏替换原则使用
在前面我们讲解了里氏替换原则,父类出现的地方子类就能出现,再结合当前的讲解,我们可以得出这样一个通俗的规则: 接口负责定义public属性和方法,并且声明与其他 对象的依赖关系,抽象类负责公共构造部分的实现,实现类准确的实现业务逻辑,同时在适 当的时候对父类进行细化。
讲了这么多,估计大家对“倒置”这个词还是有点不理解,那到底什么是“倒置”呢?我们 先说“正置”是什么意思,依赖正置就是类间的依赖是实实在在的实现类间的依赖,也就是面向实现编程,这也是正常人的思维方式,我要开奔驰车就依赖奔驰车,我要使用笔记本电脑就直接依赖笔记本电脑,而编写程序需要的是对现实世界的事物进行抽象,抽象的结果就是有了抽象类和接口,然后我们根据系统设计的需要产生了抽象间的依赖,代替了人们传统思维中的事物间的依赖,“倒置”就是从这里产生的。
依赖倒置原则的优点在小型项目中很难体现出来,例如小于10个人月的项目,使用简单的SSH架构,基本上不费太大力气就可以完成,是否采用依赖倒置原则影响不大。但是,在一个大中型项目中,采用依赖倒置原则有非常多的优点,特别是规避一些非技术因素引起的问题。项目越大,需求变化的概率也越大,通过采用依赖倒置原则设计的接口或抽象类对实现类进行约束,可以减少需求变化引起的工作量剧增的情况。人员的变动在大中型项目中也 是时常存在的,如果设计优良、代码结构清晰,人员变化对项目的影响基本为零。大中型项目的维护周期一般都很长,采用依赖倒置原则可以让维护人员轻松地扩展和维护。
依赖倒置原则是6个设计原则中最难以实现的原则,它是实现开闭原则的重要途径,依赖倒置原则没有实现,就别想实现对扩展开放,对修改关闭。在项目中,大家只要记住是“面向接口编程”就基本上抓住了依赖倒置原则的核心。
讲了这么多依赖倒置原则的优点,我们也来打击一下大家,在现实世界中确实存在着必须依赖细节的事物,比如法律,就必须依赖细节的定义。“杀人偿命”在中国的法律中古今有之,那这里的“杀人”就是一个抽象的含义,怎么杀,杀什么人,为什么杀人,都没有定义,只要是杀人就统统得偿命,这就是有问题了,好人杀了坏人,还要陪上自己的一条性命,这是不公正的,从这一点看,我们在实际的项目中使用依赖倒置原则时需要审时度势, 不要抓住一个原则不放,每一个原则的优点都是有限度的,并不是放之四海而皆准的真理, 所以别为了遵循一个原则而放弃了一个项目的终极目标:投产上线和盈利。作为一个项目经理或架构师,应该懂得技术只是实现目的的工具,惹恼了顶头上司,设计做得再漂亮,代码写得再完美,项目做得再符合标准,一旦项目亏本,产品投入大于产出,那整体就是扯淡! 你自己也别想混得更好!
4.接口隔离原则
在讲接口隔离原则之前,先明确一下我们的主角——接口。接口分为两种:
-
实例接口(Object Interface),在Java中声明一个类,然后用new关键字产生一个实 例,它是对一个类型的事物的描述,这是一种接口。比如你定义Person这个类,然后使用 Person zhangSan=new Person()产生了一个实例,这个实例要遵从的标准就是Person这个 类,Person类就是zhangSan的接口。疑惑?看不懂?不要紧,那是因为让Java语言浸染的时间 太长了,只要知道从这个角度来看,Java中的类也是一种接口。
-
类接口(Class Interface),Java中经常使用的interface关键字定义的接口。
主角已经定义清楚了,那什么是隔离呢?它有两种定义,如下所示:
- Clients should not be forced to depend upon interfaces that they don’t use.(客户端不应该依 赖它不需要的接口。)
- The dependency of one class to another one should depend on the smallest possible interface. (类间的依赖关系应该建立在最小的接口上。)
新事物的定义一般都比较难理解,晦涩难懂是正常的。我们把这两个定义剖析一下,先 说第一种定义:“客户端不应该依赖它不需要的接口”,那依赖什么?依赖它需要的接口,客 户端需要什么接口就提供什么接口,把不需要的接口剔除掉,那就需要对接口进行细化,保 证其纯洁性;再看第二种定义:“类间的依赖关系应该建立在最小的接口上”,它要求是最小 的接口,也是要求接口细化,接口纯洁,与第一个定义如出一辙,只是一个事物的两种不同 描述。
我们可以把这两个定义概括为一句话:建立单一接口,不要建立臃肿庞大的接口。再通 俗一点讲:接口尽量细化,同时接口中的方法尽量少。看到这里大家有可能要疑惑了,这与单一职责原则不是相同的吗?错,接口隔离原则与单一职责的审视角度是不相同的,单一职责要求的是类和接口职责单一,注重的是职责,这是业务逻辑上的划分,而接口隔离原则要 求接口的方法尽量少。例如一个接口的职责可能包含10个方法,这10个方法都放在一个接口 中,并且提供给多个模块访问,各个模块按照规定的权限来访问,在系统外通过文档约 束“不使用的方法不要访问”,按照单一职责原则是允许的,按照接口隔离原则是不允许的, 因为它要求“尽量使用多个专门的接口”。专门的接口指什么?就是指提供给每个模块的都应 该是单一接口,提供给几个模块就应该有几个接口,而不是建立一个庞大的臃肿的接口,容 纳所有的客户端访问。
**接口隔离原则是对接口进行规范约束,其包含以下4层含义: **
-
接口要尽量小
这是接口隔离原则的核心定义,不出现臃肿的接口(Fat Interface),但是“小”是有限度 的,首先就是不能违反单一职责原则,什么意思呢?假如说一个手机打电话,它需要实现数据传输和连接管理接口,连接管理接口中有接电话,挂电话方法,仔细分析一下,挂电话的有两种方式: 一种是正常的电话挂断,另一种是电话异常挂断,比如电话突然没电了,通信当然就断了。这两种方式的处理应该是不同的,为什么呢?正常挂电话,对方接受到挂机信号,计费系统也 就停止计费了,那手机没电了这种方式就不同了,它是信号丢失了,中继服务器检查到了, 然后通知计费系统停止计费,否则你的费用不是要疯狂地增长了吗?
思考到这里,我们是不是就要动手将该接口拆分成两个,一个接口是负责连接,一个接口是负责挂电话?是要这样做吗?且慢,让我们再思考一下,如果拆分 了,那就不符合单一职责原则了,因为从业务逻辑上来讲,通信的建立和关闭已经是最小的 业务单位了,再细分下去就是对业务或是协议(其他业务逻辑)的拆分了。想想看,一个电 话要关心3G协议,要考虑中继服务器,等等,这个电话还怎么设计得出来呢?从业务层次 来看,这样的设计就是一个失败的设计。一个原则要拆,一个原则又不要拆,那该怎么办? 好办,根据接口隔离原则拆分接口时,首先必须满足单一职责原则。
-
接口要高内聚
什么是高内聚?高内聚就是提高接口、类、模块的处理能力,减少对外的交互。比如你 告诉下属“到奥巴马的办公室偷一个×××文件”,然后听到下属用坚定的口吻回答你:“是,保 证完成任务!”一个月后,你的下属还真的把×××文件放到你的办公桌上了,这种不讲任何条 件、立刻完成任务的行为就是高内聚的表现。具体到接口隔离原则就是,要求在接口中尽量 少公布public方法,接口是对外的承诺,承诺越少对系统的开发越有利,变更的风险也就越 少,同时也有利于降低成本。
-
定制服务
一个系统或系统内的模块之间必然会有耦合,有耦合就要有相互访问的接口(并不一定 就是Java中定义的Interface,也可能是一个类或单纯的数据交换),我们设计时就需要为各 个访问者(即客户端)定制服务,什么是定制服务?定制服务就是单独为一个个体提供优良 的服务。我们在做系统设计时也需要考虑对系统之间或模块之间的接口采用定制服务。采用 定制服务就必然有一个要求:只提供访问者需要的方法。
-
接口设计是有限度的
接口的设计粒度越小,系统越灵活,这是不争的事实。但是,灵活的同时也带来了结构 的复杂化,开发难度增加,可维护性降低,这不是一个项目或产品所期望看到的,所以接口 设计一定要注意适度,这个“度”如何来判断呢?根据经验和常识判断,没有一个固化或可测量的标准。
接口隔离原则是对接口的定义,同时也是对类的定义,接口和类尽量使用原子接口或原 子类来组装。但是,这个原子该怎么划分是设计模式中的一大难题,在实践中可以根据以下 几个规则来衡量:
- 一个接口只服务于一个子模块或业务逻辑;
- 通过业务逻辑压缩接口中的public方法,接口时常去回顾,尽量让接口达到“满身筋骨 肉”,而不是“肥嘟嘟”的一大堆方法;
- 已经被污染了的接口,尽量去修改,若变更的风险较大,则采用适配器模式进行转化 处理;
- 了解环境,拒绝盲从。每个项目或产品都有特定的环境因素,别看到大师是这样做的 你就照抄。千万别,环境不同,接口拆分的标准就不同。深入了解业务逻辑,最好的接口设 计就出自你的手中!
接口隔离原则和其他设计原则一样,都需要花费较多的时间和精力来进行设计和筹划, 但是它带来了设计的灵活性,让你可以在业务人员提出“无理”要求时轻松应付。贯彻使用接 口隔离原则最好的方法就是一个接口一个方法,保证绝对符合接口隔离原则(有可能不符合 单一职责原则),但你会采用吗?不会,除非你是疯子!那怎么才能正确地使用接口隔离原 则呢?答案是根据经验和常识决定接口的粒度大小,接口粒度太小,导致接口数据剧增,开 发人员呛死在接口的海洋里;接口粒度太大,灵活性降低,无法提供定制服务,给整体项目 带来无法预料的风险。
怎么准确地实践接口隔离原则?实践、经验和领悟!
5.迪米特法则
迪米特法则(Law of Demeter,LoD)也称为最少知识原则(Least Knowledge Principle,LKP),虽然名字不同,但描述的是同一个规则:一个对象应该对其他对象有最 少的了解。通俗地讲,一个类应该对自己需要耦合或调用的类知道得最少,你(被耦合或调 用的类)的内部是如何复杂都和我没关系,那是你的事情,我就知道你提供的这么多public 方法,我就调用这么多,其他的我一概不关心。
迪米特法则对类的低耦合提出了明确的要求,其包含以下4层含义。
-
只和朋友交流
迪米特法则还有一个英文解释是:Only talk to your immediate friends(只与直接的朋友通 信。)什么叫做直接的朋友呢?每个对象都必然会与其他对象有耦合关系,两个对象之间的 耦合就成为朋友关系,这种关系的类型有很多,例如组合、聚合、依赖等。
-
朋友间也是有距离的
一个类公开的public属性或方法越多,修改时涉及的面也就越大,变更引起的风险扩散也就越大。因此,为了保持朋友类间的距离,在设计时需要反复衡量:是否还可以再减少 public方法和属性,是否可以修改为private、package-private(包类型,在类、方法、变量前 不加访问权限,则默认为包类型)、protected等访问权限,是否可以加上final关键字等。
注意 迪米特法则要求类“羞涩”一点,尽量不要对外公布太多的public方法和非静态的 public变量,尽量内敛,多使用private、package-private、protected等访问权限。 -
是自己的就是自己的
在实际应用中经常会出现这样一个方法:放在本类中也可以,放在其他类中也没有错, 那怎么去衡量呢?你可以坚持这样一个原则:如果一个方法放在本类中,既不增加类间关 系,也对本类不产生负面影响,那就放置在本类中。
-
谨慎使用Serializable
迪米特法则的核心观念就是类间解耦,弱耦合,只有弱耦合了以后,类的复用率才可以 提高。其要求的结果就是产生了大量的中转或跳转类,导致系统的复杂性提高,同时也为维 护带来了难度。读者在采用迪米特法则时需要反复权衡,既做到让结构清晰,又做到高内聚 低耦合。
不知道大家有没有听过这样一个理论:“任何两个素不相识的人中间最多只隔着6个人, 即只通过6个人就可以将他们联系在一起”,这就是著名的“六度分隔理论”。如果将这个理论 应用到我们的项目中,也就是说,我和我要调用的类之间最多有6次传递。呵呵,这只能让 大家当个乐子来看,在实际应用中,如果一个类跳转两次以上才能访问到另一个类,就需要 想办法进行重构了,为什么是两次以上呢?因为一个系统的成功不仅仅是一个标准或是原则 就能够决定的,有非常多的外在因素决定,跳转次数越多,系统越复杂,维护就越困难,所 以只要跳转不超过两次都是可以忍受的,这需要具体问题具体分析。
迪米特法则要求类间解耦,但解耦是有限度的,除非是计算机的最小单元——二进制的 0和1。那才是完全解耦,在实际的项目中,需要适度地考虑这个原则,别为了套用原则而做 项目。原则只是供参考,如果违背了这个原则,项目也未必会失败,这就需要大家在采用原 则时反复度量,不遵循是不对的,严格执行就是“过犹不及”。
6.开闭原则
在哲学上,矛盾法则即对立统一的法则,是唯物辩证法的最根本法则。本章要讲的开闭 原则是不是也有同样的重要性且具有普遍性呢?确实,开闭原则是Java世界里最基础的设计 原则,它指导我们如何建立一个稳定的、灵活的系统,先来看开闭原则的定义:
Software entities like classes,modules and functions should be open for extension but closed for modifications.(一个软件实体如类、模块和函数应该对扩展开放,对修改关闭。)
初看到这个定义,可能会很迷惑,对扩展开放?开放什么?对修改关闭,怎么关闭?没 关系,我会一步一步带领大家解开这些疑惑。
我们做一件事情,或者选择一个方向,一般需要经历三个步骤:What——是什么,Why ——为什么,How——怎么做(简称3W原则,How取最后一个w)。对于开闭原则,我们也 采用这三步来分析,即什么是开闭原则,为什么要使用开闭原则,怎么使用开闭原则。
开闭原则的定义已经非常明确地告诉我们:软件实体应该对扩展开放,对修改关闭,其 含义是说一个软件实体应该通过扩展来实现变化,而不是通过修改已有的代码来实现变化。 那什么又是软件实体呢?软件实体包括以下几个部分:
- 项目或软件产品中按照一定的逻辑规则划分的模块。
- 抽象和类。
- 方法。
一个软件产品只要在生命期内,都会发生变化,既然变化是一个既定的事实,我们就应 该在设计时尽量适应这些变化,以提高项目的稳定性和灵活性,真正实现“拥抱变化”。开闭原则告诉我们应尽量通过扩展软件实体的行为来实现变化,而不是通过修改已有的代码来完成变化,它是为软件实体的未来事件而制定的对现行开发设计进行约束的一个原则。
注意:开闭原则对扩展开放,对修改关闭,并不意味着不做任何修改,低层模块的变更,必然要有高层模块进行耦合,否则就是一个孤立无意义的代码片段。
写在最后
以上内容摘抄至 《设计模式之禅(第二版)》