3.1 六大设计原则
设计模式可以在软件设计时为我们提供强有力的支撑,在正式学习设计模式之前,我们首先了解一下软件设计的六大原则:
- 单一职责原则
- 接口隔离原则
- 里式替换原则
- 迪米特原则
- 依赖倒置原则
- 开闭原则
单一职责原则
单一职责原则的定义是:有且仅有一个原因引起一个类或者一个模块的变更。这个定义相信很多人能够看明白,但是在实际应用中却不一定能应用正确。
下面我们通过例子来介绍该原则。
例子要求:设计一个学生管理系统,其中包含学生的基本信息(姓名、学号、登录密码等)、学生的行为信息(选课、分配班级、考试等)。针对该要求,我们应该很快可以完成一个接口和实现类,类图如下:
通过上述类图可以看到在接口 IStudentInfo 中包含了对学生基本信息和行为信息的操作方法,类 StudentInfo 实现 接口的方法,体现出面向接口编程的思想。可能很多人会这样设计接口,乍一看这样没什么问题,但是这样遵循了“单一职责原则”了嘛?其实没有,因为引起这个类变动的原因有2个:学生基本信息和学生行为信息。
我们按照单一职责原则来将上述接口进行改造成2个接口,分别是IStudentBaseInfo 和 IStudentActionInfo:
可能依旧会有同学提出疑问,StudentInfo 类的变动原因不还是2个嘛?确实如此,但是需要牢记我们是面向接口编程,保证了接口单一就好。
通过这个例子给大家讲解了什么是单一职责原则,但是如何划分职责是比较麻烦或者比较有争议的事情。有的职责很容易划分,有的职责划分则模棱两可。那我们是否需要花费比较大的精力去正确划分职责呢?其实不必如此,设计模式是帮助我们写出更好的代码的经验,而非一套必需遵循的真理。如果一些简单场景却要大费周章的加入设计模式,那真的是事倍功半,费时费力。
接口隔离原则
接口隔离就要求接口细化,接口中的方法尽可能的少。例如在学生管理系统中需要提供一个优秀学生的接口。首先我们对优秀学生有一个定义就是:学习成绩好、班级贡献大。那我们设计的接口如下:
定义好了接口之后,就可以给好老师进行调用(为了和下面定义的势利眼老师做对比)。好老师类的代码为:
class GoodTeacher implements IGoodStudent {
public void goodGrade(String stuName) {
System.out.print(stuName + "学习成绩好");
}
public void goodContribution(String stuName) {
System.out.print(stuName + "班级贡献大");
}
} 调用方依赖该接口之后则可以判断学生是否优秀,这样的接口设计感觉还比较完美。但是随着拼爹时代的到来,在势利眼的老师眼中好学生的定义新增了一项:有个好爹。
针对这个需求我们有2种方案:一种是在原先接口中新增方法;一种是新增接口;那这2种方案哪个好呢?
首先我们看方案一:在原先接口新增方法后,之前好老师的代码也需要修改,需要实现新的方法。但是好老师其实不需要该方法,这样就违背了接口隔离的原则。
再看方案二:新增一个接口之后,好老师的代码无需变动,只不过势利眼老师类需要实现2个接口。
采用方案二之后的接口定义为:
采用方案二之后,好老师便无需依赖自己不需要的接口方法,满足接口隔离原则。
里式替换原则
面向对象三大特征之一便是 继承,继承可以代码复用,使得子类自动拥有父类的方法和属性,提高代码的扩展性。但是继承统一具有侵入性的问题,子类继承父类之后会继承父类所有的内容,从某种程度上稍微降低了子类的自由度。此处为何先提到继承呢?是因为里式替换原则就是在继承的基础上展开的。
里式替换原则通俗易懂来讲就是:只要在父类出现的地方,子类同样可以出现,并在子类替换父类之后,不会产生任何错误。
日常大家在开发中设计接口的时候,应该很多时候都已经用到了里式替换原则。例如设计一个查询学生的接口,代码通常如下:
public interface StudentService {
List<Student> listStudentByIds(Collection studentIds);
} 本示例中接口的入参是接口Collection(实际应用中也可以是抽象类),调用方代码如下:
public class Client {
public static void main(String[] args) {
Collection studentIds = new ArrayList<>();
List<Student> students = studentServiceImpl.listStudentByIds(studentIds);
}
} 使用里式替换原则,所有使用父类的地方就可以使用子类替换,并不会产生任何错误。代码修改之后如下:
public class Client {
public static void main(String[] args) {
// 此处将List取代Collection,同理也可以换成Set等
List studentIds = new ArrayList<>();
List<Student> students = studentServiceImpl.listStudentByIds(studentIds);
}
} 通过改动之后的代码可以看出,使用里式替换原则之后,调用方可以使用任意子类,而服务方无需修改。所以可以适配不同调用方定义不同类型的 studentIds 变量。
反过来想一下,如果服务端使用的类型是List,那么调用方也必须传List,如果调用方的类型是Set,还需要进行转换,给调用方增加了些许负担。
通过使用里式替换原则可以增加程序代码的健壮性,如果拓展多个子类或者多个调用方时,可以灵活应对,保持很好的兼容性。
迪米特原则
迪米特法则也被成为最少知识原则,即一个对象应该对其他对象有最少的了解。此处的“其他对象”就是自己需要耦合或调用的对象,自己无需关注被调用对象的内部实现,只关心被调用对象暴露了多少接口。
我们知道好的设计就是高内聚、低耦合,迪米特法则目的是为了降级类之间的耦合,实现低耦合的目标。下面从2个角度介绍一下迪米特法则是保证低耦合的:
只和直接对象交流
迪米特法则要求不要对自己不关心的类产生依赖或调用。下面通过举例进行介绍什么是只和直接对象进行交流:
需求:老师命令让班长去收集各学生的作业,信息传达链路是:老师 --> 班长 --> 学生。
针对该需求我们进行设计如下:
代码如下:
// 老师类
public class Teacher {
public void cmdHomework(Monitor monitor) {
// 定义班级的学生
List<Student> students = new ArrayList<>();
// 命令班长收作业
monitor.collectHomework(students);
}
}
// 班长类
public class Monitor {
public void collecHomeword(List<Student> students) {
for(Student student : students) {
System.out.println("已收"+ student.getName() +"的作业");
}
}
} 至此老师命令班长收作业的需求就开发完成了,大家可以结合信息传达链路和类图看一下这样的实现是否有问题。其实通过信息传达链路可以看出老师的直接对象只有班长,班长是老师和学生直接的桥梁,所以老师类不应该依赖学生类,即学生列表的初始化不应该在老师类当中。
修改设计图之后,和信息传达链路一致:
代码修改如下:
// 老师类
public class Teacher {
public void cmdHomework(Monitor monitor) {
// 命令班长收作业
monitor.collectHomework();
}
}
// 班长类
public class Monitor {
public void collecHomeword() {
// 定义班级的学生
List<Student> students = new ArrayList<>();
for(Student student : students) {
System.out.println("已收"+ student.getName() +"的作业");
}
}
} 这样就保证类只和自己关心的对象产生关系,降低了类之间的耦合性。
保持和对象之间的距离
上面介绍了应该只和直接对象进行交流,但是和直接对象交流也应该有距离。距离太近,容易刺伤;距离太近,形同陌路。保持距离在迪米特法则中就是要求对外尽量公布较少的public方法。
假如现在老师想查询某个学生的各科(数学、语文、英语)的总成绩,你的接口可以设计出如下格式:
public class Student {
public int getChineseScore() {
return 98;
}
public int getMathScore() {
return 89;
}
public int getEnglishScore() {
return 67;
}
}
public class Teacher {
public int sum(Student student) {
return student.getChineseScore() + student.getMathScore() + student.getEnglishScore();
}
} 这样老师可以获取到学生的每科分数并进行求和,从而获得学生的总成绩。
但是这样给老师暴露了太多public方法,如果以后再增加一门物理成绩,那还需要老师类的修改。所以我们不应该暴露如此多的public方法,我们只需给老师暴露一个计算总成绩的public即可,其他通过private方法控制,如果以后增加科目,只在自己内部修改即可。代码如下:
public class Student {
public int sum() {
return getChineseScore() + getMathScore() + getEnglishScore();
}
private int getChineseScore() {
return 98;
}
private int getMathScore() {
return 89;
}
private int getEnglishScore() {
return 67;
}
}
public class Teacher {
public int sum(Student student) {
return student.sum();
}
} 再仔细想想第二种方式有没有问题呢?如果老师不仅需要查询总成绩,还想了解每一科的成绩,那是不是就得想第一种方式一样,再暴露一些public方法呢。其实这两种方式在不同场景下各有优劣,此处举例并非为了说明孰优孰劣,只是为了介绍迪米特法则是如何降低耦合的。
依赖倒置原则
依赖倒置的核心思想是面向接口编程,它要求:
- 高层模块不应该依赖低层模块,两者应该都依赖其抽象
- 抽象不应该依赖细节
- 细节应该依赖抽象
每段逻辑都是有单个或多个子逻辑组合而成的,其中子逻辑就是低层模块,由子逻辑组合成的就是高层模块;那什么是抽象呢?在面向对象语言中,抽象就是接口或者抽象类,无法被实例化;细节就是接口或抽象类的实现类,可以实例化。这样介绍可能还是有些生硬,接下来通过一个例子来进行说明。在早期的时候学校只开设一门语文课程,而且只会聘请语文老师,针对老师授课我们类图如下:
代码如下:
public class ChineseTeacher {
public void teach(Chinese chinese) {
System.out.println("老师讲授语文课程");
}
}
public class Client {
public static void main(String[] args) {
Chinese chinese = new Chinese(); // 定义课程
ChineseTeacher teacher = new ChineseTeacher();
teacher.teach(); // 老师讲授语文课程
}
} 至此我们就完成了语文老师讲授语文课程的功能,随着时代的发展,我们的学习科目越来越多,老师的类别也越来越多,逐渐出现了英语老师、英语课程、数学老师、数学课程。上面的结构已经完全不能满足我们的需求了,只能每一个科目写一个类似上面的结构,但是这样会导致对外暴露太多的接口。此时我们将老师和科目再抽象一层,形成下面的类图:
调用方代码如下:(省略类继承的代码,只有Client代码)
public class Client {
public static void main(String[] args) {
// 定义语文课程和语文老师
ITeacher chineseTeacher = new ChineseTeacher();
IProject chineseProject = new ChineseProject();
// 语文老师授课
chineseProject.teach(chineseProject);
// 定义英语课程和英语老师
ITeacher englishTeacher = new EnglishTeacher();
IProject englishProject = new EnglishProject();
// 英语老师授课
englishTeacher.teach(englishProject);
}
} 有了上面的接口,如果以后再新增数学科目、数学老师、体育科目、体育老师就是比较容易的事情了,只要实现ITeacher和IProject接口即可。
通过上面的示例大家应该了解了什么是面向接口编程以及面向接口编程的好处,也了解了什么是依赖倒置原则。那在实际应用中我们怎么才能更好的使用依赖倒置原则呢?
- 每个类要有接口或抽象类。这是依赖倒置的基本,因为接口和抽象类才算是抽象,有了抽象才能依赖倒置
- 结合里式替换原则使用。父类出现的地方子类就可以出现,就可以让抽象符合公共部分,用细节实现准确的业务逻辑。
开闭原则
开闭原则是最基础的设计原则,定义是:类、模块、函数应该对扩展开放,对修改关闭。看完定义应该还是一头雾水,开放哪些内容?又如何对修改关闭呢?下面我们一步步进行探索。
一款软件产品并非一成不变的,一定会随着发展而变化,那我们如何适应这些变化呢?开闭原则告诉我们:应该尽可能的通过扩展方式来实现功能,而不是修改已有的代码。下面通过一个例子进行说明:
学校中往往会有获奖学生,假设成绩高于90分就可以获奖。代码如下(代码省略了getter/setter方法):
// 学生类
public class Student {
private String name;
private int score;
public Student(String name, int score){
this.name = name;
this.score = score;
}
public boolean canAward() {
return this.score > 90;
}
}
// 调用方
public class Client {
public static void main(String[] args) {
Student s1 = new Student("小明", 98);
Student s2 = new Student("小红", 93);
Student s3 = new Student("小文", 89);
List<Student> students = new ArrayList<>();
students.add(s1);
students.add(s2);
students.add(s3);
for(Student s : students) {
if (s.canAward()) {
System.out.println(s.getName() + "可以获奖");
} else {
System.out.println(s.getName() + "不可以获奖");
}
}
}
} 通过上面代码则可以查询到小明和小红都可以获奖。但是有一年题目很简单,大家的分数都很高,所以也需要对获奖的门槛提高到95分。此时有2种方案进行修改。
方案一:就是直接修改上文的 canAward()方法。但是这样会导致底层代码变动,违背了对修改关闭的原则。况且以后如果再调整门槛难道继续改动这一块的代码吗?而且修改了这个方法就会导致高层调用模块所有的逻辑都要变为95分才是获奖学生,这可能影响较大。
方案二:拓展一个新的类,继承自Student,并重写canAward()方法,这样底层无需变动,直接扩展即可。我们采用方案二进行介绍。类图如下:
代码如下:
// Student类代码和上文一样,无变动
// 新的获奖学生类
public class NewAwardStudent extends Student {
public NewAwardStudent(String name, int score){
super(name, score);
}
public boolean canAward() {
return this.score > 95;
}
}
// 调用方
public class Client {
public static void main(String[] args) {
Student s1 = new NewAwardStudent("小明", 98);
Student s2 = new NewAwardStudent("小红", 93);
Student s3 = new NewAwardStudent("小文", 89);
List<Student> students = new ArrayList<>();
students.add(s1);
students.add(s2);
students.add(s3);
for(Student s : students) {
if (s.canAward()) {
System.out.println(s.getName() + "可以获奖");
} else {
System.out.println(s.getName() + "不可以获奖");
}
}
}
} 代码完成之后发现获奖的就只有小明一个人了。本次需求变动并没有涉及到底层Student的改动,只涉及到高层模块的改动。其实只要需求变动高层模块免不了跟随变动,所以这样的变动可以接受,而且改动较少,并且不会影响那些仍然希望将90分作为获奖标准的调用方。这样我们通过扩展的方式实现代码,正好满足了开闭原则的要求:对扩展开放,对修改关闭。
开闭原则是一个终极目标,也是一个很难百分百达到的目标,我们只能尽可能的按照原则办事,真正的”拥抱变化“。
上面带领大家认识了软件设计的六大原则,这些原则就像设计模式一样,是指导大家如何做的更好,但并非是一种强制性地行为准则。按照准则走最好,但是一味的循规蹈矩往往会出力不讨好。
查看5道真题和解析