首页 > 代码库 > 设计模式中的六大设计原则之一,二
设计模式中的六大设计原则之一,二
2.里氏替换原则;3.依赖倒置原则;4.接口隔离原则;5.迪米特法则;开闭原则。下面我来讲讲我对这六大设计自己的理解,如有欠缺地地方,请大家及时指出啊。。。
1.单一职责原则:应该有且仅有一个原因引起类的变更。通俗的说,即一个类只负责一项职责。下面我们举一个具体的例子来说明一下什么是单一职责原则。电话通话的时候有4个过程发生:拨号,通话,回应,挂机,首先看下面这样一个借口,如图1所示:
图1.
我们来看一下这个过程的代码:
public interface IPhone{ public void dial(String phoneNumber); public void chat(Object o); public void answer(Object o); public void hangup(); }
单一职责原则要求一个接口或类只有一个原因引起i班花,也就是一个接口或类只有一个职责,它就负责一件事情,上面的接口显然不符合单一职责原则。IPhone这个接口包含了两个职责:一个协议管理(拨号和挂机),一个数据传送(通话和回应),这两个职责都会引起接口的变化!通过这样的分析,我们发现类图上的IPhone接口包含了两个职责,而且这两个职责的变化不相互影响,那就考虑拆开成两个接口,如下图2所示:
图2.
这个类图看着有点复杂了,完全满足了单一职责原则的要求,每个接口职责分明,结构清晰,但是我相信你在设计的时候肯定不会采用这种方式,一个手机类要把ConnectionManager和DataTransfer组合在一块才能使用。组合是一种强耦合关系,你和我都有共同的生命期,这样的强耦合关系还不如使用接口实现的方式呢,而且还增加了类的复杂性,多了两个类。经过这样的思考后,我们再修改一下类图,如图3所示:
图3.
这样的设计才是完美的,一个类实现了两个接口,把两个职责融合在一个类中。你会觉得这个Phone有两个原因引起变化了呀,是的是的,但是别忘记了我们是面向接口编程,我们对外公布的是接口而不是实现类。而且,如果真要实现类的单一职责,这个就必须使用上面的组合模式了,这会引起类间耦合过重、类的数量增加等问题,人为的增加了设计的复杂性。
通过上面的例子,我们来总结一下单一职责原则有什么好处:
- 类的复杂性降低,实现什么职责都有清晰明确的定义;
- 可读性提高,复杂性降低,那当然可读性提高了;
- 可维护性提高,那当然了,可读性提高,那当然更容易维护了;
- 变更引起的风险降低,变更是必不可少的,如果接口的单一职责做得好,一个接口修改只对相应的实现类有影响,对其他的接口无影响,这对系统的扩展性、维护性都有非常大帮助。
看过电话这个例子后,是不是有点反思了,我以前的设计是不是有点的问题了?不,不是的,不要怀疑自己的技术能力,单一职责原则最难划分的就是职责。一个职责一个接口,但问题是“职责”是一个没有量化的标准,一个类到底要负责那些职责?这些职责该怎么细化?细化后是否都要有一个接口或类?这些都需要从实际的项目去考虑,从功能上来说,定义一个IPhone接口也没有错,实现了电话的功能,而且设计还很简单,仅仅一个接口一个实现类,实际的项目我想大家都会这么设计。项目要考虑可变因素和不可变因素,以及相关的收益成本比率,因此设计一个IPhone接口也可能是没有错的。但是,如果纯从“学究”理论上分析就有问题了,有两个可以变化的原因放到了一个接口中,这就为以后的变化带来了风险。如果以后模拟电话升级到数字电话,我们提供的接口IPhone是不是要修改了?接口修改对其他的Invoker类是不是有很大影响?!
注意 单一职责原则提出了一个编写程序的标准,用“职责”或“变化原因”来衡量接口或类设计得是否有优良,但是“职责”和“变化原因”都是不可度量的,因项目而异,因环境而异。
对于接口,我们在设计的时候一定要做到单一,但是对于实现类就需要多方面考虑了。生搬硬套单一职责原则会引起类的剧增,给维护带来非常多的麻烦,而且过分的细分类的职责也会人为地制造系统的复杂性,本来一个类可以实现的行为硬要拆成两个类,然后使用聚合或组合的方式再耦合在一起,这个是人为制造了系统的复杂性,所以原则是死的,人是活的,这句话是非常好的。
单一职责原则很难在项目中得到体现,非常难,为什么?在国内,技术人员的地位和话语权都比较低,因此在项目中需要考虑环境,考虑工作量,考虑人员的技术水平,考虑硬件的资源情况,等等,最终妥协的结果是经常违背单一原则。而且,我们中华文明就有很多属于混合型的产物,比如筷子,我们可以把筷子当做刀来使用,分割食物;还可以当叉使用,把食物从盘子中移动到口中。而在西方的文化中,刀就是刀,叉就是叉,你去吃西餐的时候这两样肯定都是有的,刀就是切割食物,叉就是固定食物或者移动食物,分工很明晰。这种文化的差异是很难一步改造过来,但是我相信随着技术的深入,单一职责原则必然会深入到项目的设计中去,而且这个原则是那么的简单,简单得不需要我们更加深入地思考,单从字面上大家都应该知道是什么意思,单一职责嘛!
单一职责适用于接口、类,同时也适用于方法,什么意思呢?一个方法尽可能做一件事情,比如一个方法修改用户密码,不要把这个方法放到“修改用户信息”方法中,这个方法的颗粒度很粗,比如图4中所示的方法。
图4.
在IUserManager中定义了一个方法changeUser,根据传递的类型不同,把可变长度参数changeOptions修改到userBo这个对象上,并调用持久层的方法保存到数据库中。在我的项目组中,如果有人写了这样一个方法,我不管他写了多少程序,花了多少工夫,一律重写!原因很简单:方法职责不清晰,不单一,不要让别人猜测这个方法可能是用来处理什么逻辑。比较好的设计如图5所示。
图5.
通过上面的类图,如果要修改用户名称,就调用changeUserName方法;要修改家庭地址,就调用changeHomeAddress方法;要修改单位电话,就调用changeOfficeTel方法。每个方法的职责非常清晰明确,不仅开发简单,而且日后的维护也非常容易,大家可以逐渐养成这样的习惯。
所以,如果对接口、类、方法使用了单一职责原则,那么快乐的就不仅仅是你了,还有你的项目组成员,大家可以轻松而又愉快地进行开发;还有你的老板,减少了因为变更引起的工作量,减少了无为人员和资金消耗。
类的单一职责确实受非常多因素的制约,纯理论地来讲,这个原则是非常优秀的,但是现实有现实的难处,你必须去考虑项目工期、成本、人员技术水平、硬件情况、网络情况甚至有时候还要考虑政府政策、垄断协议等原因。
接口一定要做到单一职责,类的设计尽量做到只有一个原因引起变化。
2.里氏替换原则:所有引用基类的地方必须能够透明地使用其子类对象,通俗点讲,只要父类能出现的地方子类就可以出现,而且替换为子类也不会产生任何错误或异常,使用者可能根本就不需要知道是父类还是子类。但是,反过来就不行了,有子类出现的地方,父类未必能适应。里氏替换原则为良好的继承定义了一个规范,一句简单的定义包含了四层含义:
- 子类必须完全实现父类的方法
我们在做系统设计时,经常会定义一个接口或抽象类,然后编码实现,调用类则直接传入接口或抽象类,其实这里已经使用了里氏替换原则。我们举个例子来说明这个原则,大家都打过CS吧,非常经典的FPS类游戏,我们来描述一下里面用到的枪,类图如6所示。
图6.
根据类图得到以下代码:
public abstract class AbstractGun { //枪用来干什么的?射击杀戮! public abstract void shoot(); } public class Handgun extends AbstractGun { //手枪的特点是携带方便,射程短 @Override public void shoot() { System.out.println("手枪射击..."); } } public class Rifle extends AbstractGun{ //步枪的特点是射程远,威力大 public void shoot(){ System.out.println("步枪射击..."); } } public class MachineGun extends AbstractGun{ public void shoot(){ System.out.println("机枪扫射..."); } } public class Soldier { public void killEnemy(AbstractGun gun){ System.out.println("士兵开始杀人..."); gun.shoot(); } } public class Client { public static void main(String[] args) { //产生三毛这个士兵 Soldier sanMao = new Soldier(); sanMao.killEnemy(new Rifle()); } }
有人,有枪,也有场景,运行结果如下所示。
士兵开始杀人...
步枪射击...
在这个程序中,我们给三毛这个士兵一把步枪,然后就开始杀敌了,如果三毛要使用机枪当然也可以,直接把sanMao.killEnemy(new Rifle())修改为 sanMao.killEnemy(new MachineGun())就可以了,在编写程序时Solider士兵类根本就不用知道是哪个型号的枪(子类)被传入。
注意 在类中调用其他类时务必要使用父类或接口,如果不能使用父类或接口,则说明类的设计已经违背了LSP原则。
我们再来想一想,如果我们有一个玩具手 枪,该如何定义呢?我们先在类图6上增加一个类ToyGun,然后继承于AbstractGun类,修改后的类图如图7所示。
首先我们想,玩具枪是不能用来射击的,杀不死人的,这个不应该写在shoot方法中。新增加的ToyGun的源代码如代码如下:
public class ToyGun extends AbstractGun { //玩具枪是不能射击的,但是编译器又要求实现这个方法,怎么办?虚构一个呗! @Override public void shoot() { //玩具枪不能射击,这个方法就不实现了 } } public class Client { public static void main(String[] args) { //产生三毛这个士兵 Soldier sanMao = new Soldier(); sanMao.killEnemy(new ToyGun()); } }
把玩具枪传递给三毛用来杀敌,代码运行结果如下所示:
士兵开始杀人...
坏了,士兵拿着玩具枪来杀人,射不出子弹呀!如果在CS游戏中有这种事情发生,那你就等着被人爆头吧,然后看着自己凄凉的倒地。在这种情况下,我们发现业务调用类已经出现了问题,正常的业务逻辑已经不能运行,那怎么办?好办,有两种解决办法:
1:在Soldier类中增加instanceof的判断,如果是玩具枪,就不用来杀敌人。这个方法可以解决问题,但是你要知道,在程序中,每增加一个类,所有与这个父类有关系的类都必须修改,你觉得可行吗?如果你的产品出现了这个问题,因为修正了这样一个Bug,就要求所有与这个父类有关系的类都增加一个判断,客户非跳起来跟你干架不可!你还想要你的客户忠诚你吗?显然,这个方案被否定了。
2:ToyGun脱离继承,建立一个独立的父类,为了做到代码可以复用,可以与AbastractGun建立关联委托关系,如图8所示:
例如,可以在AbstractToy中声明声音、形状都委托给AbstractGun处理,仿真枪嘛,形状和声音都要和真实的枪一样了,然后两个基类下的子类自由延展,互不影响。
在Java的基础知识中,每位老师都会讲继承,Java的三大特征嘛,继承、封装、多态。继承就是告诉你拥有父类的方法和属性,然后你就可以重写父类的方法。按照继承原则,我们上面的玩具枪继承AbstractGun是绝对没有问题的,玩具枪也是枪嘛,但是在具体应用场景中就要考虑下面这个问题了:子类是否能够完整地实现父类的业务,否则就会出现像上面的拿枪杀敌人时却发现是把玩具枪的笑话。
注意 如果子类不能完整地实现父类的方法,或者父类的某些方法在子类中已经发生“畸变”,则建议断开父子继承关系,采用依赖、聚集、组合等关系代替继承。
- 子类可以有自己的个性
子类当然可以有自己的行为和外观了,也就是方法和属性,那这里为什么要再提呢?是因为里氏替换原则可以正着用,但是不能反过来用。在子类出现的地方,父类未必就可以胜任。还是以刚才的关于枪械的例子为例,步枪有几个比较“响亮”的型号,比如AK47、G3狙击步枪等,把这两个型号的枪引入后的Rifle子类图如图9所示:
很简单,G3继承了Rifle类,狙击手(Snipper)则直接使用G3狙击步枪,源代码如下:
public class G3 extends Rifle { //狙击枪都是携带一个精准的望远镜 public void zoomOut(){ System.out.println("通过望远镜查看敌人..."); } public void shoot(){ System.out.println("G3射击..."); } } public class Snipper { public void killEnemy(G3 g3){ //首先看看敌人的情况,别杀死敌人,自己也被人干掉 g3.zoomOut(); //开始射击 g3.shoot(); } } public class Client { public static void main(String[] args) { //产生三毛这个狙击手 Snipper sanMao = new Snipper(); sanMao.killEnemy(new G3()); } }
狙击手使用G3杀死敌人,运行结果如下所示:
通过望远镜查看敌人...
G3射击...
在这里系统直接调用了子类,一个狙击手是很依赖枪支的,别说换一个型号的枪了,就是换一个同型号的枪也会影响射击,所以这里就直接把子类传递了进来。这个时候,我们能不能直接使用父类传递进来呢?修改一下Client类, 使用父类作为参数,代码如下:
public class Client { public static void main(String[] args) { //产生三毛这个狙击手 Snipper sanMao = new Snipper(); Rifle rifle = new Rifle(); sanMao.killEnemy((G3)rifle); } }
显示是不行的,会在运行期抛出java.lang.ClassCastException异常,这也是大家经常说的向下转型(downcast)是不安全的,从里氏替换原则来看,就是有子类出现的地方父类未必就可以出现。
- 覆盖或实现父类的方法时输入参数可以被放大
方法中的输入参数称为前置条件,这是什么意思呢?大家做过Web Service开发就应该知道有一个“契约优先”的原则,也就是先定义出WSDL接口,制定好双方的开发协议,然后再各自实现。里氏替换原则也要求制定一个契约,就是父类或接口,这种设计方法也叫做Design by Contract,契约设计,是与里氏替换原则融合在一起的。契约制定了,也就同时制定了前置条件和后置条件,前置条件就是你要让我执行,就必须满足我的条件;后置条件就是我执行完了需要反馈,标准是什么。这个比较难理解,我们来看一个例子,我们先定义个Father类,代码如下:
public class Father { public Collection doSomething(HashMap map){ System.out.println("父类被执行..."); return map.values(); } }
这个类非常简单,就是把HashMap转换为Collection集合类型,然后再定义一个子类,源代码如下:
public class Son extends Father { //放大输入参数类型 public Collection doSomething(Map map){ System.out.println("子类被执行..."); return map.values(); } }
请注意粗体部分,与父类的方法名相同,但又不是覆写(Override)父类的方法。你加个@Override试试看,会报错的,为什么呢?方法名虽然相同,但方法的输入参数不同,就不是覆写,那这是什么呢?是重载(Overload)!不用大惊小怪的,不在一个类就不能是重载了?继承是什么意思,子类拥有父类的所有属性和方法,方法名相同,输入参数类型又不相同,当然是重载了。父类和子类都已经声明了,场景类的调用代码如下:
public class Client { public static void invoker(){ //父类存在的地方,子类就应该能够存在 Father f = new Father(); HashMap map = new HashMap(); f.doSomething(map); } public static void main(String[] args) { invoker(); } }
代码运行后的结果如下所示:
父类被执行...
根据里氏替换原则,父类出现的地方子类就可以出现,我们把上面的粗体部分修改为子类,代码如下:
public class Client { public static void invoker(){ //父类存在的地方,子类就应该能够存在 Son f =new Son(); HashMap map = new HashMap(); f.doSomething(map); } public static void main(String[] args) { invoker(); } }
运行结果还是一样,看明白是怎么回事了吗?父类方法的输入参数是HashMap类型,子类的输入参数是Map类型,也就是说子类的输入参数类型的范围扩大了,子类代替父类传递到调用者中,子类的方法永远都不会被执行。这是正确的,如果你想让子类的方法运行,就必须覆写父类的方法。大家可以这样想,在一个Invoker类中关联了一个父类,调用了一个父类的方法,子类可以覆写这个方法,也可以重载这个方法,前提是要扩大这个前置条件,就是输入参数的类型大于父类的类型覆盖范围。这样说可能比较理难理解,我们再反过来想一下,如果Father类的输入参数类型大于子类的输入参数类型,会出现什么问题呢?会出现父类存在的地方,子类就未必可以存在,因为一旦把子类作为参数传入,调用者就很可能进入子类的方法范畴。我们把上面的例子修改一下,扩大父类的前置条件,代码入下:
public class Father { public Collection doSomething(Map map){ System.out.println("Map 转 Collection被执行"); return map.values(); } }
把父类的前置条件修改为Map类型,我们再修改一下子类方法的输入参数,相对父类缩小输入参数的类型范围,也就是缩小前置条件,代码如下:
public class Son extends Father { //缩小输入参数范围 public Collection doSomething(HashMap map){ System.out.println("HashMap转Collection被执行..."); return map.values(); } } public class Client { public static void invoker(){ //有父类的地方就有子类 Father f= new Father(); HashMap map = new HashMap(); f.doSomething(map); } public static void main(String[] args) { invoker(); } }
代码运行结果如下所示:
父类被执行...
那我们再把里氏替换原则引入进来会有什么问题?有父类的地方子类就可以使用,好,我们把这个Client类修改一下,代码如下:
public class Client { public static void invoker(){ //有父类的地方就有子类 Son f =new Son(); HashMap map = new HashMap(); f.doSomething(map); } public static void main(String[] args) { invoker(); } }
代码运行后的结果如下所示:
子类被执行...
子类在没有覆写父类的方法的前提下,子类方法被执行了,这会引起业务逻辑混乱,因为在实际应用中父类一般都是抽象类,子类是实现类,你传递一个这样的实现类就会“歪曲”了父类的意图,引起一堆意想不到的业务逻辑混乱,所以子类中方法的前置条件必须与超类中被覆盖的方法的前置条件相同或者更宽松。
- 覆盖或实现父类的方法时输出结果可以被缩小
这是什么意思呢,父类的一个方法的返回值是一个类型T,子类的相同方法(重载或覆写)的返回值为S,那么里氏替换原则就要求S必须小于等于T,也就是说要么S和T是同一个类型,要么S是T的子类,为什么呢?分两种情况,如果是覆写,父类和子类的同名方法的输入参数是相同的,两个方法的范围值S小于等于T,这是覆写的要求,这才是重中之重,子类覆写父类的方法,天经地义。如果是重载,则要求方法的输入参数类型或数量不相同,在里氏替换原则要求下,就是子类的输入参数大于或等于父类的输入参数,也就是说你写的这个方法是不会被调用到的,参考上面讲的前置条件。
采用里氏替换原则的目的就是增强程序的健壮性,版本升级时也可以保持非常好的兼容性。即使增加子类,原有的子类还可以继续运行。在实际项目中,每个子类对应不同的业务含义,使用父类作为参数,传递不同的子类完成不同的业务逻辑,非常完美!
在项目中,采用里氏替换原则时,尽量避免子类的“个性”,一旦子类有“个性”,这个子类和父类之间的关系就很难调和了,把子类当做父类使用,子类的“个性”被抹杀——委屈了点;把子类单独作为一个业务来使用,则会让代码间的耦合关系变得扑朔迷离——缺乏类替换的标准。