前言
什么是设计模式?
设计模式是软件设计人员、软件开发人员在程序代码编写中总结出来的一套编码规范,设计模式起一个指导作用,用来指导我们写出高内聚低耦合,具有良好的可扩展性和可维护性的代码。
为什么要学设计模式?
当然,设计模式不是非学不可,不了解设计模式一样可以在工作中写出符合产品要求的功能。但是随着功能的不断迭代,需求不断增加和变更,项目中的代码会不断在在原有功能代码的基础之上堆叠,最终会形成难以维护的一坨屎山。另外,作为程序员,写出好的代码是我们基本的追求,也可以从专业的角度提升自己。
设计模式怎么学?
设计模式有非常多种,作为一个程序员,在日常写代码的过程中肯定有意无意的用到过某些模式。现在我们知道的23种设计模式,都是前辈们在各种实际开发场景中总结提取出来的,是一个通用的解决方案。虽说有23种之多,但这些模式都遵循了6大原则,了解了这6大原则再去看具体的设计模式就很容易理解了。
设计模式六大原则
单一职责原则
说白了就是一个类只做一件事。那我们为什么需要单一职责?如果某个类A承担了多个职责A1,A2,A3,因为某些原因需要对这三个任何一个职责进行修改或变更都可能会影响到其他职责,可能导致发生故障。所以最好的做法是将A拆分成三个类,每个类只负责一个职责。
合理的遵循单一职责原则,可以提高类的可读性、可维护性,降低类复杂度,从而提升了系统的可维护性。但是在我们日常工作中,在各种或者复杂或者简单的业务需求背景下,如何确定一个类的职责范围就需要我们好好思考了。
开闭原则
我们在做任何系统的时候,都不能指望系统一开始时需求确定,就再也不会变化,这是不现实也不科学的想法,而既然需求是一定会变化的,那么如何在面对需求的变化时,设计的软件可以相对容易修改,不至于说新需求一来,就要把整个程序推倒重来。怎样的设计才能面对需求的改变却可以保持相对稳定,并且预留好一些可扩展的点,从而使得系统可以在基于第一个版本以后不断扩展处新功能?我们用一个例子来说明怎样对扩展开放,对修改关闭。
假设我们有一个厨师类,该类有一些成员变量和一个方法:炒菜
,这个方法接收一个菜名,并且方法内部根据菜名进行不同的制作,具体类如下。
class 厨师 {
int 年龄;
string 姓名;
string 身份证;
public void 炒菜(string 菜名){
...
洗锅、洗菜、准备调味料等
...
if(菜名=="西红柿炒鸡蛋"){
...
炒西红柿逻辑;
...
炒鸡蛋逻辑;
...
其他逻辑继续追加
...
} else if (菜名=="酸辣土豆丝"){
...
}
}
}
如果有一天厨师突发灵感,想要对西红柿炒鸡蛋的制作工艺进行改良,那么应该怎么做?按照当前的做法是直接在炒菜
这个方法内,对if
块的逻辑进行逻辑修改,在足够细心的情况下修改此逻辑或许没什么问题,但在一个团队内,开发人员的风格和习惯各不相同,很难保证每个人在修改完西红柿炒鸡蛋的逻辑后可以不影响其他逻辑,此时就需要一个代码层面上的规范来强制约束大家,必须按照某个规范修改逻辑,并且这个规范天然不会影响其他逻辑,这个规范就是设计模式,在当前场景就是单一职责原则和开闭原则。
单一职责原则和开闭原则在这种场景下要求我们,需要将各种菜的逻辑单独拆分出来,并且将厨师制作的逻辑进行抽象。拆分后的逻辑类如下。
abstract class 菜谱{
string 菜名;
public abstract void 准备配料();
public abstract void 制作();
}
class 西红柿炒鸡蛋 extend 菜谱{
string 菜名;
public void 准备配料(){
...
准备盐醋酱油
...
}
public void 制作(){
...
炒西红柿逻辑;
...
炒鸡蛋逻辑;
...
}
}
class 酸辣土豆丝 extend 菜谱{
string 菜名;
public void 准备配料(){
...
}
public void 制作(){
...
}
}
class 厨师{
int 年龄;
string 姓名;
string 身份证;
public void 炒(菜谱 菜谱){
菜谱.准备配料();
菜谱.制作();
}
}
在上面的例子中,我们定义了一个菜谱基类,并且将所有菜的制作逻辑单独创建类并且继承菜谱类,实现制作一道菜的准备配料
和制作
逻辑。厨师则不局限于具体某道菜,而是根据菜谱炒菜。这样,即使要对西红柿炒鸡蛋
的制作逻辑进行改良,也不会影响到其他菜的逻辑。并且以后如果引进新菜谱,厨师也可以直接按照新菜谱进行制作,这样就遵循了对修改关闭,对新增开放的原则了。
依赖倒置原则
我们首先看例子,然后再解释这句话。
class 西红柿炒鸡蛋{
string 菜名;
public void 准备配料(){
...
准备盐醋酱油
...
}
public void 制作(){
...
炒西红柿逻辑;
...
炒鸡蛋逻辑;
...
}
}
class 厨师{
int 年龄;
string 姓名;
string 身份证;
public void 西红柿炒鸡蛋(){
西红柿炒鸡蛋 菜 = new 西红柿炒鸡蛋();
菜.准备配料();
菜.制作();
}
}
在上面的例子中,厨师类
就是高层模块,而西红柿炒鸡蛋
和酸辣土豆丝
属于底层模块,此时的厨师类
依赖了底层的实现。假如西红柿炒鸡蛋
这道菜增加了一个逻辑剥西红柿皮
,那么厨师类
也要增加调用方法,这样就减小了系统的可维护性。我们来看看修改后的逻辑实现。
abstract class 菜谱{
string 菜名;
public abstract void 制作();
}
class 西红柿炒鸡蛋 extend 菜谱{
string 菜名;
private void 剥西红柿皮(){
...
}
private void 准备配料(){
...
准备盐醋酱油
...
}
private void 炒西红柿(){
}
private void 炒鸡蛋(){
}
public void 制作(){
剥西红柿皮();
准备配料();
炒鸡蛋();
炒西红柿();
}
}
class 厨师{
int 年龄;
string 姓名;
string 身份证;
public void 炒(菜谱 菜谱){
菜谱.制作();
}
}
修改后的逻辑,厨师类
只依赖于抽象类菜谱
的抽象方法制作
,此时高层模块厨师
没有直接依赖具体实现,而是依赖了菜谱
这个抽象类。具体的菜西红柿炒鸡蛋
要怎么炒、哪块需要增加制作步骤,全部在西红柿炒鸡蛋
的菜谱中进行修改。
如何理解“抽象不应该依赖细节,细节应该依赖抽象”这句话?我们有一个菜谱
抽象类和西红柿炒鸡蛋
实现类,此时如果西红柿炒鸡蛋要增加步骤剥西红柿皮
,如果在抽象类中增加方法剥皮
,并在西红柿炒鸡蛋类中将剥西红柿皮的实现逻辑写在剥皮方法中,就犯了抽象依赖细节的错误了。抽象类应该是从具有某一同一行为的一类活动中抽象出来的通用类,而在本例中,同一行为就是菜的制作,而对于西红柿炒鸡蛋的所有制作过程,都属于制作。所以在抽象类中提供了制作
方法后,实现类西红柿炒鸡蛋
的所有制作逻辑都应该在制作
方法中实现,而非在抽象类中增加方法并在子类实现,这个就是细节应该依赖抽象。
里氏替换原则
里氏替换原则要求我们在所有依赖父类的地方,子类可以完全替代父类并且对逻辑无影响。在子类重写了父类已实现逻辑的情况下很容易违反此原则,我们还是看具体栗子。
abstract class 厨师{
abstract void 洗菜();
abstract void 调味();
void 炒(){
洗菜();
...
下锅逻辑
...
调味();
...
出锅逻辑
...
}
}
class 张三 extend 厨师{
string 洗菜(){
...
洗菜逻辑
...
}
string 调味(){
...
调味逻辑
...
}
//这里覆盖了父类的已实现方法
void 炒(){
...
下锅逻辑
...
调味();
...
出锅逻辑
...
洗菜();
}
}
class 饭店{
void 炒菜(){
厨师 张三 = new 张三();
张三.炒();
}
}
抽象类厨师
类作为父类,定义了两个抽象方法和一个已实现方法。子类张三
继承了厨师
类,并实现了两个抽象方法:洗菜
和调味
,并且又重写了父类已实现的方法炒
,此时父类的方法炒
和子类的逻辑就不一致。在父类方法中,逻辑流程是“洗菜-下锅-调味-出锅”,意味着子类所有的逻辑都必须按照这个流程执行。但子类张三
重写的逻辑时下锅-调味-出锅-洗菜
,逻辑不同,就不能在父类出现的地方替换成子类,否则可能会造成系统或者流程异常。
迪米特法则
在类的结构设计上,每个类都应当尽量降低成员的访问权限,不需要让别的类知道的字段或行为就不公开,否则会破坏类的预期行为和安全性,我们直接看例子。
class 西红柿炒鸡蛋{
private int 鸡蛋;
private int 西红柿;
private int 盐;
private int 醋;
public 西红柿炒鸡蛋(int 鸡蛋,int 西红柿,int 盐,int 醋){
this.鸡蛋=鸡蛋;
this.西红柿=西红柿;
this.盐=盐;
this.醋=醋;
}
public void 制作(){
...
搅拌鸡蛋(this.鸡蛋);
...
切西红柿(this.西红柿);
...
加入盐(this.盐);
...
加入醋(this.醋);
...
}
}
class 厨师{
public void 炒(){
西红柿炒鸡蛋 菜=new 西红柿炒鸡蛋(2,1,500克,1升);
菜.炒();
}
}
抛开前面讲的几个原则先不管,第一眼看上面的例子好像没什么问题,厨师
类有炒
方法,西红柿鸡蛋
类也没其他无关逻辑,但我们看实例化西红柿炒鸡蛋
的代码,实例化时传入的鸡蛋数2、西红柿1、盐500克、醋1升,看出问题了吧。谁家炒两个鸡蛋要放500克盐1升醋,这样做出来的菜还能吃吗?所以很明显这个实例化时的入参是有问题的,盐和醋作为西红柿炒鸡蛋
这道菜中的关键参数,需要用多少应该是根据鸡蛋和西红柿的数量来确定的,而不是初始化时任意传入的。所以这个类的定义就违反了最少知道原则,将关键参数盐
和醋
通过构造函数暴漏出来了。
class 西红柿炒鸡蛋{
private int 鸡蛋;
private int 西红柿;
public 西红柿炒鸡蛋(int 鸡蛋,int 西红柿){
this.鸡蛋=鸡蛋;
this.西红柿=西红柿;
}
public void 制作(){
...
搅拌鸡蛋(this.鸡蛋);
...
切西红柿(this.西红柿);
int 盐=0;
int 醋=0;
if(鸡蛋==2 && 西红柿==1){
盐=10;
醋=10;
}else if(/*其他判断逻辑*/){
}
}
}
class 厨师{
public void 炒(){
西红柿炒鸡蛋 菜=new 西红柿炒鸡蛋(2,1);
菜.制作();
}
}
上面我们修改过后的类定义,西红柿炒鸡蛋
构造函数只接受鸡蛋
和西红柿
数量,而关键参数盐
和醋
则是在正式制作的时候,根据鸡蛋和西红柿的数量来最终确定,这样,无论要炒多少个鸡蛋和西红柿都会有对应的盐
和醋
被放入,确保炒出来的菜是真正可以吃的,即我们定义的类的行为是符合预期的。
接口隔离原则
我们直接看示例
abstract class 人{
abstract void 吃饭();
abstract void 睡觉();
abstract void 跑步();
abstract void 工作();
abstract void 爬();
}
class 婴儿 extends 人{
void 吃饭(){
}
void 睡觉(){
}
void 跑步(){
//没法跑
}
void 工作(){
//没法工作
}
void 爬(){
}
}
class 成人 extends 人{
void 吃饭(){
}
void 睡觉(){
}
void 跑步(){
}
void 工作(){
}
void 爬(){
//没必要
}
}
在上面的代码中,我们定义了一个人
抽象类,并且定义了5个抽象方法。有两个子类婴儿
和成人
,分别实现了抽象类定义的5个方法,但我们注意到,在婴儿
子类中是没法实现跑步
和工作
逻辑的,因为婴儿不具备这样的能力。而在成人
子类中,也没必要实现爬
的方法,因为成人没必要爬。此时虽然在基类人
中定义的所有行为都是属于人的,但并非所有继承自人
的子类都需要全部实现这些方法,此时就违背了接口隔离原则。那么我们看看如何修改基类定义。
abstract class 人{
abstract void 吃饭();
abstract void 睡觉();
}
abstract class 婴儿 extends 人{
abstract void 爬();
}
abstract class 成人 extends 人{
abstract void 跑步();
abstract void 工作();
}
class 张三 extends 成人{
void 吃饭(){
}
void 睡觉(){
}
void 跑(){
}
void 工作(){
}
}
class 宝宝 extends 婴儿{
void 吃饭(){
}
void 睡觉(){
}
void 爬(){
}
}
在上面修改后的代码中,抽象类人
只定义了两个抽象方法吃饭
和睡觉
,继承自人
的两个子类抽象类婴儿
和成人
分别定义各自的抽象方法爬
和跑步
、工作
。那么在具体的实现类中,我们就可以继承不同的类:张三
作为一个成人拥有基本的吃饭、睡觉、跑、工作
行为,而宝宝
作为婴儿则有吃饭、睡觉、爬
的行为。这样各个类根据各自需求,继承满足要求的单一接口,而不用继承一个大而全但其中的许多行为都没法实现的接口,也避免了在依赖方调用对应对象方法时,某些行为未实现导致的功能异常。
总结
设计模式可以指导我们代码的结构搭建,而这六大原则则指明了设计模式的基本遵循的准则,在我们日常编写代码的时候,如果能比较好的遵循这些原则,那么即便我们没有按照某个具体的模式套在对应的场景上,写出来的代码也会具有较好的可维护性。