《设计模式之禅-6大设计原则》

第一章 单一职责原则

 简单理解:

⼀个类和⽅法只做⼀件事

案例

操作在类上

 

 

图1-1 用户信息维护类图
太Easy的类图了,我相信,即使是一个初级的程序员也可以看出这个接口设计得有问题,用户的属性和用户的行为没有分开,这是一个严重的错误!这个接口确实设计得一团糟,应该把用户的信息抽取成一个BO(Business Object,业务对象),把行为抽取成一个
Biz(Business Logic,业务逻辑),按照这个思路对类图进行修正,如图1-2所示。

 

重新拆封成两个接口,IUserBO负责用户的属性,简单地说,IUserBO的职责就是收集和反馈用户的属性信息;IUserBiz负责用户的行为,完成用户信息的维护和变更。各位可能要说了,这个与我实际工作中用到的User类还是有差别的呀!别着急,我们先来看一看分拆成两个接口怎么使用。OK,我们现在是面向接口编程,所以产生了这个UserInfo对象之后,当然可以把它当IUserBO接口使用。也可以当IUserBiz接口使用,这要看你在什么地方使用了。要获得用户信息,就当是IUserBO的实现类;要是希望维护用户的信息,就把它当作IUserBiz的实现类就成了,如代码清单1-1所示。
 
代码清单1-1 分清职责后的代码示例
IUserInfo userInfo = new UserInfo();
//我要赋值了,我就认为它是一个纯粹的BO
IUserBO userBO = (IUserBO)userInfo;
userBO.setPassword("abc");
//我要执行动作了,我就认为是一个业务逻辑类
IUserBiz userBiz = (IUserBiz)userInfo;
userBiz.deleteUser();

 

 
作用在方法上
 
单一职责适用于接口、类,同时也适用于方法,什么意思呢?一个方法尽可能做一件事,比如一个方法修改用户密码,不要把这个方法放到“修改用户信息”方法中,这个方法的颗粒度很粗,比如图1-7中所示的方法。

                                                                                                                           图1-7 一个方法承担多个职责

在IUserManager中定义了一个方法changeUser,根据传递的类型不同,把可变长度参数changeOptions修改到userBO这个对象上,并调用持久层的方法保存到数据库中。在我的项目组中,如果有人写了这样一个方法,我不管他写了多少程序,花了多少工夫,一律重写!原因很简单:方法职责不清晰,不单一,不要让别人猜测这个方法可能是用来处理什么逻辑的。比较好的设计如图1-8所示。
 
通过类图可知,如果要修改用户名称,就调用changeUserName方法;要修改家庭地址,就调用changeHomeAddress方法;要修改单位电话,就调用changeOfficeTel方法。每个方法的职责非常清晰明确,不仅开发简单,而且日后的维护也非常容易,大家可以逐渐养成这样的习惯。
 
图1-8 一个方法承担一个职责

最佳实践

阅读到这里,可能有人会问我,你写的是类的设计原则吗?你通篇都在说接口的单一职责,类的单一职责你都违背了呀!呵呵,这个还真是的,我的本意是想把这个原则讲清楚,类的单一职责嘛,这个很简单,但当我回头写的时候,发觉并不是这么回事,翻看了以前的一些设计和代码,基本上拿得出手的类设计都是与单一职责相违背的。静下心来回忆,发觉每一个类这样设计都是有原因的。我查阅了Wikipedia、OODesign等几个网站,专家和我也有类似的经验,基本上类的单一职责都用了类似的一句话来说"This is sometimes hard to see",这句话翻译过来就是“这个有时候很难说”。是的,类的单一职责确实受非常多因素的制约,纯理论地来讲,这个原则是非常优秀的,但是现实有现实的难处,你必须去考虑项目工期、成本、人员技术水平、硬件情况、网络情况甚至有时候还要考虑政府政策、垄断协议等因素。比如,2004年我就做过一个项目,做加密处理的,甲方就甩过来一句话,你什么都不用管,调用这个API就可以了,不用考虑什么传输协议、异常处理、安全连接等。所以,我们就直接使用了JNI与加密厂商提供的API通信,什么单一职责原则,根本就不用考虑,因为对方不公布通信接口和异常判断。对于单一职责原则,我的建议是接口一定要做到单一职责,类的设计尽量做到只有一个原因引起变化。
 
 

第二章 里式替换原则

简单理解:

多态,⼦类可扩展⽗类。
只要父类能出现的地方子类就可以出现,而且替换为子类也不会产生任何错误或异常,使用者可能根本就不需要知道是父类还是子类。但是,反过来就不行了,有子类出现的地方,父类未必就能适应

案例

1.子类必须完全实现父类的方法

 

 代码例子

2.子类可以有自己的个性

子类当然可以有自己的行为和外观了,也就是方法和属性,那这里为什么要再提呢?是因为里氏替换原则可以正着用,但是不能反过来用。在子类出现的地方,父类未必就可以胜任。还是以刚才的关于枪支的例子为例,步枪有几个比较“响亮”的型号,比如AK47,AUG狙击步枪等,把这两个型号的枪引入后的Rifle子类图如图2-4所示

 

 方法调用

代码清单2-9 狙击手使用AUG杀死敌人
public class Client {
public static void main(String[] args) {
//产生三毛这个狙击手
Snipper sanMao = new Snipper();
sanMao.setRifle(new AUG());
sanMao.killEnemy();
}
}

 

 

 


 

在这里,系统直接调用了子类,狙击手是很依赖枪支的,别说换一个型号的枪了,就是换一个同型号的枪也会影响射击,所以这里就直接把子类传递了进来。这个时候,我们能不能直接使用父类传递进来呢?修改一下Client类,如代码清单2-10所示。

 

代码清单2-10 使用父类作为参数
public class Client {
public static void main(String[] args) {
//产生三毛这个狙击手
Snipper sanMao = new Snipper();
sanMao.setRifle((AUG)(new Rifle()));
sanMao.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("子类被执行...");
return map.values();
}
} 
与父类的方法名相同,但又不是覆写(Override)父类的方法方法名虽然相同,但方法的输入参数不同,就不是覆写,那这是什么呢?是重载(Overload)!
代码清单2-13 场景类源代码
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();
}
}

 

 

根据里氏替换原则,父类出现的地方子类就可以出现,我们把上面的粗体部分修改为子类
如代码清单2-14所示。代码清单2-14 子类替换父类后的源代码
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();
}
}
运行结果还是一样,此时子类调用的是父类的方法。
 
父类的前置条件较大
public class Father {
public Collection doSomething(Map map){
System.out.println("父类被执行...");
return map.values();
}
}

子类前置较小

public class Son extends Father {
//缩小输入参数范围
public Collection doSomething(HashMap map){
System.out.println("子类被执行...");
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();
}
}

 

 

代码清单2-18 采用里氏替换原则后的业务场景类
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();
}
}

 

 

完蛋了吧?!子类在没有覆写父类的方法的前提下,子类方法被执行了,这会引起业务逻辑混乱,因为在实际应用中父类一般都是抽象类,子类是实现类,你传递一个这样的实现类就会“歪曲”了父类的意图,引起一堆意想不到的业务逻辑混乱,所以子类中方法的前置条件必须与超类中被覆写的方法的前置条件相同或者更宽松。
 
4. 覆写或实现父类的方法时输出结果可以被缩小
 
这是什么意思呢,父类的一个方法的返回值是一个类型T,子类的相同方法(重载或覆写)的返回值为S,那么里氏替换原则就要求S必须小于等于T,也就是说,要么S和T是同一个类型,要么S是T的子类,为什么呢?分两种情况,如果是覆写,父类和子类的同名方法的输入参数是相同的,两个方法的范围值S小于等于T,这是覆写的要求,这才是重中之重,子类覆写父类的方法,天经地义。如果是重载,则要求方法的输入参数类型或数量不相同,在里氏替换原则要求下,就是子类的输入参数宽于或等于父类的输入参数,也就是说你写的这个方法是不会被调用的,参考上面讲的前置条件。
 
采用里氏替换原则的目的就是增强程序的健壮性,版本升级时也可以保持非常好的兼容性。即使增加子类,原有的子类还可以继续运行。在实际项目中,每个子类对应不同的业务含义,使用父类作为参数,传递不同的子类完成不同的业务逻辑,非常完美!

 

最佳实践

 
在项目中,采用里氏替换原则时,尽量避免子类的“个性”,一旦子类有“个性”,这个子类和父类之间的关系就很难调和了,把子类当做父类使用,子类的“个性”被抹杀——委屈了点;把子类单独作为一个业务来使用,则会让代码间的耦合关系变得扑朔迷离——缺乏类替换的标准。
 

第三章 依赖倒置原则

 

简单理解:

细节依赖抽象,下层依赖上层。就是在具体实现类中依赖抽象类或接口,在子类中需要实现父类。

案例

 

 

发现司机只能驾驶奔驰,那么宝马呢

 

public class Driver implements IDriver{
//司机的主要职责就是驾驶汽车,这里就是细节依赖抽象
public void drive(ICar car){
car.run();
}
}
public interface ICar {
//是汽车就应该能跑
public void run();
}
public class Benz implements ICar{
//汽车肯定会跑,这里就是下层依赖上层
public void run(){
System.out.println("奔驰汽车开始运行...");
}
}
public class BMW implements ICar{
//宝马车当然也可以开动了,这里就是下层依赖上层
 public void run(){ System.out.println("宝马汽车开始运行..."); } }

依赖倒置写法

1.构造函数传递依赖对象

 

public interface IDriver {
//是司机就应该会驾驶汽车
public void drive();
}
public class Driver implements IDriver{
private ICar car;
//构造函数注入
public Driver(ICar _car){
this.car = _car;
}
//司机的主要职责就是驾驶汽车
public void drive(){
this.car.run();
}
}
2.Setter方法传递依赖对象
public interface IDriver {
//车辆型号
public void setCar(ICar car);
//是司机就应该会驾驶汽车
public void drive();
}
public class Driver implements IDriver{
private ICar car;
public void setCar(ICar car){
this.car = car;
}
//司机的主要职责就是驾驶汽车
public void drive(){
this.car.run();
}
}
3.接口声明依赖对象 
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
 
/// <summary>
/// 司机接口
/// </summary>
public interface IDriver  {
    /// <summary>
    /// Setter方法注入
    /// </summary>
    /// <param name="car"></param>
    void SetCar(ICar car);
    /// <summary>
    /// 是司机就应该会驾驶汽车
    /// </summary>
    /// <param name="car"></param>
    void driver();
 
    ///接口声明依赖对象
    //void driver(ICar car);
}

 

 

最佳实践

● 每个类尽量都有接口或抽象类,或者抽象类和接口两者都具备
● 变量的表面类型尽量是接口或者是抽象类
● 任何类都不应该从具体类派生
● 尽量不要覆写基类的方法
● 结合里氏替换原则使用 
 

第四章 接口隔离原则

建⽴单⼀接⼝

简单理解:

多态,⼦类可扩展⽗类

案例

 

最佳实践

 

第五章 迪米特原则

最少知道,降低耦合

简单理解:

多态,⼦类可扩展⽗类

案例

 

最佳实践

 

第六章 开闭原则

抽象架构,扩展实现
 

简单理解:

多态,⼦类可扩展⽗类

案例

 

最佳实践

posted @ 2023-03-20 18:09  小陈子博客  阅读(13)  评论(0编辑  收藏  举报