架构之基:从根儿上了解设计原则

2024-06-09 13:57:39 浏览数 (2)

本文节选自 奔波儿灞取经 的《程序员的必修课》,文中的“我”指原作者奔波儿灞取经。

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

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

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

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

当然,要想正确地使用设计模式,还是先得透彻地了解了它们。因此,接下来我们就先来了解下设计模式的“祖宗”:六大设计原则

1 单一职责原则(SRP)

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

可以看到,单一职责的核心就是:只做一件事。不过,关键点就是这个“事”的范围不好定义。

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

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

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

比如,我在 2015 年实习的时候,IBM 公司有个考勤系统,需要添加一个指纹打卡功能。需求是这样的:部门主管以下的员工可以用指纹来打卡

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

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

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

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

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

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

1 2 3

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

使用:

1 2 3 4 5 6

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

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

1 2 3 4 5

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

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

如果有一天,我们的需求变成了:年龄不满足就开启未成年人保护模式,不需要弹出提示。我们直接删除tips()这个调用吗?这样不太好,如果别的地方也调用了这个方法,并且需要提示,就完了。所以我们应该有两个方法:方法 A 只检测,方法 B 使用 A 的检测结果并弹出提示。代码如下所示:

1 2 3 4 5 6 7 8 9 10 11 12 13

// 判断加提示 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; }

这里我们只抽离了一个方法,但是却反映出了单一职责的好处:职责越单一,因为修改而引起的问题就越少。换句话说就是:需求的粒度跟单一职责的优势成正比,需求越详细,越能看出单一职责的好处。所以我们要尽量避免大方法、大类、大模块,因为一个类越大,涉及的东西就越多,用到它的地方就越多,那么这个类就不能轻易修改,因为一旦修改,涉及的地方就越多,就越危险,所以我们一定要尽量避免。其实 MVC 就是一个宏观的、大的单一职责思想。

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

2 里氏置换原则(LSP)

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

用人话说就是:用子类替换父类不会改变原有逻辑。众所周知,面向对象有三大基本原则:封装、继承和多态。子类本来就继承了父类,用到父类的地方替换成子类肯定没问题啊,这个原则不是废话吗,不一定!因为子类有自己的特色,也就是多态,如果这个特色太特色的话,就不适合了。

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

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

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

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

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

那么,怎么改呢?有如下两种方法:

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

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

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

可以看到,里氏置换原则就是对继承的校验,不恰当的继承关系就不满足里氏置换原则,所以,如果我们无法确定某两个类之间是否应该用继承关系时,就可以套用里氏置换原则来校验下。

3 依赖倒置原则(DIP)

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

依赖倒置的官方定义:高层不应该依赖底层,两者都应该依赖抽象;抽象不应该依赖细节,细节应该依赖抽象。其实狭义的说就是:面向接口编程,广义的说就是:面向抽象编程。也就是说,我们在使用类的时候,优先考虑使用抽象类或接口。具体一点就是:成员变量、函数参数和返回值都尽量定义成接口。

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

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

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

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

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

1 2 3 4 5 6

class MediaPlayer { public void play(String path) {} public void stop(){} public void pause(){} public void resume(){} }

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

1 2 3 4 5 6 7

class User { private MediaPlayer mediaPlayer; public void play(){ mediaplayer.play("xxx"); } }

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

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

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

好,上接口!

1 2 3 4 5 6

interface IPlayer { void play(String path); void stop(); void pause(); void resume(); }

User使用:

1 2 3 4 5 6 7

class User { private IPlayer player; public void play(){ player.play("xxx"); } }

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

所以,我们可以看到面向接口的好处:低耦合,易拓展。因为接口是抽象的,依赖接口就是依赖抽象,不依赖细节,所以实现的细节怎么改都对我无影响,所以耦合就低;又因为接口是顶层的,就更容易拓展下层的细节实现。

4 接口隔离原则(ISP)

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

接口隔离要求接口的功能要单一,这听起来怎么就是单一职责原则呢,它们有区别吗?

有!

单一职责原则针对的是“职责”,说白了就是功能块,一个职责可能有多个功能;接口隔离原则针对的是“功能”,也就是一个接口只负责一个“功能”,比如,老师的职责是讲课和改作业,如果用单一职责原则就是一个接口里面包含了讲课和改作业这两个方法;如果用接口隔离原则就是两个接口,一个讲课的接口和一个改作业的接口。换句话说就是:接口隔离原则是单一职责的单一职责原则。

举个例子,还是音乐播放器,我们定义了一个接口:

1 2 3 4 5 6 7 8 9 10 11 12

interface IPlayer { //开始 void play(String url); //停止 void stop(); //暂停 void pause(); //复原 void resume(); //获取歌曲时长 String getSongTime(); }

这正是单一职责原则,因为这个接口只定义了音乐播放相关的东西,但是却不满足接口隔离原则,因为一个接口干了多件事,假如我们现在有个歌曲展示器SongDisplayer,只需要展示歌曲的时长,也就是只需要getSongTime()这个函数,我们让它直接实现IPlayer接口吗?肯定不行!因为里面的其他函数是不需要的,也不应该有的。这就要用到接口隔离原则了,我们直接将IPlayer接口再进行拆分,如下:

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

//音乐播放器就仅限于对播放的控制 interface IPlayer { //开始 void play(String path); //停止 void stop(); //暂停 void pause(); //复原 void resume(); ... } //歌曲展示器就仅限于对歌曲信息的展示 interface ISongDisplayer { //获取歌曲时长 String getSongTime(); //获取歌曲名字 String getSongName(); //其他 ... }

这样拆分后,我们的播放器就同时实现上面两个接口,而歌曲展示器只需要实现ISongDisplayer即可。

但是,我们根本不知道将来会出什么样的需求,怎么能提前预测并做好接口隔离呢?

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

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

5 最少知识原则(LKP)

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

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

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

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

interface IPlayer { void play(String path) .... } class User { .... void play(){ player.play(song.path); } .... } class Song { public String path; public String name; .... }

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

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

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

我们应该只关联自己直接用到的,而不关联那些不需要的,如此一来,那些发生在我们关联范围外的事,就不会引起我们的任何改变,这样就大大提升了代码的健壮性。

6 开放闭合原则(OCP)

开放闭合原则(Open Close Principle,简称 OCP):一个类应该对扩展开放,对修改关闭。换句话说就是:应该多扩展代码,少修改代码。

开闭原则是最理想的原则,是所有设计模式的最终目标,基本不可能实现。它要求我们的任何改动都不修改老代码,而只添加新代码,这样就不会对老逻辑有任何影响,从而使得代码更加安全。

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

举个例子,比如我在面试百度的时候,要手写一个计算器,只需要支持简单的加减法就行,如下:

1 2 3 4 5 6 7 8 9

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; throw new IllegalArgumentException("不支持的运算"); } }

代码简单粗暴,直接使用if判断就完事。但是,如果将来要支持其他运算呢?嗯,继续添加if分支?可以,但是不太好,谁能保证你下次添加别的运算符的时候,不会手残改了别的运算呢?那么,我们能不能将新的运算不放在这个类里面呢?可以!

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

定义公共父类:

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 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

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

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)); } }

这里我们为不同的运算符提供了不同的实现类,每个类只负责自己的计算逻辑,如果将来有其他新运算加入,我们直接再添加一个新的类即可,完全不需要修改其他类的代码。

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

7 设计模式

该小节非原文内容,仅为个人补充。

在掘金上有一个不错的专栏「手撕设计模式」,里面详细介绍了 23 种设计模式中的好几种,可以作为进一步学习的参考。

8 总结

本节我们从宏观层面讲解了六大设计原则,这是 23 种设计模式的祖宗,或者说:设计模式就是这六大设计原则的具体实现,六大设计原则就是设计模式的抽象

对于设计模式,我认为正确的学习顺序是:

  1. 学习设计原则,这是对设计思想的宏观认识。
  2. 学习设计模式,这是对设计思想的具体认识。
  3. 再学习设计原则,这是对设计思想的自我抽象。

这就像我们看书的时候,先看目录,对整本书有个宏观的认识;然后仔细看每一章节,对每个模块进行具体了解;最后,也是最难的一点,就是:用自己的语言对整本书进行整体概括,然后尝试列出目录,这是对整本书的自我升华,或者叫自我抽象,这样,我们才能读到书的精髓。学习设计模式亦是如此,我们切记不要死记硬背,不要生搬硬套,不刻意设计的设计才是最好的设计。

0 人点赞