设计原则
一、单一职责原则
单一职责原则:英文名称是Single Responsiblity Principle,简称是SRP。定义:应该有且仅有一个原因引起类的变更。
单一职责原则要求:一个接口或类只有一个原因引起变化,也就是一个接口或类只有一个职责,它就负责一件事情。
单一职责原则的好处:
- 类的复杂性降低,实现什么职责都有清晰明确的定义;
- 可读性提高,复杂性降低,那当然可读性提高了;
- 可维护性提高,可读性提高,那当然更容易维护了;
- 变更引起的风险降低,变更是必不可少的,如果接口的单一职责做得好,一个接口修改只对相应的实现类有影响,对其他的接口无影响,这对系统的扩展性、维护性都有非常大的帮助。
注意: 单一职责原则提出了一个编写程序的标准,用“职责”或“变化原因”来衡量接口或类设计得是否优良,但是“职责”和“变化原因”都是不可度量的,因项目而异,因环境而异。
对于单一职责原则,接口一定要做到单一职责,类的设计尽量做到只有一个原因引起变化。
二、里氏替换原则
里氏替换原则(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层含义:
- 子类必须完全实现父类的方法;
- 在类中调用其他类时务必要使用父类或接口,如果不能使用父类或接口,则说明类的设计已经违背了LSP原则;
- 如果子类不能完整地实现父类的方法,或者父类的某些方法在子类中已经发生“畸变”,则建议断开父子继承关系,采用依赖、聚集、组合等关系代替继承。
- 子类可以有自己的个性;
- 覆盖或实现父类的方法时输入参数可以被放大;
- 如果父类的输入参数类型大于子类的输入参数类型,会出现父类存在的地方,子类未必会存在,因为一旦把子类作为参数传入,调用者很可能进入子类的方法范畴;
- 子类中方法的前置条件必须与超类中被覆写的方法的前置条件相同或者更宽松。
- 覆写或实现父类的方法时输出结果可以被缩小。
- 父类的一个方法的返回值是一个类型T,子类的相同方法(重载或覆写)的返回值为S,那么里氏替换原则就要求S必须小于等于T,也就是说,要么S和T是同一个类型,要么S是T的子类。
采用里氏替换原则的目的就是增强程序的健壮性,版本升级时也可以保持非常好的兼容性。即使增加子类,原有的子类还可以继续执行。
三、依赖倒置原则
依赖倒置原则(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.
包含三层含义:
- 高层模块不应该依赖底层模块,两者都应该依赖抽象;
- 抽象不应该依赖细节;
- 细节应该依赖抽象。 高层模块和低层模块容易理解,每一个逻辑的实现都是由原子逻辑组成的,不可分割的原子逻辑就是底层模块,原子逻辑的再组装就是高层模块。
在Java语言中,抽象就是指接口或抽象类,两者都是不能直接被实例化的;细节就是实现类,实现接口或继承抽象类而产生的类就是细节,其特点就是可以直接被实例化,也就是可以加上一个关键字new产生一个对象。依赖倒置原则在Java语言中的表现就是:
模块间的依赖通过抽象发生,实现类之间不发生直接的依赖关系,其依赖关系是通过接口或抽象类产生的;
接口或抽象类不依赖于实现类;
实现类依赖接口或抽象类。 更加精简的定义就是“面向接口编程”--OOD(Object-Oriented Design,面向对象设计)的精髓之一。
依赖的三种写法:
依赖是可以传递的,A对象依赖B对象,B又依赖C,C又依赖D。。。。。。生生不息,依赖不止,记住一点:只要做到抽象依赖,即使是多层的依赖传递也无所畏惧!
对象的依赖关系有三种方式来传递:
构造函数传递依赖对象 --- 在类中通过构造函数声明依赖对象,按照依赖注入的说法,这种方式叫做构造函数注入。
Setter方法传递依赖对象 --- 在抽象中设置Setter方法声明依赖关系,依照注入的说法,这是Setter依赖注入。
在接口的方法中声明依赖对象,这种方式也叫做接口注入。
依赖倒置原则的本质就是通过抽象(接口或抽象类)使各个类或模块的实现彼此独立,不互相影响,实现模块间的松耦合,我们怎么在项目中使用这个规则呢?只要遵循以下的规则就可以:每个类尽量都有接口或抽象类,或者抽象类和接口两者都具备; --- 这是依赖倒置的基本要求,接口或抽象类都是属于抽象的,有了抽象才可能依赖倒置。
变量的表面类型尽量是接口或者是抽象类;
任何类都不应该从具体类派生;
尽量不要覆写基类的方法; --- 如果基类是一个抽象类,而且这个方法已经实现了,子类尽量不要覆写。类间依赖的是抽象,覆写了抽象方法,对依赖的稳定性会产生一定的影响。
结合里氏替换原则使用。 --- 接口负责定义public属性和方法,并且声明与其他对象的依赖关系,抽象类负责公共构造部分的实现,实现类准确的实现业务逻辑,同时在适当的时候对父类进行细化。
四、接口隔离原则
接口分为两种:
实例接口(Object Interface),在Java中声明一个类,然后用new关键字产生一个实例,它是对一个类型的食物的描述,这是一种接口。
类接口(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。(类间的依赖关系应该建立在最小的接口上。)
接口隔离原则是对接口进行规范约束,其包含以下4层含义:接口要尽量小; --- 这是接口隔离原则的核心定义,不出现臃肿的接口(Fat Interface),但是“小”是有限度的,首先就是不能违反单一职责原则。
接口要高内聚; --- 高内聚就是提高接口、类、模块的处理能力,减少对外的交互。在接口中尽量少公布public方法,接口是对外的承诺,承诺越少对系统的开发越有利,变更的风险也就越少,同时也越有利于降低成本。
定制服务; --- 一个系统或系统内的模块之间必然会有耦合,有耦合就要有相互访问的接口(并不一定就是Java中定义的Interface,也可能是一个类或单纯的数据交换),我们设计时就需要为各个访问者(即客户端)定制服务。定制服务就是单独为一个个体提供优良的服务。我们在做系统设计时也需要考虑对系统之间或模块之间的接口采用定制服务。采用定制服务就必然有一个要求:只提供访问者需要的方法。
接口设计是有限度的。 --- 接口的设计粒度越小,系统越灵活,这是不争的事实。但是,灵活的同时也带来了结构的复杂化,开发难度增加,可维护性低,这不是一个项目或产品所期望看到的,所以接口设计一定要注意适度,这个“度”如何来判断?根据经验和常识判断,没有一个固话或可测量的标准。
接口隔离原则是对接口的定义,同时也是对类的定义,接口和类尽量使用原子接口或原子类来组装。但是,这个原子该怎么划分是设计模式中的一大难题,在实践中可以根据以下几个规则来衡量:
- 一个接口只服务于一个模块或业务逻辑;
- 通过业务逻辑压缩接口中的public方法,接口时常去回顾,尽量让接口达到“满身筋骨肉”,而不是“肥嘟嘟”的一大堆方法;
- 已经被污染了的接口,尽量去修改,若变更的风险较大,则采用适配器模式进行转化处理;
- 了解环境,拒绝盲从。每个项目或产品都有特定的环境因素,别看到大师是这样做的你就照抄。千万别,环境不同,接口拆分的标准就不同。深入了解业务逻辑,最好的接口设计就出自你的手中!
五、迪米特法则
迪米特法则(Law of Demeter ,LoD)也称为最少知识原则(Least Knowledge
Principle,LKP),虽然名字不同,但描述的是同一个规则:一个对象应该对其他对象有最少的了解。通俗地讲,一个类应该对自己需要耦合或调用的类知道得最少,你(被耦合或调用的类)的内部是如何复杂都和我没关系,那是你的事情,我就知道你提供的这么多public方法,我就调用这么多,其他的我一概不关心。
迪米特法则对类的低耦合提出了明确的要求,其包含以下4层含义:
- 只和朋友交流 --- 迪米特法则还有一个英文解释是:Only talk to your immedate
friends(只与直接的朋友通信。)什么叫做直接的朋友?每个对象都必然会与其他对象有耦合关系,两个对象之间的耦合就成为朋友关系,这种关系的类型有很多,例如组合、聚合、依赖等。朋友类的定义:出现在成员变量、方法的输入输出参数中的类称为成员朋友类,而出现在方法体内部的类不属于朋友类。注意:一个类只和朋友交流,不与陌生类交流,不要出现getA()
.getB().getC().getD()这种情况(在一种极端的情况下允许出现这种访问,即每一个点后面返回类型都相同),类与类之间的关系是建立在类间的,而不是方法间,因此一个方法尽量不引入一个类中不存在的对象,当然,JDK
API提供的类除外。 - 朋友间也是有距离的 --- 一个类公开的public属性或方法越多,修改时涉及的面积越大,变更引起的风险扩散也就越大。因此,为了保持朋友类间的距离,在设计时需要反复衡量:是否还可以再减少public方法和属性,是否可以修改为private、package-private(包类型,在类、方法、变量前不加访问权限,则默认为包类型)、protected
等访问权限,是否可以加上final关键字等。
注意:迪米特法则要求类“羞涩”一点,尽量不要对外公布太多的public方法和非静态的public变量,尽量内敛,多使用private、package-private、protected等访问权限。 - 是自己的就是自己的 --- 如果一个方法放在本类中,即不增加类间的关系,也对本类不产生负面影响,就放置在本类中。
- 谨慎使用Serializable
迪米特法则的核心观念就是类间解耦,弱耦合,只有弱耦合了以后,类的复用率才可以提高。其要求的结果就是产生了大量的中转或跳转类,导致系统的复杂性提高,同时也为维护带来了难度。
迪米特法则要求类间解耦,但解耦是有限度的,除非是计算机的最小单元——二进制的0和1。那才是完全解耦,在实际的项目中,需要适度地考虑这个原则,别为了套用原则而做项目。原则只是供参考,如果违背了这个原则,项目也未必会失败,这就需要大家在采用原则时反复度量,不遵循是不对的,严格执行就是“过犹不及”。
六、开放封闭原则
开放封闭原则的定义:Software entities like classes, modules and functions should be open for extension but closed for
modifications.(一个软件实体如类、模块和函数应该对扩展开放,对修改关闭。)其含义是说一个软件实体应该通过扩展来实现变化。软实体包括以下几个部分:
- 项目或软件产品中按照一定的逻辑规则划分的模块;
- 抽象和类;
- 方法。
一个软件产品只要在生命周期内,都会发生变化,既然变化是一个既定的事实,我们就应该在设计时尽量适应这些变化,以提高项目的稳定性和灵活性,真正实现“拥抱变化”。开放封闭原则告诉我们应尽量通过扩展软件实体的行为来实现变化,而不是通过修改已有的代码来完成变化,它是为软件实体的未来事件而制定的对现行开发设计进行约束的一个原则。
开放封闭原则对扩展开放,对修改封闭,并不意味着不做任何修改,低层模块的变更,必然要有高层模块进行耦合,否则就是一个孤立无意义的代码片段。我们可以把变化归纳以下三种类型:
- 逻辑变化; -- 只变化一个逻辑,而不涉及其他模块,比如原有的一个算法是 ab+c ,现在需要修改为 ab*c ,可以通过修改原有类中的方法的方式来完成,前提条件是所有依赖关系都按照相同的逻辑处理。
- 子模块变化; --- 一个模块变化,会对其他的模块产生影响,特别是一个低层次的模块变化必然引起高层次模块的变化,因此在通过扩展完成变化时,高层次的模块修改是必然的。
- 可见视图变化 --- 可见视图是提供给客户使用的界面,如JSP程序、Swing界面等,该部分的变化一般会引起连锁反应(特别是在国内做项目,做欧美的外包项目一般不会影响太大)。如果仅仅是界面上按钮、文字的重新排列倒是简单,最司空见惯的是业务耦合变化,什么意思?一个展示数据的列表,按照原有的需求是6列,突然有一天要增加1列,而且这一列要跨N张表,处理M个逻辑才能展现出来,这样的变化是比较恐怖的,但还是可以通过扩展来完成变化,这就要看我们原有的设计是否灵活。
为什么要采用开放封闭原则:
每个事物的诞生都有它存在的必要性,存在即合理,那开闭原则的存在也是合理的,为什么这么说呢?
首先,开闭原则是那么地著名,只要是做面向对象编程的,甭管是什么语言,Java也好,C++也好,或者是Smalltalk,在开发时都会提及开闭原则。
其次,开闭原则是最基础的一个原则,前面介绍的原则都是开闭原则的具体形态,也就是说前五个原则就是指导设计的工具和方法,而开闭原则才是其精神领袖。换一个角度理解,依照Java语言的称谓,开闭原则是抽象类,其他五大原则是具体的实现类,开闭原则在面向对象设计领域中的地位就类似于牛顿第一定律在力学、勾股定律在几何学、质能方程在狭义相对论中的地位,其地位无人能及。
最后,开闭原则是非常重要的,可通过以下几个方面来理解其重要性。
1、开闭原则对测试的影响
所有已经投产的代码都是有意义的,并且都受系统规则的约束,这样的代码都要经过“千锤百炼”的测试过程,不仅保证逻辑是正确的,还要保证苛刻条件(高压力、异常、错误)下不产生“有毒代码(Poisonous
Code)”,因此有变化提出时,我们就需要考虑一下,原有的健壮代码是否可以不修改,仅仅通过扩展实现变化呢?否则,就需要把原有的测试过程回笼一遍,需要进行单元测试、功能测试、集成测试甚至是验收测试,现在虽然在大力提倡自动化测试工具,但是仍然代替不了人工的测试工作。
2、 开闭原则可以提高复用性
在面向对象的设计中,所有的逻辑都是从原子逻辑组合而来的,而不是在一个类中独立实现一个业务逻辑。只有这样代码才可以复用,粒度越小,被复用的可能性就越大。那为什么要复用呢?减少代码量,避免相同的逻辑分散在多个角落,避免日后的维护人员为了修改一个微小的缺陷或增加新功能而要在整个项目中到处查找相关的代码,然后发出对开发人员“极度失望”的感概。那怎么才能提高复用率呢?缩小逻辑粒度,直到一个逻辑不可再拆分为止。
3、开闭原则可以提高可维护性
一款软件投产后,维护人员的工作不仅仅是对数据进行维护,还可能要对程序进行扩展,维护人员最乐意做的事情就是扩展一个类,而不是修改一个类,甭管原有的代码写得多么优秀还是多么糟糕,让维护人员读懂代码原有的代码,然后再修改,是一件很痛苦的事情,不要让他在原有的代码海洋里游戈完毕后再修改,那是对维护人员的一种折磨和摧残。
4、面向对象开发的要求
万物皆对象,我们需要把所有的事物都抽象成对象,然后针对对象进行操作,但是万物皆运动,有运动就有变化,有变化就要有策略去应对,怎么快速应对呢?这就需要在设计之初考虑到所有可能变化的因素,然后留下接口,等待“可能”转变为“现实”。
如何使用开闭原则
1、抽象约束
抽象是对一组事物的通用描述,没有具体的实现,也就表示它可以有非常多的可能性,可以跟随需求的变化而变化。因此,通过接口或抽象类可以约束一组可能变化的行为,并且能够实现对扩展开放,其包含三层含义:第一,通过接口或抽象类约束扩展,对扩展进行边界限定,不允许出现在接口或抽象类中不存在的public方法;第二,参数类型,引用对象尽量使用接口或者抽象类,而不是实现类;第三,抽象层尽量保持稳定,一旦确定即不允许修改。
2、元数据(metadata)控制模块行为
编程时使用元数据来控制程序的行为,减少重复开发。用来描述环境和数据的数据,通俗地说就是配置参数,参数可以从文件中获得,也可以从数据中库中获得。
3、制定项目章程
在一个团队中,建立项目章程是非常重要的,因为章程中指定了所有人员都必须遵守的约定,对项目来说,约定优于配置。相信大家都做过项目,会发现一个项目会产生非常多的配置文件。以SSH项目开发为例,一个项目中Bean配置文件就非常多,管理非常麻烦。如果需要扩展,就需要增加子类,并修改SpringContext文件。然而,如果你在项目中指定这样一个章程:所有的Bean都自动注入,使用Annotation进行装配,进行扩展时,甚至只用写一个类,然后由持久层生成对象,其他的都不需要修改,这就需要项目内约束,每个项目成员都必须遵守,该方法需要一个团队有较高的自觉性,需要一个较长时间的磨合,一旦项目成员都熟悉这样的规则,比通过接口或抽象类进行的约束效率更高,而且扩展性一点也没有减少。
4、封装变化
对变化的封装包含两层含义:第一,将相同的变化封装到一个接口或抽象类中;第二,将不同的变化封装到不同的接口或抽象类中,不应该有两个不同的变化出现在同一个接口或抽象类中。封装变化,也就是受保护的变化(protected
variations),找出预计有变化或不稳定的点,我们为这些变化点创建稳定的接口,准确地讲是封装可能发生的变化,一旦预测到或“第六感”发觉有变化,就可以进行封装,23个设计模式都是从各个不同的角度对变化进行封装的,我们会在各个模式中逐步讲解。