如何理解六大设计原则

12/7/2022 book

设计模式不是必须的,但是如果你的代码是"非一次性的",那么它就是必须的。

对于大多数开发者来说,你的代码都是需要维护的,而不是写一遍就放起来不管了,既然需要维护,就要不断的读读改改,那就不是一次性的,那么怎么让他"读读改改"起来方便些呢?嗯,设计模式!

设计模式切记不要死记硬背,不要生搬硬套,否则不如不学。而且不要一上来就说:要用xxx模式,需求还没出,你猴急个锤子,难不成是你刚学会这个模式,想拿来练练手,就想把它硬塞到需求里去吗。

那么,如果需求已经出了呢,也别急,先想想,想好了怎么写,选择哪种设计模式,如果没有合适的,套不进去,那就别套了,只要方便维护,就是好的设计,不一定非要去套现有的设计模式。

我们先来了解下设计模式的祖宗:六大设计原则。

# 单一职责原则

单一职责原则SRP(Single Responsibility Principle): 一个类只干一件事。

单一职责的核心就是:只做一件事,关键点就是这个"事"的范围不好定义。

比如:写一个音乐播放器,一个类负责播放,一个类负责停止,这也满足单一职责。但是,这个职责太小了。那么,如果把播放、停止、甚至下载歌曲,都塞进一个类里面呢,那就太大了。所以,职责的范围很重要,只要确定好了这个范围,那这个原则就已经实现了90%。

那么,这个范围怎么确定呢,我们可以这么理解:一些相关的,关联性比较强的,就把他们当作同一种职责,放到一个单独的类(文件)里

那么,怎么确定是否相关呢,看需求!这个只能看需求,没有别的方法。如果需求没有明确,那么我们就要联系现实来决定,毕竟程序的本质就是模拟现实。

比如,我在15年实习的时候,IBM公司有个考勤系统,需要添加一个指纹打卡功能。

需求是这样的:部门主管以下的员工可以用指纹来打卡。

那么,这个"打卡功能"是属于员工的还是属于打卡器的?换句话说,这个打卡的函数,是写在员工类里面呢,还是写在打卡器类里面呢?需求没说啊。

那么,我们就联系现实来决定。

在现实生活中,应该是一个打卡器放在门口,员工向打卡器录入指纹,来进行打卡,说白了就是:"员工使用打卡器来打卡",也就是:"员工使用打卡器"、"打卡器打卡",所以,打卡功能是打卡器的,员工只是使用它的这个功能的,所以,这个函数应该定义在打卡器里面,员工调用这个函数来进行打卡。

如果有人不爽,非要定义在员工类里面呢?你可以这么干。不过,后来需求改变成:非员工,比如保洁人员,也需要每天打卡签到。这时候,那位非常有个性有特色的人,估计脑瓜子嗡嗡的了吧。

单一职责不仅可以用在类/文件里面,也可以用在函数里面。

比如,现在需要写一个校验函数,校验用户的性别和年龄,必须是18岁以上的男性才有资格,很简单的我们可以这么写:

public static boolean checkSexAndAge(boolean isMan, int age){
  	return isMan && age >= 18;
}
1
2
3

使用:

private void login(){
  	if(checkSexAndAge(false, 17)) {
      	tips("不是18岁以上的男性")
      	return;
    }
}
1
2
3
4
5
6

这里有人有意见了,说这样写不太好,因为每个校验的地方都要自己弹出提示,这样就是很多重复的代码,所以提示这个逻辑应该放在checkSexAndAge()这个函数里面去,也就是下面这样:

public static boolean checkSexAndAge(boolean isMan, int age){
  	if(isMan && age >= 18) return true;
  	tips("不是18岁以上的男性");
  	return false;
}
1
2
3
4
5

这样简直美滋滋,任何时候只要直接调checkSexAndAge就行,判断了还自带提示。这在目前当然是完美的,虽然方法名不太合适。

突然有一天,产品说有些地方不要提示了,如果不满18岁,就开启未成年人保护模式,那就没法统一修改了,只能重写一个不带提示的方法,最简单的就是: 把原来里面的逻辑判断剥离出来,抽成一个方法,专门用来判断,原来带提示的方法调用新方法去判断,然后自己内部弹出提示。

// 判断加提示
public static boolean checkAndTips(boolean isMan, int age) {
    if(!checkSexAndAge(isMan, age)) {
        tips("不是18岁以上的男性");
        return false;
    }
    return true;
}

// 新方法,只做逻辑判断
public static boolean checkSexAndAge(boolean isMan, int age){
  	return isMan && age >= 18;
}
1
2
3
4
5
6
7
8
9
10
11
12
13

这虽然只是一个方法的剥离,但是体现了单一职责的好处,在需求细化的时候,单一职责的作用体现的更明显,简言之就是需求的粒度与单一职责的优势成正比,我们要尽量避免大类,避免"多功能类",因为一个类的功能越多,那么使用到它的地方也越多,就会导致任何一个部分都离不开它,都要依赖它,A模块需要,B模块也需要,在将A模块拆出来的时候,不得不把这个多功能类也拷贝过来,而这个多功能类里面又有B模块的逻辑,导致A模块不干净,被污染,所以我们要避免多功能类,多功能类是耦合的罪魁祸首,一定要尽量避免。

单一职责不仅适用于类和文件,还适用于函数、模块等,这是一种思想,一定要掌握。

# 里氏置换原则

里氏置换原则LSP(Liskov Substitution Principle): 所有使用基类的地方都必须能透明的使用子类。

什么意思呢,就是说凡是用到父类的地方,替换成子类不会改变原有逻辑,我们知道面向对象编程的三个基本原则: 封装,继承和多态。里氏置换原则就是继承的体现,有人说这不是废话吗,子类继承了父类,用到父类的地方替换成子类肯定没问题啊,不一定!因为子类有自己的特色,这个特色太特色的话,就不适合了。

比如:我用电脑工作和游戏;改成:我用苹果电脑工作,用联想电脑打游戏;没问题!

但是如果:我开车上班,坐车下班;改成:我开玩具车上班,坐遥控车下班;这个可能吗?

但是,玩具车和遥控车也是"车"的子类啊,它俩也是车啊。

那么这个问题出在哪里呢?明明所有的定义都是ok的。这是因为子类太特色了

我们定义的,其出发点是"能跑",也就是说,只要能跑的都是"车",都是它的子类,所以,玩具车和遥控车都能跑,也都是车的子类,但是,车都能载人吗?猛一看,都能!仔细一想,玩具车不能!所以,我们上述demo中用到的是车的"载人"功能,而不是车的"能跑"功能,所以,玩具车就不合适了。

那么,怎么改呢,两种方法:

  • 1 提取一个可载人的接口:interface IManned,明确表示哪些车可以载人。
  • 2 提取一个二级父类:class MannedCar,表示该类车可以载人。

公共点就是:把可载人 这个点 明确出来

所以,里氏置换更简洁的说法就是:子类可以有自己的特色,但是不能太反常,如果子类的特色跟父类差太多,那么就应该细化父类或者剥离接口。

里氏置换原则是对继承的校验,不适合的继承关系就不满足里氏置换,所以如果我们在纠结是否应该用继承关系的时候,可以用里氏置换来校验下。

# 依赖倒置原则

依赖倒置原则DIP(Dipendence Inversion Principle): 面向接口编程 或 面向抽象编程。

依赖倒置的官方定义: 高层不应该依赖底层,两者都应该依赖抽象;抽象不应该依赖细节;细节应该依赖抽象。说白了就是:面向接口编程,再广义一点就是:面向抽象编程。也就是说,我们使用一个类的时候,尽量依赖接口,定义函数的时候,参数和返回值尽量定义成接口,所有对外部类的依赖尽量都是接口。

为什么要这么干呢,这么干有什么好处呢?

我们知道,接口都是抽象的,抽象的就是不确定的,不确定的就是可变的。而我们的大部分代码都是"非一次性的",也都是需要改变的,所以,接口正合适。

换句话说,接口就是具有某种功能的某种东西,是什么我不管,只要具有这种功能就行,而我们需要的,也就是具有这种功能的东西。

比如:我需要给手机充个电,我需要的是一个"能充电的东西",而你却对外说:"我需要个充电宝",如果有人没有充电宝,只有电源呢,他就不认你了,这这里,你把我需要的东西具像化了,也就是把范围缩小了,范围越小越精确,就越不容易改变,这明显是不对的。

再比如,现在我要提供一个音乐播放器,我直接使用移动端的MediaPlayer,很容易就写出了如下代码:

class MediaPlayer {
  	public void play(String path) {}
  	public void stop(){}
  	public void pause(){}
  	public void resume(){}
}
1
2
3
4
5
6

三分钟就写完了,使用方直接调用:

class User {
  	private MediaPlayer mediaPlayer;
  
  	public void play(){
      	mediaplayer.play("xxx");
    }
}
1
2
3
4
5
6
7

完事之后某一天,主管:咱们的播放器不好用,能用那个开源的吗?

当然可以,于是就去改,但是发现,要改的地方太多了,我不但要改MedidPlayer这个类,甚至调用我播放器的人也需要改他的User类,我在别人眼里的段位又低了!

这时候就应该反思了,其实User 这个类,不在乎你的播放器是怎么写的,它只关心能不能播放、停止、暂停、恢复,说白了,它要的是一个具有这种功能的某种东西,而不是具有这种功能的这种东西。

好,上接口!

interface IPlayer {
  	void play(String path);
  	void stop();
  	void pause();
  	void resume();
}
1
2
3
4
5
6

User使用:

class User {
  	private IPlayer player;
  
  	public void play(){
      	player.play("xxx");
    }
}
1
2
3
4
5
6
7

此时User只依赖于IPlayer,而不依赖具体的实现,不管你是啥,只要具有播放器的功能就行,后面不管你怎么改变IPlayer的实现,User都不需要改变。

到这里,我们可以总结面向接口的好处:能够减少耦合,代码容易维护,容易扩展,因为接口是抽象的,所以不用纠结于具体的逻辑,想怎么实现都可以;又因为接口是顶层的,就更容易扩展下层的具体实现,就更灵活。

# 接口隔离原则

接口隔离原则ISP(Interface Segregation Principle): 接口尽量小,功能尽量单一,说白了就是接口粒度要细。

接口隔离要求接口的功能尽量单一,而单一职责也是要求一个类只干一件事,他们有什么区别?

单一职责针对的是"职责",一个职责可能有多个功能,可能由多个接口完成;而接口隔离针对的是"接口",一个接口应该只负责"一个"功能,而不是"一块"功能,举个例子,还是音乐播放器,我只定义了一个接口:

interface IPlayer {
    //开始
    void play(String url); 
    //停止
    void stop();
    //暂停
    void pause();
    //复原
    void resume();
    //获取歌曲时长
    String getSongTime();
}
1
2
3
4
5
6
7
8
9
10
11
12

这个满足了单一职责,但是却不满足接口隔离,假如我们现在有个歌曲展示器SongDisplayer,需要展示歌曲时长,那么我们也应该有个getSongTime()函数,那我们直接让它实现IPlayer接口吗?不行!因为实现这个接口就必须实现里面的其他方法,但是这些方法肯定不是需要的,也不应该有。这就产生了问题,原因接口不够小明显违背了接口隔离原则,我们就可以对接口进行拆分:

//音乐播放器就仅限于对播放的控制
interface IPlayer {
    //开始
    void play(String path); 
    //停止
    void stop();
    //暂停
    void pause();
    //复原
    void resume();
    ...
}

//歌曲展示器就仅限于对歌曲信息的展示
interface ISongDisplayer {
    //获取歌曲时长
    String getSongTime();
    //获取歌曲名字
    String getSongName();
    ...
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

此时我们创建的播放器就可以同时实现两个接口,只展示歌曲的话就只实现ISongDisplayer即可。

那么,我们根本不知道后面会出什么样的需求,怎么保证接口隔离呢?

不需要保证!因为接口隔离更多时候是个后置操作,说白了,更多时候是在问题发生的时候再去拆接口,所以是个后置操作,就像我们上面的修改,也没费多大劲,顶多就是多写一个接口,复制一部分代码,修改几个实现关系而已,根本没动业务上的代码,所以不必纠结,大多时候我们保证单一职责即可。

总之一句话: 接口要尽量小,尽量单一。

# 最少知识原则

最少知识原则LKP(Least Knowledge Principle)也叫迪米特法则(LOD): 一个对象应该对其他对象有最少的了解,说白了就是:只关联自己需要的。

就像语文老师,只关心语文成绩即可,非要关心数学,怪不得头发都掉光了。

废话不说,我们来看demo,又是那个音乐播放器,原本应该是这样的:

interface IPlayer {
  	void play(String path)
    ....
}

class User {
  	....
  	void play(){
      	player.play(song.path);
    }
  	....
}

class Song {
  	public String path;
  	public String name;
  	....
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

可以看到,播放时,只需要一个path即可,但是有人自觉聪明,我直接把Song给他闯过去不行吗?这样后面万一需要Song里面的其他变量,比如name 啥的,我也不用改函数了,好有道理啊!

突然有一天,要求可以播放用户通过聊天发送过来的歌曲,这个歌曲没有名字,点击就下载到本地,只有一个路径了,这个时候你怎么办呢?你当然可以用这个路径去创建一个Song然后丢进去,但是这样绕了一圈不就增加了复杂度吗?再万一将来某天要修改Song这个类呢,你的播放器也跟着修改了。

其实,播放器需要的只是一个播放的路径,至于其他的,它根本不关心,如果真的需要,你再提供,但也只需要提供它需要的,不要有任何附加内容。否则,一旦那些附加内容变化了,也间接导致播放器自身的变化,这是不应该的。

我们应该只关心自己直接使用的,不关心那些不需要的,这样,那些发生在我们不关心的区域内的事,不会引起我们的任何改变,从而大大提升了代码的鲁棒性。

# 开放闭合原则

开放闭合原则OCP(Open Close Principle): 一个类应该对扩展开放,对修改关闭。

开闭原则是最基础的原则,也是最完美的原则,很难百分百实现,他要求我们的任何修改都不改变老代码,只增加新代码,这样就不会引起老逻辑的改变,使得代码更加安全。

有人说,我们的代码不是一次性的,肯定是要修改的,怎么可能不修改呢?没错,肯定是需要修改的,但是合理运用开闭原则可以做到少修改,改的越少风险越小。

举个例子,我们来写个计算器,只需要支持短整型的加法和减法就行,很简单,我们直接这么写:

public class Calculator {
    public static int calculate(int left, int right, String option) {
        //加法
        if("+".equals(option)) return left + right;
        //减法
        if("-".equals(option)) return left - right;
        new IllegalArgumentException("不支持的运算");
    }
}
1
2
3
4
5
6
7
8
9

这样写怎么样,当然没问题,但是如果后期要支持其他的各种各样的运算,都需要在这里面添加if分支,每次都要改这个类,可能会影响到里面的其他运算,不安全。

那么我们就可以将每个运算定义成一个单独的类型,后面新增其他运算,只需要新加一个类就可以了。我们知道,基本的数学运算都是需要两个操作数和一个运算符的,我们可以定义一个公有的父类,来保存操作数和运算符。

定义公共父类:

public abstract Calculator {
    // 左操作数
    protected String leftOpt;
    // 右操作数
    protected String rightOpt;
    // 操作符
    protected String operator;

    // 设置左操作数
    public void setLeftOpt(String leftOpt) {
        this.leftOpt = leftOpt;
    }

    // 设置右操作数
    public void setRightOpt(String rightOpt) {
        this.rightOpt = rightOpt;
    }

    // 计算,提供一个模版函数,供子类实现
    protected abstract int calculate();

    // 对外公开的获取结果的Api
    public String getResult(){
        // 计算结果
        String result = calculate();
        // 清空操作数
        clear();
        // 返回结果
        retrun result;
    }

    //清空操作数
    public void clear(){
        leftOpt = null;
        rightOpt = null;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37

加法器:

public class PlusCalculator extends Calculator {

    public static String OPERATOR = "+";

    public PlusCalculator() {
        super();
        this.operator = OPERATOR;
    }

    // 加法
    @Override
    public String calculate() {
        return String.valueOf(Integer.parseInt(leftOpt) + Integer.parseInt(rightOpt));
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

减法器:

public class SubCalculator extends Calculator {
    public static String OPERATOR = "-";

    public SubCalculator() {
        super();
        this.operator = OPERATOR;
    }

    // 减法
    @Override
    public String calculate() {
        return String.valueOf(Integer.parseInt(leftOpt) - Integer.parseInt(rightOpt));
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

代码很简单,根据不同的运算符定义不同的计算器实现类,每个类负责实现自己的计算逻辑,如果将来需要支持其他运算符,直接再添加一个对应的类即可,完全不需要修改其他类的代码。

而且我们可以看到,开闭原则中用到了单一职责(每个类只做自己的运算),用到了最少知识(每个类只关心自己的操作数和运算符),其实就是一句话:**越单纯,越干净,越好!**因为这样自己的责任就越少,就越不容易被牵连,也就越稳定,越安全。

# 本章小节

本章讲了六大设计原则,这是所有设计模式的基础,设计模式就是这六大原则的具体实现,如果原则理解透了,就不会收到某种设计模式的影响,那么下一节,我就带着大家来从常见的设计模式中,抽丝剥茧的体验下这些设计模式中的设计原则。