1. 单一职责原则 SingleResponsibility Principle
SRP,Single ResponsibilityPrinciple:There should never bemore than one reason for a class to change.
应该有且仅有一个原因引起类的变更。(如果类需要变更,那么只可能仅由某一个原因引起)
问题由来:
类T负责两个不同的职责:职责P1,职责P2。当由于职责P1需求发生改变而需要修改类T时,有可能会导致原本运行正常的职责P2功能发生故障。
解决方案:
遵循单一职责原则。分别建立两个类T1、T2,使T1完成职责P1功能,T2完成职责P2功能。这样,当修改类T1时,不会使职责P2发生故障风险;同理,当修改T2时,也不会使职责P1发生故障风险。
示例:
如果一个接口包含了两个职责,并且这两个职责的变化不互相影响,那么就可以考虑拆分成两个接口。
方法的职责应清晰、单一。一个method尽可能制作一件事情。changeUserInfo()可拆分为changeUserName()、changeUserAddr()....
说到单一职责原则,很多人都会不屑一顾。因为它太简单了。稍有经验的程序员即使从来没有读过设计模式、从来没有听说过单一职责原则,在设计软件时也会自觉的遵守这一重要原则,因为这是常识。在软件编程中,谁也不希望因为修改了一个功能导致其他的功能发生故障。而避免出现这一问题的方法便是遵循单一职责原则。虽然单一职责原则如此简单,并且被认为是常识,但是即便是经验丰富的程序员写出的程序,也会有违背这一原则的代码存在。
为什么会出现这种现象呢?因为有职责扩散。所谓职责扩散,就是因为某种原因,职责P被分化为粒度更细的职责P1和P2。此时,按照SRP 应该再新建一个类负责职责P2,但是这样会修改花销很大!除了改接口 还需要改客户端代码!所以一般就直接在原有类方法中增加判断支持职责P2;或者在原有类中新增一个方法来处理职责P2(做到了方法级别的SRP),
例如原有一个接口,模拟动物呼吸的场景:
程序上线后,发现问题了,并不是所有的动物都呼吸空气的,比如鱼就是呼吸水的。
修改一:
修改时如果遵循单一职责原则,需要将Animal类细分为陆生动物类Terrestrial,水生动物Aquatic,代码如下:
BUT,这样修改花销是很大的,除了将原来的类分解之外,还需要修改客户端。
修改二:直接修改类Animal;虽然违背了单一职责原则,但花销却小的多
这种修改方式要简单的多。但是却存在着隐患:有一天需要将鱼分为呼吸淡水的鱼和呼吸海水的鱼,则又需要修改Animal类的breathe方法,而对原有代码的修改会对调用“猪”“牛”“羊”等相关功能带来风险,也许某一天你会发现程序运行的结果变为“牛呼吸水”了。
这种修改方式直接在代码级别上违背了单一职责原则,虽然修改起来最简单,但隐患却是最大的。
修改三:
这种修改方式没有改动原来的方法,而是在类中新加了一个方法;虽然也违背了单一职责原则,但在方法级别上却是符合单一职责原则的,因为它并没有动原来方法的代码。
好处:
一个接口的修改只对相应的实现类有影响,对其他接口无影响;有利于系统的可扩展性、可维护性。
问题:
“职责”的粒度不好确定!
过分细分的职责也会人为地增加系统复杂性。
建议:
对于单一职责原则,建议接口一定要做到单一职责,类的设计尽量做到只有一个原因引起变化。只有逻辑足够简单,才可以在代码级别上违反单一职责原则;只有类中方法数量足够少,才可以在方法级别上违反单一职责原则;
2. 里氏替换原则 LiskovSubstitution Principle
LSP,LiskovSubstitution Principle:
1) If for eachobject s of type S, there is an object oftype T such that for all programs P defined in terms of T, thebehavior of P is unchanged when s is substituted fort when S isa subtype of T.
2) Functions thatuse pointers or references to base classes must be able to user objects ofderived classes without knowing it.
所有引用基类的地方,都能透明地替换成其子类对象。只要父类能出现的地方,子类就可以出现。
引入里氏替换原则能充分发挥继承的优点、减少继承的弊端。
继承的优点:
- 代码共享,减少创建类的工作量;每个子类都有父类的方法和属性;
- 提高代码重用性;
- 子类可以形似父类,但又异于父类;
- 提高代码可扩展性;
- 提高产品开放性。
继承的缺点:
- 继承是侵入性的——只要继承,就必须拥有父类的属性和方法;
- 降低代码的灵活性——子类必须拥有父类的属性和方法,让子类自由的世界多了些约束;
- 增强了耦合性——当父类的属性和方法被修改时,必须要考虑子类的修改。
示例(继承的缺点):
原有类A,实现减法功能:
新增需求:新增两数相加、然后再与100求和的功能,由类B来负责
OOPS! 原本运行正常的相减功能发生了错误。原因就是类B在给方法起名时无意中重写了父类的方法!
问题由来:
有一功能P1,由类A完成。现需要将功能P1进行扩展,扩展后的功能为P,其中P由原有功能P1与新功能P2组成。新功能P由类A的子类B来完成,则子类B在完成新功能P2的同时,有可能会导致原有功能P1发生故障。
解决方案:
LSP为继承定义了一个规范,包括四层含义:
1)子类必须完全实现父类的方法
如果子类不能完整地实现父类的方法,或者父类的某些方法在子类中已经发生畸变;则建议不要用继承,而采用依赖、聚集、组合等关系代替继承。
例如:父类AbstractGun有shoot()方法,其子类ToyGun不能完整实现父类的方法(玩具枪不能射击,ToyGun.shoot()中没有任何处理逻辑),则应该断开继承关系,另外建一个AbstractToy父类。
2)子类可以有自己得个性
即,在子类出现的地方,父类未必就能替代。
3)重载或实现父类方法时,输入参数可以被放大(入参可以更宽松)
否则,用子类替换父类后,会变成执行子类重载后的方法,而该方法可能“歪曲”父类的意图,可能引起业务逻辑混乱。
4)重写或实现父类方法时,返回类型可以被缩小(返回类型更严格)
建议:
在实际编程中,我们常常会通过重写父类的方法来完成新的功能,这样写起来虽然简单,但是整个继承体系的可复用性会比较差,特别是运用多态比较频繁时,程序运行出错的几率非常大。父类中凡是已经实现好的方法(相对于抽象方法而言),实际上是在设定一系列的规范和契约,虽然它不强制要求所有的子类必须遵从这些契约,但是如果子类对这些非抽象方法任意修改,就会对整个继承体系造成破坏。而里氏替换原则就是表达了这一层含义。
里氏替换原则通俗的来讲就是:子类可以扩展父类的功能,但不能改变父类原有的功能。
Q:LSP如何减少继承的弊端?
3.依赖倒置原则 DependenceInversion Principle:
DIP,DependenceInversion Principle:
High level modulesshould not depend upon low level modules. Both should depend upon abstractions.
Abstractionsshould not depend upon details. Details should depend upon abstractions.
即“面向接口编程”:
高层模块不应该依赖低层模块,两者都应该依赖其抽象;——模块间的依赖通过抽象发生。实现类之间不发生直接的依赖关系(eg. 类B被用作类A的方法中的参数),其依赖关系是通过接口或抽象类产生的;
抽象不应该依赖细节;——接口或抽象类不依赖于实现类;
细节应该依赖抽象;——实现类依赖接口或抽象类。
何为“倒置”?
依赖正置:类间的依赖是实实在在的实现类间的依赖,即面向实现编程,这是正常人的思维方式;而依赖倒置是对现实世界进行抽象,产生了抽象间的依赖,代替了人们传统思维中的事物间的依赖。
依赖倒置可以减少类间的耦合性、降低并行开发引起的风险。
示例(减少类间的耦合性):
例如有一个Driver,可以驾驶Benz:
问题来了:现在有变更,Driver不仅要驾驶Benz,还需要驾驶BMW,怎么办?
Driver和Benz是紧耦合的,导致可维护性大大降低、稳定性大大降低(增加一个车就需要修改Driver,Driver是不稳定的)。
示例(降低并行开发风险性):
如上例,Benz类没开发完成前,Driver是不能编译的!不能并行开发!
问题由来:
类A直接依赖类B,假如要将类A改为依赖类C,则必须通过修改类A的代码来达成。这种场景下,类A一般是高层模块,负责复杂的业务逻辑;类B和类C是低层模块,负责基本的原子操作;假如修改类A,会给程序带来不必要的风险。
解决办法:
将类A修改为依赖接口I,类B和类C各自实现接口I,类A通过接口I间接与类B或者类C发生联系,则会大大降低修改类A的几率。
上例中,新增一个抽象ICar接口,ICar不依赖于BMW和Benz两个实现类(抽象不依赖于细节)。
1)Driver和ICar实现类松耦合
2)接口定下来,Driver和BMW就可独立开发了,并可独立地进行单元测试
依赖有三种写法:
1)构造函数传递依赖对象(构造函数注入)
2)setter方法传递依赖对象(setter依赖注入)
3)接口声明依赖对象(接口注入)
建议:
DIP的核心是面向接口编程;DIP的本质是通过抽象(接口、抽象类)使各个类或模块的实现彼此独立,不互相影响。
在项目中遵循以下原则:
Ø 每个类尽量都有接口或抽象类
Ø 变量的表面类型尽量使接口或抽象类
Ø 任何类都不应该从具体类派生*——否则就会依赖具体类。
Ø 尽量不要重写父类中已实现的方法——否则父类就不是一个真正适合被继承的抽象。
Ø 结合里氏替代原则使用
4.接口隔离原则 InterfaceSegregation Principle
Interface Segregation Principle:
Clients should notbe forced to depend upon interfaces that they don't use.——客户端只依赖于它所需要的接口;它需要什么接口就提供什么接口,把不需要的接口剔除掉。
The dependency ofone class to another one should depend on the smallest possible interface.——类间的依赖关系应建立在最小的接口上。
即,接口尽量细化,接口中的方法尽量少
问题由来:
类A通过接口I依赖类B,类C通过接口I依赖类D,如果接口I对于类A和类B来说不是最小接口,则类B和类D必须去实现他们不需要的方法。
解决方案:
将臃肿的接口I拆分为独立的几个接口,类A和类C分别与他们需要的接口建立依赖关系。包含4层含义:
1)接口要尽量小
不能出现Fat Interface;但是要有限度,首先不能违反单一职责原则(不能一个接口对应半个职责)。
2)接口要高内聚
在接口中尽量少公布public方法。
接口是对外的承诺,承诺越少对系统的开发越有利。
3)定制服务
只提供访问者需要的方法。例如,为管理员提供IComplexSearcher接口,为公网提供ISimpleSearcher接口。
4)接口的设计是有限度的
建议:
Ø 一个接口只服务于一个子模块或业务逻辑;
Ø 通过业务逻辑压缩接口中的public方法;
Ø 已被污染了的接口,尽量去修改;若变更的风险较大,则采用适配器模式转化处理;
Ø 拒绝盲从
与单一职责原则的区别:
二者审视角度不同;单一职责原则要求的是类和接口职责单一,注重的是职责,这是业务逻辑上的划分;接口隔离原则要求接口的方法尽量少。。。
5.迪米特法则 Law ofDemeter
LoD,Law of Demeter:
又称最少知识原则(Least Knowledge Principle),一个对象应该对其他对象有最少的了解.
一个类对自己依赖的类知道的越少越好。也就是说,对于被依赖的类来说,无论逻辑多么复杂,都尽量地的将逻辑封装在类的内部,对外除了提供的public方法,不对外泄漏任何信息。
问题由来:
类与类之间的关系越密切,耦合度越大,当一个类发生改变时,对另一个类的影响也越大。
解决方案:
迪米特法则包含4层含义:
1)只和朋友交流
Onlytalk to your immediate friends.两个对象之间的耦合就成为朋友关系。即,出现在成员变量、方法输入输出参数中的类就是朋友;局部变量不属于朋友。
不与无关的对象发生耦合!
方针:不要调用从另一个方法中返回的对象中的方法!只应该调用以下方法:
Ø 该对象本身的方法
Ø 该对象中的任何组件的方法
Ø 方法参数中传入的对象的方法
Ø 方法内部实例化的对象的方法
例如:Teacher类可以命令TeamLeader对Students进行清点,则Teacher无需和Students耦合,只需和TeamLeader耦合即可。
反例:
客户端不应该了解气象站类中的温度计对象;应在气象站类中直接加入获取温度的方法。改为:
2)朋友间也应该有距离
即使是朋友类之间也不能无话不说,无所不知。
--> 一个类公开的public属性或方法应该尽可能少!
3)是自己的就是自己的
如果一个方法放在本类中也可以、放在其他类中也可以,怎么办?
--> 如果一个方法放在本类中,既不增加类间关系,也对本类不产生负面影响,就放置在本类中。
4)谨慎使用Serializable
否则,若后来修改了属性,序列化时会抛异常NotSerializableException。
建议:
迪米特法则的核心观念是:类间解耦。其结果是产生了大量中转或跳转类。
6.开闭原则 OpenClosed Principle
Open ClosedPrinciple:
Software entities like classes, modules and functionsshould be open for extension but closed for modifications.
对扩展开放,对修改关闭。一个软件实体应该通过扩展来实现变化,而不是通过修改已有代码来实现变化。一个软件实体应该通过扩展来实现变化,而不是通过修改已有的代码来实现变化。——but,并不意味着不做任何修改;底层模块的扩展,必然要有高层模块进行耦合。
“变化”可分为三种类型:
Ø 逻辑变化——不涉及其他模块,可修改原有类中的方法;
Ø 子模块变化——会对其他模块产生影响,通过扩展来完成变化;
Ø 可见视图变化——界面变化,有时需要通过扩展来完成变化。
问题由来:
在软件的生命周期内,因为变化、升级和维护等原因需要对软件原有代码进行修改时,可能会给旧代码中引入错误,也可能会使我们不得不对整个功能进行重构,并且需要原有代码经过重新测试。
解决方案:
当软件需要变化时,尽量通过扩展软件实体的行为来实现变化,而不是通过修改已有的代码来实现变化。要求:
1)抽象约束(要实现对扩展开放,首要前提就是抽象约束)
通过接口或抽象类可以约束一组可能变化的行为,并能实现对扩展开放。包含三层含义:
通过接口或抽象类可以约束扩展,对扩展进行边界限定,不允许出现在接口抽象类中不存在的public方法;参数类型、引用对象尽量使用接口或抽象类,而不是实现类;
抽象应尽量保持稳定,一旦确定即不允许修改。
2)元数据(metadata)控制模块行为
元数据,即用来描述环境和数据的数据,即配置数据。例如SpingContext。
3)制定项目章程
4)封装变化
封装可能发生的变化。将相同的变化封装到一个接口或抽象类中;将不同的变化封装到不同的接口或抽象类中。
好处:
Ø 易于单元测试,如果直接修改已有代码,则需要同时修改单元测试类;而通过扩展,则只需生成一个测试类。
Ø 可提高复用性
Ø 可提高可维护性
面向对象开发的要求
建议:
开闭原则是最基础的原则,前5个原则都是开闭原则的具体形态。