设计原则拾遗

2021-06-14  本文已影响0人  天命_风流

单一职责原则

如何理解单一职责原则?

如果一个类有多个功能,会增加它对上游的依赖和下游对它的依赖。具体来说,当你要修改修改这个类的时候,你需要更多地考虑这部分代码可能带来的影响,这是典型的“牵一发而动全身”,这会让我们的修改变得棘手
你应当设计一个功能单一的类,从而可以为上游提供一个更加清晰的抽象接口。
如果你有多个功能需求,你可以考虑组合这些类,而不是将所有功能写到一个类中

如何判断一个类的职责是否足够单一?

这个判断没有统一的标准,它更多地依赖程序员的直觉,即使如此,我们也需要知道程序员应该关注哪些方向
所以,给出下面的一些判断的参考条件:

类的职责是否设计的越单一越好?

当然不是!设计的原则是高内聚低耦合,单一的类可以降低耦合度,同样也会降低代码的内聚性
先对类进行抽象,基础的、共有的部分就不要再拆了

对扩展开放、对修改关闭

什么是开闭原则?

为什么需要这个原则,解决了什么问题,又会带来哪些问题?

这个原则可以提升代码的扩展性和稳定性,如果你的代码符合这个原则,在你添加功能的时候,会有一些表现:

  1. 你不会破坏原有的代码的运行
  2. 你不会破坏原有的单元测试

实际上,绝大多数设计模式都是为了解决扩展性而存在的,而这些模式都遵循这个原则
但是,这个要实现这个原则是有代价的,我们在提高代码扩展性(例如使用了某个设计模式)的同时,必然会导致代码可读性的下降,引入一些额外的代码,这些代码可能是:

  1. 一个用于传递参数的类
  2. 一个用于初始化的方法
  3. 可以适应扩展的方法,例如迭代

如果你的变动没有特别频繁,让代码保持简单可能是更好的选择

如何做到开闭原则?

这非常难,在编码的时候你需要时刻具备三个意识:

里式替换原则

什么是里式替换原则?

如何理解这里说的协议?

我认为这里所说的协议是一个非常重要的概念
在设计类的时候,我们要对类的职责进行抽象,抽象出的产物可能是接口
而协议,则是如何使用这些接口的约定。
举个例子
接口A 提供用户查询的能力,它的接口约定是:入参一个 userID,出参一个 userInfo 类,当输入的 userID 有问题的时候会怎么怎么样...
接口B 需要接收 userInfo 做一些其他处理,如果遇到xx问题会怎么这么样...

你会看到,这些接口需要完成自己的功能,并且在出现特定的问题的时候执行特定的逻辑,这部分逻辑就需要我们用协议来约定了

违反里式替换原则的表现有哪些?

里式替换是一个非常宽松的原则,使用起来也非常简单,在很多情况下,子类继承父类,实现多态的过程,就遵守了里式替换原则。但是,这也不是绝对的,胡乱重载会破坏里式替换,例如:

接口隔离原则

什么是接口隔离原则?

在这里,对于“接口”存在三种定义:

  1. 一组 API 的集合
  2. 单个 API 的接口或函数
  3. OOP 中的接口,即 interface

不论我们对于接口的定义是什么样的,这个原则的思想都是不变的:接口只依赖自己需要的依赖,对于不需要的则不要依赖

针对不同的接口定义,我们可以细化出不同的要求(但是他们的思想都是一样的)

  1. 对于一组 API,只向他们暴露功能必须的代码。例如:对用户的操作不应该暴露在普通 userService 中,而应该选择性地暴露给更高权限的 userManage 中
  2. 对于单个 API 的接口或函数,提供给这个接口或函数他们必要的信息,对于不要的信息就不要展示。例如:一个函数需要统计一些数据的最大值,那即使你有一个现成的函数可以提供最大值、最小值、平均值等信息,你也不能直接使用这个函数来满足需求,而该只选择暴露最大值
  3. OOP 中的 interface,接口的定义要单一,不要让实现类实现自己不必要的功能。例如:我们用到了 MySQL 和 Redis,我们需要对 MySQL 进行调用用时记录,要对 Redis 每秒调用次数进行记录,这时我们需要定义两个 interface 记录 用时 和 QPS,而不是定义一个具有两个功能的接口

接口隔离的意义是什么?

意义在于对功能和信息的封装,避免出现一个功能强大的接口,以至于这个接口可以满足很多个需求。如果存在这样一个接口,当这个接口有变动的时候,你需要考虑所有调用这个接口的地方,这增加了我们维护的成本。并且,这个抽象是不合适的

依赖反转原则

什么是依赖反转原则?

要理解依赖反转原则,首先要理解什么是依赖反转:
依赖反转是一种设计思想,通常用来指导框架的设计。在通常情况下,程序员编写的代码需要自己控制相关的全部流程,例如创建、调用、销毁等。而在使用框架之后,一部分的控制逻辑将会交给框架来实现,程序员只需要在框架的预留点中编写自己的代码即可。
在这个过程中,对于代码的控制权从程序员转移到了框架手中,这种控制权的反转就是依赖反转。

有了这个定义,我们就可以更好地理解依赖反转原则了:
高层模块不要依赖低层模块,高层和低层应该通过抽象来相互依赖。抽象不要依赖具体实现细节,实现细节要依赖抽象

如何理解依赖反转原则?

还记得之前说到的协议吗?在我的理解中,依赖反转出了要求低层进行抽象,还要求高层和低层的代码有一个依赖的协议。
我习惯将一个接口看做抽象,那么如何协调不同层级的接口,就要约定一份协议。
我们可以看一看争哥在《设计模式之美》中举的例子:

Tomcat 是运行 Java Web 应用程序的容器。我们编写的 Web 应用程序代码只需要部署在 Tomcat 容器下,便可以被 Tomcat 容器调用执行。按照之前的划分原则,Tomcat 就是高层模块,我们编写的 Web 应用程序代码就是低层模块。Tomcat 和应用程序代码之间并没有直接的依赖关系,两者都依赖同一个“抽象”,也就是 Servlet 规范。Servlet 规范不依赖具体的 Tomcat 容器和应用程序的实现细节,而 Tomcat 容器和应用程序依赖 Servlet 规范。

启示

  1. 依赖倒置原则看似是要让高层次的模块控制低层次的模块,实际上是在规范低层次模块的设计
  2. 对于框架的设计来说,要将相关的接口设计的足够抽象、通用,设计的时候需要考虑各种种类和场景
  3. 依赖的倒置,可以让低层模块容易扩展,方便低层的替换
  4. 使用接口完成抽象,使用协议来约束和调用接口

KISS原则

什么是KISS原则?

符合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 原则的判定标准很模糊,我们不妨给一些可供参考的准则(仅供参考):

  1. 不要用同事不懂的技术写代码,如上面的正则
  2. 不要重复造轮子,善用已有的工具库
  3. 不要过度使用奇技淫巧,不要写那种像是炫技一样但是不易读的代码
  4. 需要考虑性能和场景,例如我们有大文本的搜索需求,使用 KMP 算法会比暴力查找要好
  5. 如果一件事当前不需要做,那就不要做。(当然,你可以预留扩展点)

DRY 和 复用性

什么是DRY原则?

如何理解重复?

DRY 让我们不要写重复的代码,那么改如何理解这里所说的重复呢?没有长得一样的代码就是没有重复吗?两段长得一样的代码就一定是重复的了吗?其实不一定,我们举个例子:
我们假设对用户的注册有一定的限制条件,例如限制用户名和密码都只能使用 小写字母 或 数字。那么,这两个字段的检查逻辑应该是一样的,如果我们写两个函数: isVaildUsername 和 isVaildPassword,这两个函数的代码完全一样,这是否重复了呢?是否要写一个名为 isVaildUsernameOrPassword?
实际上,即使两个代码完全一样,我们也有必要写两个函数分别检查用户名和密码,因为他们的代码相同,但是负责的职责是不同的。如果我们现在要求密码为 字母+数字 的组合形式,就可以只修改 isVaildPassword 的代码。
如果你想实现复用,完全可以进行更底层的封装,例如编写一个 onlyContainsLetters 函数

实际上,我们应该避免的是功能语义的重复和代码执行的重复

如何提高代码的复用性?

有一些手段你可以提升代码的复用性,供参考:

上一篇 下一篇

猜你喜欢

热点阅读