本文开始对《设计模式之禅》进行学习总结,首先是六大设计原则。
单一职责原则
单一职责原则(Single Responsibility Principle)简称SRP,这个原则存在的争议之处是“职责”的定义、划分。
先举例说明什么事单一职责:以用户管理、修改用户的信息、增加机构(一个人属于多个机构)、增加角色等维护,用户有这么多的行为维护。
-
假如我们把他们写到一个接口中,类图如图最左侧,从图中可以很容易看出,类的定义没有划分用户的属性和用户的行为,按照抽取属性和行为的思路可以进行职责划分。
-
如图中间,职责划分后将接口拆为两个
IUserBo
和IUserBiz
,现在是面向接口编程,所以产生UserInfo
对象后可以既把它当做IUserBo
接口获得用户属性,也可以当做IUserBiz
操作维护用户相关信息。.... IUserInfo userInfo = new UserInfo(); // 赋值 IUserBO userBO = (IUserBo) userInfo; userBO.setPassword("abc"); // 执行动作 IUserBiz userBiz = (IUserBiz) userInfo; userBiz.deleteUser(); ....
-
项目实际习惯更倾向于右侧使用两个不同的类或者接口进行操作。
SRP的原话解释是:There should never be more than one reason for a class to change
这句话很好看懂,但是看懂是一回事,实践又是另一回事了。举例打电话:电话通话有4个过程:拨号、通话、回应。类图如下
public interface IPhone{
// 拨通电话
public void dial(String phoneNumber);
// 通话
public void chat(Object o);
// 通话完毕,挂电话
public void hangup();
}
实现类比较简单,这个接口起始已经接近完美,看清楚是”接近“!一个接口或类只有一个原因引起变化,也就是一个接口只负责一件事,但是上面的接口只负责一件事吗?只有一个原因引起变化吗?好像不是!
这个接口包含了两个职责:一个是协议管理(dial
和hanup
),另一个是数据传输(chat
),所以考虑职责的划分和互不影响,拆分为两个接口如下图左侧:
但是图中左侧的设计,一个手机要把两个类组合才能使用,这种组合是一种强耦合关系,并且增加了两个类。你和我都有共同的生命周期,这种强耦合的组合方式还不如使用接口的实现方式,如图右侧,这才是完美的设计!
你可能会觉得这个IPhone
有两个原因引起变化了呀!但是别忘了我们是面向接口编程,对外提供的是接口而不是实现类,而且要是如果真要实现类的单一职责,就必须使用上面的组合模式,这会引起类间的耦合过重、类的数量增加等问题。
通过上面的例子,总结一下单一职责原则有什么好处:
- 类的复杂性降低,实现什么职责有清晰明确的定义;
- 可读性提高,复杂性降低,当然可读性提高了;
- 可维护性提高,复杂性降低当然更容易维护;
- 变更引起的风险降低,变更是必不可少的,如果接口的单一职责做的好,一个接口修改只对相应的实现类有影响,对其他接口无影响,这对系统的扩展性、维护性都有非常大的帮助。
反思:
看过电话的例子后是不是陷入了深思?不要怀疑自己的能力,单一职责最难划分的就是职责。一个职责一个接口,但是”职责“没有一个量化标准。从功能上说IPhone
一个接口,一个实现类没有错,实现了电话的功能而且设计很简单,实际的项目我想大家都会这么设计。项目要考虑可变因素和不可变因素,以及相关的收益成本比。
一个方法承担多个职责,下图仅仅是个简单的逻辑尽管按左边的编写可能后续维护人员也可以看懂,但是如果是非常复杂的类作为参数,那么维护起来会浪费很多时间。方法职责不清晰,不单一,不要让别人猜测这个方法可能是用来处理什么逻辑的。比如图中右边每个方法的职责非常明确,不仅开发简单而且日后维护也非常容易。
里氏替换原则
里氏替换原则(Liskov Substitution Principle),面向对象的语言中,继承是必不可少的。继承在带来代码共享、提高扩展性的同时,带来的是代码的侵入、降低灵活、增加耦合,没有规范的继承会使得代码难以维护。
里氏替换原则为良好的继承定义了一个规范,包含一下4层含义:
1、子类必须完全实现父类的方法
举一个士兵拿枪射击的例子:
士兵具有枪,可以设置拿什么枪,并且有杀敌动作。枪是一个抽象类,可以有很多个实现如手枪、步枪、机枪。
枪支抽象类
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{
@Override
public void shoot(){
System.out.println("步枪射击...");
}
}
public class MachineGun 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();
}
}
在这个程序中给士兵什么武器将用什么射击,编写程序时候士兵根本不知道是哪个型号的枪(子类)被传入。
【注意】在类中调用其他类时务必使用父类或接口,如果不能则说明类的设计已经违背里氏替换原则
从上例子中再想一想如果我们有一个玩具手枪,该如何定义?是否是直接继承抽象枪类?
答案是否定的,因为玩具枪不能用来杀人,这个不应写在shoot()
方法中。那么如何解决呢?
-
如果已经将玩具枪继承自
AbstractGun
那么就需要再Soldier
中增加instanceof的判断,如果是玩具枪就不能用来杀人。这是一种方案,但是如果你的产品出现问题,因为修正了一个这样的Bug就要求所有与这个父类有关系的类都增加一个判断,这显然是不可行的。 -
ToyGun脱离继承,建立一个独立的父类,为了实现代码复用,可以与
AbstractGun
建立关联委托关系,将声音、形状等委托给其处理,如图
2、子类可以有自己的个性
子类可以有自己的方法和属性,里氏替换原则可以正着用,但不能反过来用,比如子类出现的地方,父类未必可以胜任。再举例狙击手类图如下:
很简单,AUG继承了Rifle类,狙击手(Snipper)则直接使用AUG狙击步枪
public class Aug extends Rifle {
// 瞄准
public void zoomOut() {
System.out.println("通过望远镜查看敌人...");
}
public void shoot() {
System.out.println("AUG射击...");
}
}
这样狙击手设计杀人的实现如下:
public class Client {
public static void main(String[] args) {
Snipper ab = new Snipper();
ab.setRifle(new Aug());
// 错误代码
// ab.setRifle((Aug) new Rifle());
ab.killEnemy();
}
}
这样狙击手就可以先瞄准,再射击了。看一下错误代码,显然是不行的,会在运行时抛出java.lang.ClassCastException
异常,这也是大家经常说的向下转型(downcast)是不安全的,从里氏替换原则来看,就是有子类可以实现的地方父类未必可以胜任。
3、重写或实现父类的方法时输入参数可以被放大
这句话是什么意思呢?举例说明你就会明白
// 父类
public class Father {
public Collection doSomething(HashMap map) {
System.out.println("父类被执行...");
return map.Values();
}
}
// 子类
public class Son extends Father {
public Collection doSomething(Map map) {
System.out.println("子类被执行..."