单一职责原则
如何理解单一职责原则?
- 不要设计功能大而全的类,要设计粒度小、功能单一的类
如果一个类有多个功能,会增加它对上游的依赖和下游对它的依赖。具体来说,当你要修改修改这个类的时候,你需要更多地考虑这部分代码可能带来的影响,这是典型的“牵一发而动全身”,这会让我们的修改变得棘手
你应当设计一个功能单一的类,从而可以为上游提供一个更加清晰的抽象接口。
如果你有多个功能需求,你可以考虑组合这些类,而不是将所有功能写到一个类中
如何判断一个类的职责是否足够单一?
这个判断没有统一的标准,它更多地依赖程序员的直觉,即使如此,我们也需要知道程序员应该关注哪些方向
所以,给出下面的一些判断的参考条件:
- 类的代码行数、函数、属性过多
- 类对上游的依赖过多,或被太多下游依赖
- 你很难给你的类取一个非常合适的名字(因为你的类的职责定义的不够清晰)
- 类中大量的方法都是集中操作类中的某几个属性,你可以考虑将这部分属性和方法拆出来
类的职责是否设计的越单一越好?
当然不是!设计的原则是高内聚低耦合,单一的类可以降低耦合度,同样也会降低代码的内聚性
先对类进行抽象,基础的、共有的部分就不要再拆了
对扩展开放、对修改关闭
什么是开闭原则?
- 当你要添加一个新的功能的时候,应该是在已有代码的基础上进行扩展,而不是修改已有的代码
为什么需要这个原则,解决了什么问题,又会带来哪些问题?
这个原则可以提升代码的扩展性和稳定性,如果你的代码符合这个原则,在你添加功能的时候,会有一些表现:
- 你不会破坏原有的代码的运行
- 你不会破坏原有的单元测试
实际上,绝大多数设计模式都是为了解决扩展性而存在的,而这些模式都遵循这个原则
但是,这个要实现这个原则是有代价的,我们在提高代码扩展性(例如使用了某个设计模式)的同时,必然会导致代码可读性的下降,引入一些额外的代码,这些代码可能是:
- 一个用于传递参数的类
- 一个用于初始化的方法
- 可以适应扩展的方法,例如迭代
如果你的变动没有特别频繁,让代码保持简单可能是更好的选择
如何做到开闭原则?
这非常难,在编码的时候你需要时刻具备三个意识:
- 扩展意识:这段代码是否会面临频繁的需求变更,不同业务是否需要提供不同的实现?如果是,那么如何做好相关的扩展?
- 抽象意识:我写的这部分代码是否可以进行抽象?是否可以写一个抽象类或者使用一组接口来对逻辑进行抽象?如果是,那最好面向接口而非实现来编程
- 封装意识:将业务中可变的,不稳定的东西封装起来(可能是封装成一个类),在封装的基础上对外部提供稳定的接口
里式替换原则
什么是里式替换原则?
- 子类对象能够替换程序中父类出现的任何地方,并且保证原有程序的逻辑行为不变,以及正确性不被破坏
- 人话:按照协议来设计
如何理解这里说的协议?
我认为这里所说的协议是一个非常重要的概念
在设计类的时候,我们要对类的职责进行抽象,抽象出的产物可能是接口
而协议,则是如何使用这些接口的约定。
举个例子
接口A 提供用户查询的能力,它的接口约定是:入参一个 userID,出参一个 userInfo 类,当输入的 userID 有问题的时候会怎么怎么样...
接口B 需要接收 userInfo 做一些其他处理,如果遇到xx问题会怎么这么样...
你会看到,这些接口需要完成自己的功能,并且在出现特定的问题的时候执行特定的逻辑,这部分逻辑就需要我们用协议来约定了
违反里式替换原则的表现有哪些?
里式替换是一个非常宽松的原则,使用起来也非常简单,在很多情况下,子类继承父类,实现多态的过程,就遵守了里式替换原则。但是,这也不是绝对的,胡乱重载会破坏里式替换,例如:
- 子类违背了父类声明要实现的功能:父类提供一个按用户注册时间排序的方法,子类将这个方法修改成按照最后登录时间排序
- 子类违背父类对输入、输出、异常 的约定:父类约定运行出错会返回空集合,而子类运行出错后却抛异常
- 子类违背父类注释的说明:父类规定用户金额不能为负数,子类为VIP用户,允许透支一定额度。(此时可以修改父类注释)
接口隔离原则
什么是接口隔离原则?
- 接口的调用者不应该被强迫依赖它不需要的接口
在这里,对于“接口”存在三种定义:
- 一组 API 的集合
- 单个 API 的接口或函数
- OOP 中的接口,即 interface
不论我们对于接口的定义是什么样的,这个原则的思想都是不变的:接口只依赖自己需要的依赖,对于不需要的则不要依赖
针对不同的接口定义,我们可以细化出不同的要求(但是他们的思想都是一样的)
- 对于一组 API,只向他们暴露功能必须的代码。例如:对用户的操作不应该暴露在普通 userService 中,而应该选择性地暴露给更高权限的 userManage 中
- 对于单个 API 的接口或函数,提供给这个接口或函数他们必要的信息,对于不要的信息就不要展示。例如:一个函数需要统计一些数据的最大值,那即使你有一个现成的函数可以提供最大值、最小值、平均值等信息,你也不能直接使用这个函数来满足需求,而该只选择暴露最大值
- OOP 中的 interface,接口的定义要单一,不要让实现类实现自己不必要的功能。例如:我们用到了 MySQL 和 Redis,我们需要对 MySQL 进行调用用时记录,要对 Redis 每秒调用次数进行记录,这时我们需要定义两个 interface 记录 用时 和 QPS,而不是定义一个具有两个功能的接口
接口隔离的意义是什么?
意义在于对功能和信息的封装,避免出现一个功能强大的接口,以至于这个接口可以满足很多个需求。如果存在这样一个接口,当这个接口有变动的时候,你需要考虑所有调用这个接口的地方,这增加了我们维护的成本。并且,这个抽象是不合适的
依赖反转原则
什么是依赖反转原则?
要理解依赖反转原则,首先要理解什么是依赖反转:
依赖反转是一种设计思想,通常用来指导框架的设计。在通常情况下,程序员编写的代码需要自己控制相关的全部流程,例如创建、调用、销毁等。而在使用框架之后,一部分的控制逻辑将会交给框架来实现,程序员只需要在框架的预留点中编写自己的代码即可。
在这个过程中,对于代码的控制权从程序员转移到了框架手中,这种控制权的反转就是依赖反转。
有了这个定义,我们就可以更好地理解依赖反转原则了:
高层模块不要依赖低层模块,高层和低层应该通过抽象来相互依赖。抽象不要依赖具体实现细节,实现细节要依赖抽象
如何理解依赖反转原则?
还记得之前说到的协议吗?在我的理解中,依赖反转出了要求低层进行抽象,还要求高层和低层的代码有一个依赖的协议。
我习惯将一个接口看做抽象,那么如何协调不同层级的接口,就要约定一份协议。
我们可以看一看争哥在《设计模式之美》中举的例子:
Tomcat 是运行 Java Web 应用程序的容器。我们编写的 Web 应用程序代码只需要部署在 Tomcat 容器下,便可以被 Tomcat 容器调用执行。按照之前的划分原则,Tomcat 就是高层模块,我们编写的 Web 应用程序代码就是低层模块。Tomcat 和应用程序代码之间并没有直接的依赖关系,两者都依赖同一个“抽象”,也就是 Servlet 规范。Servlet 规范不依赖具体的 Tomcat 容器和应用程序的实现细节,而 Tomcat 容器和应用程序依赖 Servlet 规范。
启示
- 依赖倒置原则看似是要让高层次的模块控制低层次的模块,实际上是在规范低层次模块的设计
- 对于框架的设计来说,要将相关的接口设计的足够抽象、通用,设计的时候需要考虑各种种类和场景
- 依赖的倒置,可以让低层模块容易扩展,方便低层的替换
- 使用接口完成抽象,使用协议来约束和调用接口
KISS原则
什么是KISS原则?
- KISS 的英文是 Keep It Simple and Stupid. 整体思想是保持(代码)简单
符合KISS原则的代码会很易读,好维护
如何理解KISS原则?
很多人简单地认为代码越短越符合 KISS 原则,这其实是不正确的。
例如,下面的代码用三种方式实现了校验 IP 地址是否合法的功能
// 第一种实现方式: 使用正则表达式
public boolean isValidIpAddressV1(String ipAddress) {
if (StringUtils.isBlank(ipAddress)) return false;
String regex = "^(1\\d{2}|2[0-4]\\d|25[0-5]|[1-9]\\d|[1-9])\\."
+ "(1\\d{2}|2[0-4]\\d|25[0-5]|[1-9]\\d|\\d)\\."
+ "(1\\d{2}|2[0-4]\\d|25[0-5]|[1-9]\\d|\\d)\\."
+ "(1\\d{2}|2[0-4]\\d|25[0-5]|[1-9]\\d|\\d)$";
return ipAddress.matches(regex);
}
// 第二种实现方式: 使用现成的工具类
public boolean isValidIpAddressV2(String ipAddress) {
if (StringUtils.isBlank(ipAddress)) return false;
String[] ipUnits = StringUtils.split(ipAddress, '.');
if (ipUnits.length != 4) {
return false;
}
for (int i = 0; i < 4; ++i) {
int ipUnitIntValue;
try {
ipUnitIntValue = Integer.parseInt(ipUnits[i]);
} catch (NumberFormatException e) {
return false;
}
if (ipUnitIntValue < 0 || ipUnitIntValue > 255) {
return false;
}
if (i == 0 && ipUnitIntValue == 0) {
return false;
}
}
return true;
}
// 第三种实现方式: 不使用任何工具类
public boolean isValidIpAddressV3(String ipAddress) {
char[] ipChars = ipAddress.toCharArray();
int length = ipChars.length;
int ipUnitIntValue = -1;
boolean isFirstUnit = true;
int unitsCount = 0;
for (int i = 0; i < length; ++i) {
char c = ipChars[i];
if (c == '.') {
if (ipUnitIntValue < 0 || ipUnitIntValue > 255) return false;
if (isFirstUnit && ipUnitIntValue == 0) return false;
if (isFirstUnit) isFirstUnit = false;
ipUnitIntValue = -1;
unitsCount++;
continue;
}
if (c < '0' || c > '9') {
return false;
}
if (ipUnitIntValue == -1) ipUnitIntValue = 0;
ipUnitIntValue = ipUnitIntValue * 10 + (c - '0');
}
if (ipUnitIntValue < 0 || ipUnitIntValue > 255) return false;
if (unitsCount != 3) return false;
return true;
}
很明显,使用正则表达式的代码最短,但实际上可能是阅读起来最费劲的,因为它要求阅读者了解正则表达式的相关知识
实际上,个人感觉第二种方式是最好的
有哪些准则可以帮助我们写出简单的代码?
KISS 原则的判定标准很模糊,我们不妨给一些可供参考的准则(仅供参考):
- 不要用同事不懂的技术写代码,如上面的正则
- 不要重复造轮子,善用已有的工具库
- 不要过度使用奇技淫巧,不要写那种像是炫技一样但是不易读的代码
- 需要考虑性能和场景,例如我们有大文本的搜索需求,使用 KMP 算法会比暴力查找要好
- 如果一件事当前不需要做,那就不要做。(当然,你可以预留扩展点)
DRY 和 复用性
什么是DRY原则?
- Don’t Repeat Yourself. 不要重复你自己
如何理解重复?
DRY 让我们不要写重复的代码,那么改如何理解这里所说的重复呢?没有长得一样的代码就是没有重复吗?两段长得一样的代码就一定是重复的了吗?其实不一定,我们举个例子:
我们假设对用户的注册有一定的限制条件,例如限制用户名和密码都只能使用 小写字母 或 数字。那么,这两个字段的检查逻辑应该是一样的,如果我们写两个函数: isVaildUsername 和 isVaildPassword,这两个函数的代码完全一样,这是否重复了呢?是否要写一个名为 isVaildUsernameOrPassword?
实际上,即使两个代码完全一样,我们也有必要写两个函数分别检查用户名和密码,因为他们的代码相同,但是负责的职责是不同的。如果我们现在要求密码为 字母+数字 的组合形式,就可以只修改 isVaildPassword 的代码。
如果你想实现复用,完全可以进行更底层的封装,例如编写一个 onlyContainsLetters 函数
实际上,我们应该避免的是功能语义的重复和代码执行的重复
- 功能语义重复:我们有两个函数,isVaildPassword 和 checkPasswordVaild,两个函数都是检测密码是否合法,那这两个函数即使命名和代码不同,我们也要消除这种情况。当然,如果两者的应用场景不同,两个函数的存在可能是合理的
- 代码执行重复:我们在做一个功能的时候,很可能重复调用了同一个函数,这种情况就是代码执行重复。这种情况是需要消除的
如何提高代码的复用性?
有一些手段你可以提升代码的复用性,供参考:
- 职责单一,高内聚:职责单一可以让我们很方便地把一段相关的逻辑独立出来,当我们需要这段逻辑的时候,可以直接使用这个职责单一的类
- 降低耦合:如果一段代码的耦合度很高,有很多功能,如果我们想要使用其中的一部分代码,需要将这个耦合解耦
- 业务代码和非业务代码分离:业务代码常常指向性很强,难以复用。而非业务代码常常更加通用
- 通用代码下沉:按照分层的角度来说,越底层的代码通用性越高,我们可以将需要复用的代码放在更下层以方便调用。实际上,开发上通常是推荐同层调用和高层调用低层,而要避免低层调用高层,所以越低层越通用这个原则是非常有意义的
网友评论