说明
6大设计原则来自于观看 “设计模式之禅” 一书后的总结
单一职责原则
单一职责原则的英文名称是Single Responsibility Principle,简称是SRP。这个原则存在争议之处在哪里呢?就是对职责的定义,什么是类的职责,以及怎么划分类的职责。下面举个例子。
示例 一
正例:权限认证(RBAC模式)(Role - Based Access Control,基于角色的访问控制,通过分
配和取消角色来完成用户权限的授予和取消,使动作主体(用户)与资源的行为(权限)分离)。
再举一个反例,用户管理、修改用户的信息、增加机构(一个人属于多个机构)、增加角色等,用户有这么多的信息和行为要维护,当我们把这些写到一个接口中,都是用户管理类嘛,如下示类图:
这个接口设计的问题在于用户的属性和用户的行为没有分开,对于后期的维护特别不友好,应该把用户的信息
抽取成一个Bo(Bussiness Object,业务对象),把行为抽取成一个Biz(Business Logic,业务逻辑)
如下图所示:
代码清单 1 - 1 分清职责后的代码示例
.....
IUserBiz userInfo = new UserInfo();
//我要赋值了,我就认为它是一个纯粹的BO
IUserBO userBO = (IUserBO)userInfo;
userBO.setPassword("abc");
//我要执行动作了,我就认为是一个业务逻辑类
IUserBiz userBiz = (IUserBiz)userInfo;
userBiz.deleteUser();
.....
动作分析:为什么要把一个接口拆分成两个呢?其实,在实际的使用中,我们更倾向于使用两个不同的类或接口:一个是IUserBO,一个是IUserBiz,类图如下图所示。
归纳:单一职责原则的定义是:应该有且仅有一个原因引起类的变更
示例 二
电话这玩意,是现代人都离不开,电话通话的时候有4个过程发生:拨号、通话、回应、挂机,那我们写一个接口,其类图下所示:
代码清单:电话过程
public interface Iphone {
//拨通电话
public void dial(String phoneNumber);
//通话
public void chat(Object o);
//通话完毕,挂电话
public void hangup();
}
这个接口乍看上去没啥毛病,平常开发也是这样写,单一职责原则要求一个接口或类只有一个原因引起变化,也就是一个接口或类只有一个职责,它就负责一件事情,但是看上面的接口只负责一件事情吗?是只有一个原因引起变化吗?好像不是!
IPhone这个接口可不是只有一个职责,它包含了两个职责:一个是协议管理,一个是数据传送。dial()和hangup()两个方法实现的是协议管理,分别负责拨号接通和挂机;chat()实现的是数据的传送,把我们说的话转换成模拟信息或数字信号传递到对方,然后再把对方传递过来的信号还原成我们听得懂的语言。我们可以这样考虑这个问题,协议接通的变化会引起这个接口或实现类的变化吗?会的!那数据传送(想想看,电话不仅仅可以通话,还可以上网)的变化会引起这个接口或实现类的变化吗?会的!那就很简单了,这里有两个原因都引起了类的变化。这两个职责会互相影响吗?电话拨号,我只要能接通就成,不管是电信的还是网通的协议;电话连接后还关心传递的是什么数据吗?通过这样的分析,我们发现类图上的IPhone接口还包含了两个职责,而且这两个职责的变化不互相影响,那就考虑拆分成两个接口,如下图所示:
这个类图看上去有点复杂了,完美满足了单一职责原则的要求,每个接口职责分明,结构清晰,但是一般在设计的时候不会采用这种方式,一个手机类要把ConnectionManager和DataTransfer组合在一块才能使用。组合是一种强耦合关系,你和我都有共同的生命期,这样的强耦合关系还不如使用接口实现的方式呢,而且还增加了类的复杂性,多了两个类,经过这样的思考后,我们再修改一下类图,如下图所示:
这样的设计才是完美的,一个类实现了两个接口,把两个职责融合在一个类中,你会觉得这个Phone有两个原因引起变化了呀,是的,但是别忘记了我们是面向接口编程,我们对外公布的是接口而不是实现类。而且,如果真要实现类的单一职责,这个就必须使用上面的组合模式了,这会引起类间耦合过重、类的数量增加等问题,人为地增加了设计的复杂性。
总结
通过上面的例子,总结一下单一职责原则有什么好处:
1、类的复杂性降低,实现什么职责都有清晰明确的定义;
2、可读性提高,复杂性降低,那当然可读性提高了;
3、可维护性提高,可读性提高,那当然更容易维护了;
4、变更引起的风险降低,变更是必不可少的,如果接口的单一职责做得好,一个接口修改只对相应的实现类
有影响,对其他的接口无影响,这对系统的扩展性、维护性都有非常大的帮助。
注意:单一职责原则提出了一个编写程序的标准,用 “职责” 或 “变化原因” 来衡量接口或类设计得是否优良,但是 “职责” 和 “变化原因” 都是不可度量的,因项目而异,因环境而异。
里氏替换原则
在面向对象的语言中,继承是必不可少的、非常优秀的语言机制,它有如下优点:
1、代码共享,减少创建类的工作量,每个子类都拥有父类的方法和属性;
2、提高代码的重用性;
3、子类可以形似父类,但又异于父类,“龙生龙,凤生凤,老鼠生来会打洞” 是说子拥有父的 “种”,“世界上
没有两片完全相同的叶子” 是指明子与父的不同;
4、提高代码的可扩展性,实现父类的方法就可以 “为所欲为” 了,君不见很多开源框架的扩展接口都是通过
继承父类来完成的;
5、提高产品或项目的开放性
自然界的所有事物都是优点和缺点并存的,即使是鸡蛋,有时候也能挑出骨头来,继承的缺点如下:
1、继承是侵入性的,只要继承,就必须拥有父类的所有属性和方法;
2、降低代码的灵活性。子类必须拥有父类的属性和方法,让子类自由的世界中多了些约束;
3、增加了耦合性。当父类的常量、变量和方法被修改时,必需要考虑子类的修改,而且在缺乏规范的环境下,
这种修改可能带来非常糟糕的结果一大片的代码需要重构。
Java使用extends关键字来实现继承,它采用了单一继承的规则,C++则采用了多重继承的规则,一个子类可以继承多个父类。从整体上来看,利大于弊,怎么才能让 “利” 的因素发挥最大的作用,同时减少 “弊” 带来的麻烦呢? 解决方案是引入里氏替换原则 (Liskov Substitution Principle,LSP),什么是里氏替换原则呢?它有两种定义:
第一种定义,也是最正宗的定义:如果对每一个类型为S的对象o1,都有类型为T的对象o2,使得以T定义的所有
程序P在所有的对象o1都代换成o2时,程序P的行为没有发生变化,那么类型S是类型T的子类型。
第二种定义:所有引用基类的地方必须能透明地使用其子类的对象。
第二个定义是最清晰明确的,通俗点讲,只要父类能出现的地方子类就可以出现,而且替换为子类也不会产生任何错误或异常,使用者可能根本就不需要知道是父类还是子类。但是,反过来就不行了,有子类出现的地方,父类未必就能适应。
示例 一
里氏替换原则为良好的继承定义了一个规范,一句简单的定义包含了4层含义。
1.子类必须完全实现父类的方法,举个例子,大家都打过CS吧,非常经典的FPS类游戏,我们来描述一下里面用到的枪,如下图:
枪的主要职责是射击,如何射击在各个具体的子类中定义,手枪是单发射程比较近,步枪威力大射程远,机枪用于扫射。在士兵类中定义了一个方法KillEnemy,使用枪来杀敌人,具体使用什么枪来杀敌人,调用的时候才知道,AbstractGun类的源程序
枪支的抽象类:
public abstract class AbstractGun {
//枪用来干什么?杀敌!
public abstract void shoot();
}
手枪、步枪、机枪的实现类如下面代码
public class Handgun extends AbstractGun{
//手枪的特点是携带方便,射程短
@Override
public void shoot() {
System.out.println("手枪射击...");
}
}
public class MachineGun extends AbstractGun{
@Override
public void shoot() {
System.out.println("机枪射击...");
}
}
public class Rifle extends AbstractGun{
//步枪的特点是射程远,威力大
@Override
public void shoot() {
System.out.println("步枪射击...");
}
}
有了枪支,还要有能够使用这些枪支的士兵,代码清单如下:
士兵的实现类:
public class Soldier {
//定义士兵的枪支
private AbstractGun gun;
//给士兵一支枪
public void setGun(AbstractGun _gun) {
this.gun = _gun;
}
public void killEnemy() {
System.out.println("士兵开始杀敌人...");
gun.shoot();
}
}
定义士兵使用枪来杀敌,但是这把枪是抽象的,具体是手枪还是步枪需要在战场前(也就是场景中)前通过setGun方法确定。场景类Client的代码如下所示:
public class Client {
public static void main(String[] args) {
//产生三毛这个士兵
Soldier sanMao = new Soldier();
//给三毛一支枪
sanMao.setGun(new Rifle());
sanMao.killEnemy();
}
}
有人、有枪,也有场景,运行结果如下所示。
士兵开始杀敌人...
步枪射击...
在这个程序中,我们给三毛这个士兵一把步枪,然后就开始杀敌了。如果三毛要使用机枪,当然也可以,直接把sanMao.killEnemy(new Rifle())修改为sanMao.killEnemy(new MachineGun())即可,在编写程序时Solider士兵类根本就不用知道是哪个型号的枪(子类)被传入。
注意:在类中调用其他类时务必要使用父类或接口,如果不能使用父类或接口,则说明类的设计已经违背了LSP原则
示例 二
我们再来想一想,如果我们有一个玩具有枪,该如何定义呢?我们先在类图上增加一个类ToyGun,然后继承于AbstractGun类,修改后的类图如下所示。
首先我们想,玩具枪是不能用来射击的,杀不死人的,这个不应该写在shoot方法中。新增加的ToyGun的源代码如下所示。
玩具枪源代码:
public class ToyGun extends AbstractGun{
//玩具枪是不能射击的,但是编译器又要求实现这个方法,怎么办?虚构一个呗!
@Override
public void shoot() {
//玩具枪不能射击,这个方法就不实现了
}
}
由于引入了新的子类,场景类中也使用了该类,Client稍作修改,源代码如下所示:
public class Client1 {
public static void main(String[] args) {
//产生三毛这个士兵
Soldier sanMao = new Soldier();
sanMao.setGun(new ToyGun());
sanMao.killEnemy();
}
}
代码运行结果如下所示:
士兵开始杀敌人...
坏了,士兵拿着玩具枪来杀敌人,射不出子弹呀!如果在CS游戏中有这种事情发生,那你就等着被人爆头吧,然后GG了。在这种情况下,我们发现业务调用类已经出现了问题,正常的业务逻辑已经不能运行,那怎么办?好办,有两种解决方法:
1、在Soldier类中增加instanceof的判断,如果是玩具枪,就不用来杀敌人,这个方法可以解决问题,但是在程序中,每增加一个类,所有与这个父类有关系的类都必须修改,你觉得可行吗?如果你的产品出现了这个问题,因为修正了这样一个BUG,就要求所有与这个父类有关系的类都增加一个判断,客户非跳起来跟你干架不可!你还想要客户忠诚你吗?显然,这个方案被pass掉了
2、ToyGun脱离继承,建立一个独立的父类,为了实现代码复用,可以与AbastractGun建立关联委托关系,如下图所示:
例如,可以在AbstractToy中声明将声音、形状都委托给AbstractGun处理,仿真枪嘛,形状和声音都要和真实的枪一样了,然后两个基类下的子类自由延展,互不影响。
在Java的基础知识中都会讲到继承,Java的三大特征嘛,继承、封装、多态。继承就是告诉你拥有父类的方法和属性,然后你就可以重写父类的方法。按照继承原则,我们上面的玩具枪继承AbstractGun是绝对没有问题的,玩具枪也是枪嘛,但是在具体应用场景中就要考虑下面这个问题了:子类是否能够可以完整地实现父类的业务,否则就会出现像上面的拿枪杀敌人时却发现是把玩具枪的笑话。
注意:如果子类不能完整地实现父类的方法,或者父类的某些方法在子类中已经发生 “畸变” ,则建议断开父子继承关系,采用依赖、聚集、组合等关系替代继承。
2.子类可以有自己的个性
子类当然可以有自己的行为和外观了,也就是方法和属性,里氏替换原则可以正着用,但是不能反过来用。在子类出现的地方,父类未必就可以胜任。还是以刚才的关于枪支的例子为例,步枪有几个比较 “响亮” 的型号,比如AK47 、 AUG 狙击步枪等,把这两个型号的枪引入后的Rifle子类如下图所示:
AUG继承了Rifle类,狙击手(Snipper)则直接使用AUG狙击步枪,代码如下:
public class AUG extends Rifle{
//狙击枪都携带一个精准的望远镜
public void zoomOut() {
System.out.println("通过望远镜察看敌人...");
}
public void shoot() {
System.out.println("AUG射击...");
}
}
有狙击枪就有狙击手,狙击手类的代码如下:
public class Snipper {
private Rifle rifle;
//给士兵一支枪
public void setRifle(Rifle _rifle) {
this.rifle = _rifle;
}
public void killEnemy(AUG aug) {
//首先看看敌人的情况,别杀死敌人,自己也被干掉
aug.zoomOut();
aug.shoot();
}
}
狙击手,为什么叫Snipper?Snipe翻译过来就是鹬,就是 “鹬蚌相争,渔人得利” 中的那个鸟,英国贵族到印度打猎,发现这个鹬很聪明,人一靠近就飞走了,没办法就开始伪装、远程精准射击,于是乎Snipper就诞生了。
狙击手使用狙击枪来杀死敌人,业务场景Client类的代码如下:
public class Client2 {
public static void main(String[] args) {
Snipper snipper = new Snipper();
snipper.setRifle(new AUG());
snipper.killEnemy(new AUG());
}
}
狙击手使用G3杀手敌人,运行结果如下所示:
通过望远镜察看敌人...
AUG射击...
在这里,系统直接调用了子类,狙击手是很依赖枪支的,别说换一个型号的枪了,就是换一个同型号的枪也会影响射击,所以这里就直接把子类传递了进来。这个时候,我们能不能直接使用父类传递进来呢?修改一下Client类,如下代码:
public class Client3 {
public static void main(String[] args) {
Snipper snipper = new Snipper();
snipper.setRifle(new Rifle());
snipper.killEnemy((AUG) new Rifle());
}
}
运行如下结果:
java.lang.ClassCastException
这也是大家经常说的向下转型(downcast) 是不安全的,从里氏替换原则来看,就是有子类出现的地方父类未必就可以出现。
示例 三
覆盖或实现父类的方法时输入参数可以被放大
方法中的输入参数被称为前置条件,这是什么意思呢?大家做过Web Service开发就应该知道有一个 “契约优先” 的原则,也就是先定义出WSDL接口,指定好双方的开发协议,然后各自实现。里氏替换原则也要求制定一个契约,就是父类或接口,这种设计方法也叫做 Design by Contract(契约设计) ,与里氏替换原则有着异曲同工之妙。契约制定了,也就同时制定了前置条件和后置条件,前置条件就是你要让我执行,就必须满足我的条件;后置条件就是我执行完了需要反馈,标准是什么。如下例:
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 Client4 {
public static void invoker() {
//父类存在的地方,子类就应该能够存在
Father f = new Father();
HashMap map = new HashMap();
f.doSomething(map);
}
public static void main(String[] args) {
invoker();
}
}
代码运行后的结果如下:
父类被执行...
根据里氏替换原则,父类出现的地方子类就可以出现,我们把上面的Father替换成子类,代码如下:
public class Client5 {
public static void invoker() {
//父类存在的地方,子类就应该能够存在
Son f = new Son();
HashMap map = new HashMap();
f.doSomething(map);
}
public static void main(String[] args) {
invoker();
}
}
运行结果还是一样,父类方法的输入参数是HashMap类型,子类的输入参数是Map类型,也就是说子类的输入参数类型的范围扩大了,子类替代父类传递到调用者中,子类的方法永远都不会被执行。这是正确的,如果想让子类的方法运行,就必须覆写父类的方法。但是如果Father类的输入参数类型宽于子类的输入参数类型,会出现什么问题呢?会出现父类存在的地方,子类就未必可以存在,因为一旦把子类作为参数传入,调用者就很可能进入子类的方法范畴。我们把上面的例子修改一下,扩大父类的前置条件,代码如下:
public class Father1 {
public Collection doSomething(Map map) {
System.out.println("Map 转Collection被执行");
return map.values();
}
}
把父类的前置条件修改为Map类型,我们再修改一下子类方法的输入参数,相对父类缩小输入参数的类型范围,也就是缩小前置条件,代码如下:
public class Son1 extends Father1{
//放大输入参数类型
public Collection doSomething(HashMap map) {
System.out.println("HashMap转Collection被执行...");
return map.values();
}
}
在父类的前置条件大于子类的前置条件的情况下,业务场景的代码如下:
public class Client6 {
public static void invoker() {
//父类存在的地方,子类就应该能够存在
Father1 f = new Father1();
HashMap map = new HashMap();
f.doSomething(map);
}
public static void main(String[] args) {
invoker();
}
}
运行结果如下:
Map 转Collection被执行
那我们再把里氏替换原则引入进来会有什么问题?有父类的地方子类就可以使用,我们把这个Client类修改一下,源代码如下:
public class Client7 {
public static void invoker() {
//父类存在的地方,子类就应该能够存在
Son1 f = new Son1();
HashMap map = new HashMap();
f.doSomething(map);
}
public static void main(String[] args) {
invoker();
}
}
运行结果如下:
HashMap转Collection被执行...
完犊子了!子类在没有覆写父类的方法的前提下,子类方法被执行了,这会引起业务逻辑混乱,因为在实际应用中父类一般都是抽象类,子类是实现类,如果传递这样的实现类就会 “歪曲” 了父类的意图,引起一堆意向不到的业务逻辑混乱,所以子类中方法的前置条件必须与超类中被覆写的方法的前置条件相同或者更宽松
覆写或实现父类的方法时输出结果可以被缩小
这是什么意思呢,父类的一个方法的返回值是一个类型T,子类的相同方法(重载或覆写)的返回值为S,那么里氏替换原则就要求S必须小于等于T,也就是说,要么S和T是同一个类型,要么S是T的子类,为什么呢?分为两种情况
1、如果是覆写,父类和子类的同名方法的输入参数是相同的,两个方法的范围值S小于等于T,这是覆写的要求,这才是重中之重,子类覆写父类的方法,天经地义。
2、如果是重载,则要求方法的输入参数类型或数量不相同,在里氏替换原则要求下,就是子类的输入参数宽于或等于父类的输入参数,也就是说你写的这个方法是不会被调用的。
总结
采用里氏替换原则的目的就是增强程序的健壮性,版本升级时也可以保持非常好的兼容性。即使增加子类,原有的子类还可以继续运行。在实际项目中,每个子类对应不同的业务含义,使用父类作为参数,传递不同的子类完成不同的业务逻辑,非常完成!
在项目中,采用里氏替换原则时,尽量避免子类的 “个性” ,一旦子类有 “个性” ,这个子类和父类之间的关系就很难掉和了,把子类当父类使用,子类的 “个性” 被抹杀 — 委屈了点;把子类单独作为一个业务来使用,则会让代码间的耦合关系变得扑朔迷离 — 缺乏类替换的标准。