oo基本原则
1.1 我是“牛”类,我可以担任多职吗
单一职责原则的英文名称是Single Responsibility Principle,简称是SRP。这个设计原则备受争议,只要你想和别人争执、怄气或者是吵架,这个原则是屡试不爽的。如果你是老大,看到一个接口或类是这样或那样设计的,你就问一句:“你设计的类符合SRP原则吗,”,保准对方立马“萎缩”掉,而且还一脸崇拜地看着你,心想:“老大确实英明”。这个原则存在争议之处在哪里呢,就是对职责的定义,什么是类的职责,以及怎么划分类的职责。我们先举个例子来说明什么是单一职责原则。
只要做过项目,肯定要接触到用户、机构、角色管理这些模块,基本上使用的都是RBAC模型,确实是很好的一个解决办法。我们今天要讲的是用户管理、修改用户的信息、增加机构(一个人属于多个机构)、增加角色等,用户有这么的信息和行为要维护,我们就把这些写到一个接口中,都是用户管理类嘛,我们先来看它的类图,如1-1所示。
图1-1 用户信息维护类图
太Easy的类图了,我相信,即使是一个初级的程序员也可以看出这个接口设计得有问
,用户的属性(Property)和用户的行为(Behavior)没有分开,这是一个严重的错误~非常正确,这个接口确实设计得一团糟,应该把用户的信息抽取成一个BO(Bussiness Object,业务对象),把行为抽取成一个BIZ(Business Logic,业务逻辑),按照这个思路对类图进行修正,如图1-2所示。
图1-2 职责划分后的类图
重新拆封成两个接口,IUserBO负责用户的属性,简单地说,IUserBO的职责就是收集和反馈用户的属性信息;IUserBiz负责用户的行为,完成用户信息的维护和变更。各位可能要说了,这个与我实际工作中用到的User类还是有差别的呀~别着急,我们先来看一看分拆成两个接口怎么使用。OK,我们现在是面向接口编程,所以产生了这个UserInfo对象之后,当然可以把它当IUserBO接口使用。当然,也可以当IUserBiz接口使用,这要看你在什么地方使用了。要获得用户信息,就当是IUserBO的实现类;要是希望维护用户的信息,就把它当作IUserBiz的实现类就成了,如代码清单1-1所示。
代码清单1-1 分清职责后的代码示例
?
....... 1
2
IUserBiz userInfo = new UserInfo(); 3
4
//我要赋值了,我就认为它是一个纯粹的BO 5
6
IUserBO userBO = (IUserBO)userInfo; 7
8
userBO.setPassword("abc"); 9
10
//我要执行动作了,我就认为是一个业务逻辑类 11
12
IUserBiz userBiz = (IUserBiz)userInfo; 13
14
userBiz.deleteUser(); 15
16
....... 17
确实可以如此,问题也解决了,但是我们来回想一下我们刚才的动作,为什么要把一个接口拆分成两个呢,其实,在实际的使用中,我们更倾向于使用两个不同的类或接口:一个是IUserBO, 一个是IUserBiz,类图应该如图1-3所示。
图1-3 项目中经常采用的SRP类图
以上我们把一个接口拆分成两个接口的动作,就是依赖了单一职责原则,那什么是单一职责原则呢,单一职责原则的定义是:应该有且仅有一个原因引起类的变更。 1.2 绝杀技,打破你的传统思维
解释到这里,估计你已经很不屑了,“切~这么简单的东西还要讲,~弱智!”好,我们来讲点复杂的。SRP的原话解释是:There should never be more than one
reason for a class to change。这句话初中生都能看懂,不多说,但是看懂是一码事,实施就是另外一码事了。上面讲的例子很好理解,在实际项目中大家已经都是这么做了,那我们再来看下面这个例子是否好理解。电话这玩意,是现代人都离不了,电话通话的时候有四个过程发生:拨号、通话、回应、挂机,那我们写一个接口,其类图应该如图1-4所示。
图1-4 电话类图
我不是有意要冒犯IPhone的,同名纯属巧合,我们来看一个这个过程的代码,如代码清单1-2所示。
代码清单1-2 电话过程
?
public interface IPhone { 1
2
//拨通电话 3
4
public void dial(String phoneNumber); 5
6
//通话 7
8
public void chat(Object o); 9
10
//回应,只有自己说话而没有回应,那算啥,~ 11
12
public void answer(Object o); 13
14
//通话完毕,挂电话 15
16
public void huangup(); 17
18
} 19
实现类也比较简单,我就不再写了,大家看看这个接口有没有问题,我相信大部分的读者都会说这个没有问题呀,以前我就是这么做的呀,某某书上也是这么写的呀,还有什么什么的源码也是这么写的~是的,这个接口接近于完美,看清楚了,是“接近”~单一职责原则要求一个接口或类只有一个原因引起变化,也就是一个接口或类只有一个职责,它就负责一件事情,看看上面的接口只负责一件事情吗,是只有一个原因引起变化吗,好像不是~
IPhone这个接口可不是只有一个职责,它包含了两个职责:一个是协议管理,一个是数据传送。diag()和huangup()两个方法实现的是协议管理,分别负责拨号接通和挂机;chat()和answer()是数据的传送,把我们说的话转换成模拟信号或数字信号传递到对方,然后再把对方传递过来的信号还原成我们听得懂语言。我们可以这样考虑这个问题,协议接通的变化会引起这个接口或实现类的变化吗,会的~那数据传送(想想看,电话不仅仅可以通话,还可以上网)的变化会引起这个接口或实现类的变化吗,会的~那就很简单了,这里有两个原因都引起了类的变化,而且这两个职责会相互影响吗,电话拨号,我只要能接通就成,甭管是电信的还是网通的协议;电话连接后还关心传递的是什么数据吗,不关心,你要是乐意使用56K的小猫传递一个高清的片子,那也没有问题(顶多有人说你13了)。通过这样的分析,我们发现类图上的IPhone接口包含了两个职责,而且这两个职责的变化不相互影响,那就考虑拆开成两个接口,其类图如图1-5所示。
图1-5 职责分明的电话类图
这个类图看着有点复杂了,完全满足了单一职责原则的要求,每个接口职责分明,结构清晰,但是我相信你在设计的时候肯定不会采用这种方式,一个手机类要把ConnectionManager和DataTransfer组合在一块才能使用。组合是一种强耦合关系,你和我都有共同的生命期,这样的强耦合关系还不如使用接口实现的方式呢,而且还增加了类的复杂性,多了两个类。经过这样的思考后,我们再修改一下类图,如图1-6所示。
图1-6 简洁清晰、职责分明的电话类图
这样的设计才是完美的,一个类实现了两个接口,把两个职责融合在一个类中。你会觉得这个Phone有两个原因引起变化了呀,是的是的,但是别忘记了我们是面向接口编程,我们对外公布的是接口而不是实现类。而且,如果真要实现类的单一职责,这个就必须使用上面的组合模式了,这会引起类间耦合过重、类的数量增加等问题,人为的增加了设计的复杂性。
通过上面的例子,我们来总结一下单一职责原则有什么好处: , 类的复杂性降低,实现什么职责都有清晰明确的定义;
, 可读性提高,复杂性降低,那当然可读性提高了;
, 可维护性提高,那当然了,可读性提高,那当然更容易维护了; , 变更引起的风险降低,变更是必不可少的,如果接口的单一职责做得好,一个
接口修改只对相应的实现类有影响,对其他的接口无影响,这对系统的扩展性、
维护性都有非常大帮助。
看过电话这个例子后,是不是有点反思了,我以前的设计是不是有点的问题了,不,不是的,不要怀疑自己的技术能力,单一职责原则最难划分的就是职责。一个职责一个接口,但问题是“职责”是一个没有量化的
,一个类到底要负责那些职责,这些职责该怎么细化,细化后是否都要有一个接口或类,这些都需要从实际的项目去考虑,从功能上来说,定义一个IPhone接口也没有错,实现了电话的功能,而且设计还很简单,仅仅一个接口一个实现类,实际的项目我想大家都会这么设计。项目要考虑可变因素和不可变因素,以及相关的收益成本比率,因此设计一个IPhone接口也可能是没有错的。但是,如果纯从“学究”理论上分析就有问题了,有两个可以变化的原因放到了一个接口中,这就为以后的变化带来了风险。如果以后模拟电话升级到数字电话,我们提供的接口IPhone是不是要修改了,接口修改对其他的Invoker类是不是有很大影响,~
注意 单一职责原则提出了一个编写程序的标准,用“职责”或“变化原因”来衡量接口或类设计得是否有优良,但是“职责”和“变化原因”都是不可度量的,因项目而异,因环境而异。
1.3 我单纯,所以我快乐
对于接口,我们在设计的时候一定要做到单一,但是对于实现类就需要多方面考虑了。生搬硬套单一职责原则会引起类的剧增,给维护带来非常多的麻烦,而且过分的细分类的职责也会人为地制造系统的复杂性,本来一个类可以实现的行为硬要拆成两个类,然后使用聚合或组合的方式再耦合在一起,这个是人为制造了系统的复杂性,所以原则是死的,人是活的,这句话是非常好的。
单一职责原则很难在项目中得到体现,非常难,为什么,在国内,技术人员的地位和话语权都比较低,因此在项目中需要考虑环境,考虑工作量,考虑人员的技术水平,考虑硬件的资源情况,等等,最终妥协的结果是经常违背单一原则。而且,我们中华文明就有很多属于混合型的产物,比如筷子,我们可以把筷子当做刀来使用,分割食物;还可以当叉使用,把食物从盘子中移动到口中。而在西方的文化中,刀就是刀,叉就是叉,你去吃西餐的时候这两样肯定都是有的,刀就是切割食物,叉就是固定食物或者移动食物,分工很明晰。这种文化的差异是很难一步改造过来,但是我相信随着技术的深入,单一职责原则必然会深入到项目的设计中去,而且这个原则是那么的简单,简单得不需要我们更加深入地思考,单从字面上大家都应该知道是什么意思,单一职责嘛~
单一职责适用于接口、类,同时也适用于方法,什么意思呢,一个方法尽可能做一件事情,比如一个方法修改用户密码,不要把这个方法放到“修改用户信息”方法中,这个方法的颗粒度很粗,比如图1-7中所示的方法。
图1-7 一个方法承担多个职责
在IUserManager中定义了一个方法changeUser,根据传递的类型不同,把可变长度参数changeOptions修改到userBo这个对象上,并调用持久层的方法保存到数据库中。在我的项目组中,如果有人写了这样一个方法,我不管他写了多少程序,花了多少工夫,一律重写~原因很简单:方法职责不清晰,不单一,不要让别人猜测这个方法可能是用来处理什么逻辑。比较好的设计如图1-8所示。
图1-8 一个方法承担一个职责
通过上面的类图,如果要修改用户名称,就调用changeUserName方法;要修改家庭地址,就调用changeHomeAddress方法;要修改单位电话,就调用changeOfficeTel方法。每个方法的职责非常清晰明确,不仅开发简单,而且日后的维护也非常容易,大家可以逐渐养成这样的习惯。
所以,如果对接口、类、方法使用了单一职责原则,那么快乐的就不仅仅是你了,还有你的项目组成员,大家可以轻松而又愉快地进行开发;还有你的老板,减少了因为变更引起的工作量,减少了无为人员和资金消耗。当然,最快乐的也许就是你了,因为加官进爵可能等着你哟~
定义:不要存在多于一个导致类变更的原因。通俗的说,即一个类只负责一项职责。
问题由来:类T负责两个不同的职责:职责P1,职责P2。当由于职责P1需求发生改变而需要修改类T时,有可能会导致原本运行正常的职责P2功能发生故障。
解决
:遵循单一职责原则。分别建立两个类T1、T2,使T1完成职责P1功能,T2完成职责P2功能。这样,当修改类T1时,不会使职责P2发生故障风险;同理,当修改T2时,也不会使职责P1发生故障风险。
说到单一职责原则,很多人都会不屑一顾。因为它太简单了。稍有经验的程序员即使从来没有读过设计模式、从来没有听说过单一职责原则,在设计软件时也会自觉的遵守这一重要原则,因为这是常识。在软件编程中,谁也不希望因为修改了一个功能导致其他的功能发生故障。而避免出现这一问题的方法便是遵循单一职责原则。虽然单一职责原则如此简单,并且被认为是常识,但是即便是经验丰富的程序员写出的程序,也会有违背这一原则的代码存在。为什么会出现这种现象呢,因为有职责扩散。所谓职责扩散,就是因为某种原因,职责P被分化为粒度更细的职责P1和P2。
比如:类T只负责一个职责P,这样设计是符合单一职责原则的。后来由于某种原因,也许是需求变更了,也许是程序的设计者境界提高了,需要将职责P细分为粒度更细的职责P1,P2,这时如果要使程序遵循单一职责原则,需要将类T也分解为两个类T1和T2,分别负责P1、P2两个职责。但是在程序已经写好的情况下,这样做简直太费时间了。所以,简单的修改类T,用它来负责两个职责是一个比较不错的选择,虽然这样做有悖于单一职责原则。(这样做的风险在于职责扩散的不确定性,因为我们不会想到这个职责P,在未来可能会扩散为P1,P2,P3,P4……Pn。所以记住,在职责扩散到我们无法控制的程度之前,立刻对代码进行重构。) 举例说明,用一个类描述动物呼吸这个场景:
[java] view plaincopyprint?
1. class Animal{
2. public void breathe(String animal){
3. System.out.println(animal+"呼吸空气");
4. }
5. }
6. public class Client{
7. public static void main(String[] args){
8. Animal animal = new Animal();
9. animal.breathe("牛");
10. animal.breathe("羊");
11. animal.breathe("猪");
12. }
13. }
运行结果:
牛呼吸空气
羊呼吸空气
猪呼吸空气
程序上线后,发现问题了,并不是所有的动物都呼吸空气的,比如鱼就是呼吸水的。修改时如果遵循单一职责原则,需要将Animal类细分为陆生动物类Terrestrial,水生动物Aquatic,代码如下:
[java] view plaincopyprint?
1. class Terrestrial{
2. public void breathe(String animal){
3. System.out.println(animal+"呼吸空气");
4. }
5. }
6. class Aquatic{
7. public void breathe(String animal){
8. System.out.println(animal+"呼吸水");
9. }
10. }
11.
12. public class Client{
13. public static void main(String[] args){
14. Terrestrial terrestrial = new Terrestrial();
15. terrestrial.breathe("牛");
16. terrestrial.breathe("羊");
17. terrestrial.breathe("猪");
18.
19. Aquatic aquatic = new Aquatic();
20. aquatic.breathe("鱼");
21. }
22. }
运行结果:
牛呼吸空气
羊呼吸空气
猪呼吸空气
鱼呼吸水
我们会发现如果这样修改花销是很大的,除了将原来的类分解之外,还需要修改客户端。而直接修改类Animal来达成目的虽然违背了单
一职责原则,但花销却小的多,代码如下:
[java] view plaincopyprint?
1. class Animal{
2. public void breathe(String animal){
3. if("鱼".equals(animal)){
4. System.out.println(animal+"呼吸水");
5. }else{
6. System.out.println(animal+"呼吸空气");
7. }
8. }
9. }
10.
11. public class Client{
12. public static void main(String[] args){
13. Animal animal = new Animal();
14. animal.breathe("牛");
15. animal.breathe("羊");
16. animal.breathe("猪");
17. animal.breathe("鱼");
18. }
19. }
可以看到,这种修改方式要简单的多。但是却存在着隐患:有一天需要将鱼分为呼吸淡水的鱼和呼吸海水的鱼,则又需要修改Animal类的breathe方法,而对原有代码的修改会对调用“猪”“牛”“羊”等相关功能带来风险,也许某一天你会发现程序运行的结果变为“牛呼吸水”了。这种修改方式直接在代码级别上违背了单一职责原则,虽然修改起来最简单,但隐患却是最大的。还有一种修改方式: [java] view plaincopyprint?
1. class Animal{
2. public void breathe(String animal){
3. System.out.println(animal+"呼吸空气");
4. }
5.
6. public void breathe2(String animal){
7. System.out.println(animal+"呼吸水");
8. }
9. }
10.
11. public class Client{
12. public static void main(String[] args){
13. Animal animal = new Animal();
14. animal.breathe("牛");
15. animal.breathe("羊");
16. animal.breathe("猪");
17. animal.breathe2("鱼");
18. }
19. }
可以看到,这种修改方式没有改动原来的方法,而是在类中新加了一个方法,这样虽然也违背了单一职责原则,但在方法级别上却是符合单一职责原则的,因为它并没有动原来方法的代码。这三种方式各有优缺点,那么在实际编程中,采用哪一中呢,其实这真的比较难说,需要根据实际情况来确定。我的原则是:只有逻辑足够简单,才可以在代码级别上违反单一职责原则;只有类中方法数量足够少,才可以在方法级别上违反单一职责原则;
例如本文所举的这个例子,它太简单了,它只有一个方法,所以,无论是在代码级别上违反单一职责原则,还是在方法级别上违反,都不会造成太大的影响。实际应用中的类都要复杂的多,一旦发生职责扩散而需要修改类时,除非这个类本身非常简单,否则还是遵循单一职责原则的好。
遵循单一职责原的优点有:
, 可以降低类的复杂度,一个类只负责一项职责,其逻辑肯定要比负责多项职责简单的多;
, 提高类的可读性,提高系统的可维护性;
, 变更引起的风险降低,变更是必然的,如果单一职责原则遵守的好,当修改一个功能时,可以显著降低对其他功能的影响。
需要说明的一点是单一职责原则不只是面向对象编程思想所特有的,只要是模块化的程序设计,都适用单一职责原则。
肯定有不少人跟我刚看到这项原则的时候一样,对这个原则的名字充满疑惑。其实原因就是这项原则最早是在1988年,由麻省理工学院的一位姓里的女士(Barbara Liskov)提出来的。
定义1:如果对每一个类型为 T1的对象 o1,都有类型为 T2 的对象o2,使得以 T1定义的所有程序 P 在所有的对象 o1 都代换成 o2 时,程序 P 的行为没有发生变化,那么类型 T2 是类型 T1 的子类型。
定义2:所有引用基类的地方必须能透明地使用其子类的对象。
问题由来:有一功能P1,由类A完成。现需要将功能P1进行扩展,扩展后的功能为P,其中P由原有功能P1与新功能P2组成。新功能P由类A的子类B来完成,则子类B在完成新功能P2的同时,有可能会导致原有功能P1发生故障。 解决方案:当使用继承时,遵循里氏替换原则。类B继承类A时,除添加新的方法完成新增功能P2外,尽量不要重写父类A的方法,也尽量不要重载父类A的方法。
继承包含这样一层含义:父类中凡是已经实现好的方法(相对于抽象方法而言),实际上是在设定一系列的
和契约,虽然它不强制要求所有的子类必须遵从这些契约,但是如果子类对这些非抽象方法任意修改,就会对整个继承体系造成破坏。而里氏替换原则就是表达了这一层含义。
继承作为面向对象三大特性之一,在给程序设计带来巨大便利的同时,也带来了弊端。比如使用继承会给程序带来侵入性,程序的可移植性降低,增加了对象间的耦合性,如果一个类被其他的类所继承,则当这个类需要修改时,必须考虑到所有的子类,并且父类修改后,所有涉及到子类的功能都有可能会产生故障。
举例说明继承的风险,我们需要完成一个两数相减的功能,由类A来负责。
[java] view plaincopyprint?
1. class A{
2. public int func1(int a, int b){
3. return a-b;
4. }
5. }
6.
7. public class Client{
8. public static void main(String[] args){
9. A a = new A();
10. System.out.println("100-50="+a.func1(100, 50));
11. System.out.println("100-80="+a.func1(100, 80));
12. }
13. }
运行结果:
100-50=50
100-80=20
后来,我们需要增加一个新的功能:完成两数相加,然后再与100求和,由类B来负责。即类B需要完成两个功能:
, 两数相减。
, 两数相加,然后再加100。
由于类A已经实现了第一个功能,所以类B继承类A后,只需要再完成第二个功能就可以了,代码如下: [java] view plaincopyprint?
1. class B extends A{
2. public int func1(int a, int b){
3. return a+b;
4. }
5.
6. public int func2(int a, int b){
7. return func1(a,b)+100;
8. }
9. }
10.
11. public class Client{
12. public static void main(String[] args){
13. B b = new B();
14. System.out.println("100-50="+b.func1(100, 50));
15. System.out.println("100-80="+b.func1(100, 80));
16. System.out.println("100+20+100="+b.func2(100, 20));
17. }
18. }
类B完成后,运行结果:
100-50=150
100-80=180
100+20+100=220
我们发现原本运行正常的相减功能发生了错误。原因就是类B在给方法起名时无意中重写了父类的方法,造成所有运行相减功能的代码全部调用了类B重写后的方法,造成原本运行正常的功能出现了错误。在本例中,引用基类A完成的功能,换成子类B之后,发生了异常。在实际编程中,我们常常会通过重写父类的方法来完成新的功能,这样写起来虽然简单,但是整个继承体系的可复用性会比较差,特别是运用多态比较频繁时,程序运行出错的几率非常大。如果非要重写父类的方法,比较通用的做法是:原来的父类和子类都继承一个更通俗的基类,原有的继承关系去掉,采用依赖、聚合,组合等关系代替。
里氏替换原则通俗的来讲就是:子类可以扩展父类的功能,但不能改变父类原有的功能。它包含以下4层含义:
, 子类可以实现父类的抽象方法,但不能覆盖父类的非抽象方法。
, 子类中可以增加自己特有的方法。
, 当子类的方法重载父类的方法时,方法的前置条件(即方法的形参)要比父类方法的输入参数更宽松。
, 当子类的方法实现父类的抽象方法时,方法的后置条件(即方法的返回值)要比父类更严格。
看上去很不可思议,因为我们会发现在自己编程中常常会违反里氏替换原则,程序照样跑的好好的。所以大家都会产生这样的疑问,假如我非要不遵循里氏替换原则会有什么后果,
后果就是:你写的代码出问题的几率将会大大增加。
定义:高层模块不应该依赖低层模块,二者都应该依赖其抽象;抽象不应该依赖细节;细节应该依赖抽象。 问题由来:类A直接依赖类B,假如要将类A改为依赖类C,则必须通过修改类A的代码来达成。这种场景下,类A一般是高层模块,负责复杂的业务逻辑;类B和类C是低层模块,负责基本的原子操作;假如修改类A,会给程序带来不必要的风险。 解决方案:将类A修改为依赖接口I,类B和类C各自实现接口I,类A通过接口I间接与类B或者类C发生联系,则会大大降低修改类A的几率。
依赖倒置原则基于这样一个事实:相对于细节的多变性,抽象的东西要稳定的多。以抽象为基础搭建起来的
比以细节为基础搭建起来的架构要稳定的多。在java中,抽象指的是接口或者抽象类,细节就是具体的实现类,使用接口或者抽象类的目的是制定好规范和契约,而不去涉及任何具体的操作,把展现细节的任务交给他们的实现类去完成。
依赖倒置原则的核心思想是面向接口编程,我们依旧用一个例子来说明面向接口编程比相对于面向实现编程好在什么地方。场景是这样的,母亲给孩子讲故事,只要给她一本书,她就可以照着书给孩子讲故事了。代码如下:
[java] view plaincopyprint?
1. class Book{
2. public String getContent(){
3. return "很久很久以前有一个阿拉伯的故事……";
4. }
5. }
6.
7. class Mother{
8. public void narrate(Book book){
9. System.out.println("妈妈开始讲故事");
10. System.out.println(book.getContent());
11. }
12. }
13.
14. public class Client{
15. public static void main(String[] args){
16. Mother mother = new Mother();
17. mother.narrate(new Book());
18. }
19. }
运行结果:
妈妈开始讲故事
很久很久以前有一个阿拉伯的故事……
运行良好,假如有一天,需求变成这样:不是给书而是给一份报纸,让这位母亲讲一下报纸上的故事,报纸的代码如下: [java] view plaincopyprint?
1. class Newspaper{
2. public String getContent(){
3. return "林书豪38+7领导尼克斯击败湖人……";
4. }
5. }
这位母亲却办不到,因为她居然不会读报纸上的故事,这太荒唐了,只是将书换成报纸,居然必须要修改Mother才能读。假如以
后需求换成杂志呢,换成网页呢,还要不断地修改Mother,这显然不是好的设计。原因就是Mother与Book之间的耦合性太高了,必须
降低他们之间的耦合度才行。
我们引入一个抽象的接口IReader。读物,只要是带字的都属于读物:
[java] view plaincopyprint?
1. interface IReader{
2. public String getContent();
3. }
Mother类与接口IReader发生依赖关系,而Book和Newspaper都属于读物的范畴,他们各自都去实现IReader接口,这样就符合依赖
倒置原则了,代码修改为:
[java] view plaincopyprint?
1. class Newspaper implements IReader {
2. public String getContent(){
3. return "林书豪17+9助尼克斯击败老鹰……";
4. }
5. }
6. class Book implements IReader{
7. public String getContent(){
8. return "很久很久以前有一个阿拉伯的故事……";
9. }
10. }
11.
12. class Mother{
13. public void narrate(IReader reader){
14. System.out.println("妈妈开始讲故事");
15. System.out.println(reader.getContent());
16. }
17. }
18.
19. public class Client{
20. public static void main(String[] args){
21. Mother mother = new Mother();
22. mother.narrate(new Book());
23. mother.narrate(new Newspaper());
24. }
25. }
运行结果:
妈妈开始讲故事
很久很久以前有一个阿拉伯的故事……
妈妈开始讲故事
林书豪17+9助尼克斯击败老鹰……
这样修改后,无论以后怎样扩展Client类,都不需要再修改Mother类了。这只是一个简单的例子,实际情况中,代表高层模块的Mother类将负责完成主要的业务逻辑,一旦需要对它进行修改,引入错误的风险极大。所以遵循依赖倒置原则可以降低类之间的耦合性,提高系统的稳定性,降低修改程序造成的风险。
采用依赖倒置原则给多人并行开发带来了极大的便利,比如上例中,原本Mother类与Book类直接耦合时,Mother类必须等Book类编码完成后才可以进行编码,因为Mother类依赖于Book类。修改后的程序则可以同时开工,互不影响,因为Mother与Book类一点关系也没有。参与协作开发的人越多、项目越庞大,采用依赖导致原则的意义就越重大。现在很流行的TDD开发模式就是依赖倒置原则最成功的应用。
定义:一个对象应该对其他对象保持最少的了解。
问题由来:类与类之间的关系越密切,耦合度越大,当一个类发生改变时,对另一个类的影响也越大。
解决方案:尽量降低类与类之间的耦合。
自从我们接触编程开始,就知道了软件编程的总的原则:低耦合,高内聚。无论是面向过程编程还是面向对象编程,只有使各个模块之间的耦合尽量的低,才能提高代码的复用率。低耦合的优点不言而喻,但是怎么样编程才能做到低耦合呢,那正是迪米特法则要去完成的。
迪米特法则又叫最少知道原则,最早是在1987年由美国Northeastern University的Ian Holland提出。通俗的来讲,就是一个类对自己依赖的类知道的越少越好。也就是说,对于被依赖的类来说,无论逻辑多么复杂,都尽量地的将逻辑封装在类的内部,对外除了提供的public方法,不对外泄漏任何信息。迪米特法则还有一个更简单的定义:只与直接的朋友通信。首先来解释一下什么是直接的朋友:每个对象都会与其他对象有耦合关系,只要两个对象之间有耦合关系,我们就说这两个对象之间是朋友关系。耦合的方式很多,依赖、关联、组合、聚合等。其中,我们称出现成员变量、方法参数、方法返回值中的类为直接的朋友,而出现在局部变量中的类则不是直接的朋友。也就是说,陌生的类最好不要作为局部变量的形式出现在类的内部。
举一个例子:有一个集团公司,下属单位有分公司和直属部门,现在要求打印出所有下属单位的员工ID。先来看一下违反迪米特法则的设计。
[java] view plaincopyprint?
1. //总公司员工
2. class Employee{
3. private String id;
4. public void setId(String id){
5. this.id = id;
6. }
7. public String getId(){
8. return id;
9. }
10. }
11.
12. //分公司员工
13. class SubEmployee{
14. private String id;
15. public void setId(String id){
16. this.id = id;
17. }
18. public String getId(){
19. return id;
20. }
21. }
22.
23. class SubCompanyManager{
24. public List
getAllEmployee(){
25. List list = new ArrayList();
26. for(int i=0; i<100; i++){
27. SubEmployee emp = new SubEmployee();
28. //为分公司人员按顺序分配一个ID
29. emp.setId("分公司"+i);
30. list.add(emp);
31. }
32. return list;
33. }
34. }
35.
36. class CompanyManager{
37.
38. public List getAllEmployee(){ 39. List list = new ArrayList(); 40. for(int i=0; i<30; i++){
41. Employee emp = new Employee(); 42. //为总公司人员按顺序分配一个ID
43. emp.setId("总公司"+i);
44. list.add(emp);
45. }
46. return list;
47. }
48.
49. public void printAllEmployee(SubCompanyManager sub){ 50. List list1 = sub.getAllEmployee(); 51. for(SubEmployee e:list1){
52. System.out.println(e.getId()); 53. }
54.
55. List list2 = this.getAllEmployee(); 56. for(Employee e:list2){
57. System.out.println(e.getId()); 58. }
59. }
60. }
61.
62. public class Client{
63. public static void main(String[] args){ 64. CompanyManager e = new CompanyManager(); 65. e.printAllEmployee(new SubCompanyManager()); 66. }
67. }
现在这个设计的主要问题出在CompanyManager中,根据迪米特法则,只与直接的朋友发生通信,而SubEmployee类并不是
CompanyManager类的直接朋友(以局部变量出现的耦合不属于直接朋友),从逻辑上讲总公司只与他的分公司耦合就行了,与分公司
的员工并没有任何联系,这样设计显然是增加了不必要的耦合。按照迪米特法则,应该避免类中出现这样非直接朋友关系的耦合。修改
后的代码如下:
[java] view plaincopyprint?
1. class SubCompanyManager{
2. public List getAllEmployee(){ 3. List list = new ArrayList();
4. for(int i=0; i<100; i++){
5. SubEmployee emp = new SubEmployee(); 6. //为分公司人员按顺序分配一个ID
7. emp.setId("分公司"+i);
8. list.add(emp);
9. }
10. return list;
11. }
12. public void printEmployee(){
13. List list = this.getAllEmployee(); 14. for(SubEmployee e:list){
15. System.out.println(e.getId()); 16. }
17. }
18. }
19.
20. class CompanyManager{
21. public List getAllEmployee(){ 22. List list = new ArrayList(); 23. for(int i=0; i<30; i++){
24. Employee emp = new Employee(); 25. //为总公司人员按顺序分配一个ID
26. emp.setId("总公司"+i);
27. list.add(emp);
28. }
29. return list;
30. }
31.
32. public void printAllEmployee(SubCompanyManager sub){ 33. sub.printEmployee();
34. List list2 = this.getAllEmployee(); 35. for(Employee e:list2){
36. System.out.println(e.getId()); 37. }
38. }
39. }
修改后,为分公司增加了打印人员ID的方法,总公司直接调用来打印,从而避免了与分公司的员工发生耦合。
迪米特法则的初衷是降低类之间的耦合,由于每个类都减少了不必要的依赖,因此的确可以降低耦合关系。但是凡事都有度,虽然
可以避免与非直接的类通信,但是要通信,必然会通过一个“中介”来发生联系,例如本例中,总公司就是通过分公司这个“中介”来与分公
司的员工发生联系的。过分的使用迪米特原则,会产生大量这样的中介和传递类,导致系统复杂度变大。所以在采用迪米特法则时要反
复权衡,既做到结构清晰,又要高内聚低耦合。
定义:客户端不应该依赖它不需要的接口;一个类对另一个类的依赖应该建立在最小的接口上。 问题由来:类A通过接口I依赖类B,类C通过接口I依赖类D,如果接口I对于类A和类B来说不是最小接口,则类B和类D必须去实现他们不需要的方法。
解决方案:将臃肿的接口I拆分为独立的几个接口,类A和类C分别与他们需要的接口建立依赖关系。也就是采用接口隔离原则。 举例来说明接口隔离原则:
(图1 未遵循接口隔离原则的设计)
这个图的意思是:类A依赖接口I中的方法1、方法2、方法3,类B是对类A依赖的实现。类C依赖接口I中的方法1、方法4、
方法5,类D是对类C依赖的实现。对于类B和类D来说,虽然他们都存在着用不到的方法(也就是图中红色字体标记的方法),但由于实现了接口I,所以也必须要实现这些用不到的方法。对类图不熟悉的可以参照程序代码来理解,代码如下: [java] view plaincopyprint?
1. interface I {
2. public void method1();
3. public void method2();
4. public void method3();
5. public void method4();
6. public void method5();
7. }
8.
9. class A{
10. public void depend1(I i){
11. i.method1();
12. }
13. public void depend2(I i){
14. i.method2();
15. }
16. public void depend3(I i){
17. i.method3();
18. }
19. }
20.
21. class B implements I{ 22. public void method1() { 23. System.out.println("类B实现接口I的方法1"); 24. }
25. public void method2() { 26. System.out.println("类B实现接口I的方法2"); 27. }
28. public void method3() { 29. System.out.println("类B实现接口I的方法3"); 30. }
31. //对于类B来说,method4和method5不是必需的,但是由于接口A中有这两个方法,
32. //所以在实现过程中即使这两个方法的方法体为空,也要将这两个没有作用的方法进行实现。
33. public void method4() {} 34. public void method5() {} 35. }
36.
37. class C{
38. public void depend1(I i){ 39. i.method1();
40. }
41. public void depend2(I i){ 42. i.method4();
43. }
44. public void depend3(I i){ 45. i.method5();
46. }
47. }
48.
49. class D implements I{ 50. public void method1() { 51. System.out.println("类D实现接口I的方法1"); 52. }
53. //对于类D来说,method2和method3不是必需的,但是由于接口A中有这两个方法,
54. //所以在实现过程中即使这两个方法的方法体为空,也要将这两个没有作用的方法进行实现。
55. public void method2() {} 56. public void method3() {} 57.
58. public void method4() { 59. System.out.println("类D实现接口I的方法4"); 60. }
61. public void method5() {
62. System.out.println("类D实现接口I的方法5");
63. }
64. }
65.
66. public class Client{
67. public static void main(String[] args){
68. A a = new A();
69. a.depend1(new B());
70. a.depend2(new B());
71. a.depend3(new B());
72.
73. C c = new C();
74. c.depend1(new D());
75. c.depend2(new D());
76. c.depend3(new D());
77. }
78. }
可以看到,如果接口过于臃肿,只要接口中出现的方法,不管对依赖于它的类有没有用处,实现类中都必须去实现这些方法,这显
然不是好的设计。如果将这个设计修改为符合接口隔离原则,就必须对接口I进行拆分。在这里我们将原有的接口I拆分为三个接口,拆
分后的设计如图2所示:
(图2 遵循接口隔离原则的设计) 照例贴出程序的代码,供不熟悉类图的朋友参考:
[java] view plaincopyprint?
1. interface I1 {
2. public void method1();
3. }
4.
5. interface I2 {
6. public void method2(); 7. public void method3(); 8. }
9.
10. interface I3 {
11. public void method4(); 12. public void method5(); 13. }
14.
15. class A{
16. public void depend1(I1 i){ 17. i.method1();
18. }
19. public void depend2(I2 i){ 20. i.method2();
21. }
22. public void depend3(I2 i){ 23. i.method3();
24. }
25. }
26.
27. class B implements I1, I2{ 28. public void method1() { 29. System.out.println("类B实现接口I1的方法1"); 30. }
31. public void method2() { 32. System.out.println("类B实现接口I2的方法2"); 33. }
34. public void method3() { 35. System.out.println("类B实现接口I2的方法3"); 36. }
37. }
38.
39. class C{
40. public void depend1(I1 i){ 41. i.method1();
42. }
43. public void depend2(I3 i){ 44. i.method4();
45. }
46. public void depend3(I3 i){ 47. i.method5();
48. }
49. }
50.
51. class D implements I1, I3{
52. public void method1() {
53. System.out.println("类D实现接口I1的方法1");
54. }
55. public void method4() {
56. System.out.println("类D实现接口I3的方法4");
57. }
58. public void method5() {
59. System.out.println("类D实现接口I3的方法5");
60. }
61. }
接口隔离原则的含义是:建立单一接口,不要建立庞大臃肿的接口,尽量细化接口,接口中的方法尽量少。也就是说,我们要为各个类建立专用的接口,而不要试图去建立一个很庞大的接口供所有依赖它的类去调用。本文例子中,将一个庞大的接口变更为3个专用的接口所采用的就是接口隔离原则。在程序设计中,依赖几个专用的接口要比依赖一个综合的接口更灵活。接口是设计时对外部设定的“契约”,通过分散定义多个接口,可以预防外来变更的扩散,提高系统的灵活性和可维护性。
说到这里,很多人会觉的接口隔离原则跟之前的单一职责原则很相似,其实不然。其一,单一职责原则原注重的是职责;而接口隔离原则注重对接口依赖的隔离。其二,单一职责原则主要是约束类,其次才是接口和方法,它针对的是程序中的实现和细节;而接口隔离原则主要约束接口接口,主要针对抽象,针对程序整体框架的构建。
采用接口隔离原则对接口进行约束时,要注意以下几点:
, 接口尽量小,但是要有限度。对接口进行细化可以提高程序设计灵活性是不挣的事实,但是如果过小,则会造成接口数量过
多,使设计复杂化。所以一定要适度。
, 为依赖接口的类定制服务,只暴露给调用的类它需要的方法,它不需要的方法则隐藏起来。只有专注地为一个模块提供定制
服务,才能建立最小的依赖关系。
, 提高内聚,减少对外交互。使接口用最少的方法去完成最多的事情。
运用接口隔离原则,一定要适度,接口设计的过大或过小都不好。设计接口的时候,只有多花些时间去思考和筹划,才能准确地实践这一原则。
定义:一个软件实体如类、模块和函数应该对扩展开放,对修改关闭。
问题由来:在软件的生命周期内,因为变化、升级和维护等原因需要对软件原有代码进行修改时,可能会给旧代码中引入错误,也可能会使我们不得不对整个功能进行重构,并且需要原有代码经过重新测试。
解决方案:当软件需要变化时,尽量通过扩展软件实体的行为来实现变化,而不是通过修改已有的代码来实现变化。
开闭原则是面向对象设计中最基础的设计原则,它指导我们如何建立稳定灵活的系统。开闭原则可能是设计模式六项原则中定义最模糊的一个了,它只告诉我们对扩展开放,对修改关闭,可是到底如何才能做到对扩展开放,对修改关闭,并没有明确的告诉我们。以前,如果有人告诉我“你进行设计的时候一定要遵守开闭原则”,我会觉的他什么都没说,但貌似又什么都说了。因为开闭原则真的太虚了。
在仔细思考以及仔细阅读很多设计模式的文章后,终于对开闭原则有了一点认识。其实,我们遵循设计模式前面5大原则,以及使用23种设计模式的目的就是遵循开闭原则。也就是说,只要我们对前面5项原则遵守的好了,设计出的软件自然是符合开闭原则的,这个开闭原则更像是前面五项原则遵守程度的“平均得分”,前面5项原则遵守的好,平均分自然就高,说明软件设计开闭原则遵守的好;如果前面5项原则遵守的不好,则说明开闭原则遵守的不好。
其实笔者认为,开闭原则无非就是想表达这样一层意思:用抽象构建框架,用实现扩展细节。因为抽象灵活性好,适应性广,只要抽象的合理,可以基本保持软件架构的稳定。而软件中易变的细节,我们用从抽象派生的实现类来进行扩展,当软件需要发生变化时,我们只需要根据需求重新派生一个实现类来扩展就可以了。当然前提是我们的抽象要合理,要对需求的变更有前瞻性和预见性才行。
说到这里,再回想一下前面说的5项原则,恰恰是告诉我们用抽象构建框架,用实现扩展细节的注意事项而已:单一职责原则告诉我们实现类要职责单一;里氏替换原则告诉我们不要破坏继承体系;依赖倒置原则告诉我们要面向接口编程;接口隔离原则告诉我们在设计接口的时候要精简单一;迪米特法则告诉我们要降低耦合。而开闭原则是总纲,他告诉我们要对扩展开放,对修改关闭。
最后说明一下如何去遵守这六个原则。对这六个原则的遵守并不是是和否的问题,而是多和少的问题,也就是说,我们一般不会说有没有遵守,而是说遵守程度的多少。任何事都是过犹不及,设计模式的六个设计原则也是一样,制定这六个原则的目的并不是要我们刻板的遵守他们,而需要根据实际情况灵活运用。对他们的遵守程度只要在一个合理的范围内,就算是良好的设计。我们用一幅图来说明一下。
图中的每一条维度各代表一项原则,我们依据对这项原则的遵守程度在维度上画一个点,则如果对这项原则遵守的合理的话,这个点应该落在红色的同心圆内部;如果遵守的差,点将会在小圆内部;如果过度遵守,点将会落在大圆外部。一个良好的设计体现在图中,应该是六个顶点都在同心圆中的六边形。
在上图中,设计1、设计2属于良好的设计,他们对六项原则的遵守程度都在合理的范围内;设计3、设计4设计虽然有些不足,
但也基本可以接受;设计5则严重不足,对各项原则都没有很好的遵守;而设计6则遵守过渡了,设计5和设计6都是迫切需要重构的
设计。