美文网首页
设计原则拾遗

设计原则拾遗

作者: 天命_风流 | 来源:发表于2021-06-14 20:09 被阅读0次

单一职责原则

如何理解单一职责原则?

  • 不要设计功能大而全的类,要设计粒度小、功能单一的类

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

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

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

  • 类的代码行数、函数、属性过多
  • 类对上游的依赖过多,或被太多下游依赖
  • 你很难给你的类取一个非常合适的名字(因为你的类的职责定义的不够清晰)
  • 类中大量的方法都是集中操作类中的某几个属性,你可以考虑将这部分属性和方法拆出来

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

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

对扩展开放、对修改关闭

什么是开闭原则?

  • 当你要添加一个新的功能的时候,应该是在已有代码的基础上进行扩展,而不是修改已有的代码

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

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

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

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

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

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

如何做到开闭原则?

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

  • 扩展意识:这段代码是否会面临频繁的需求变更,不同业务是否需要提供不同的实现?如果是,那么如何做好相关的扩展?
  • 抽象意识:我写的这部分代码是否可以进行抽象?是否可以写一个抽象类或者使用一组接口来对逻辑进行抽象?如果是,那最好面向接口而非实现来编程
  • 封装意识:将业务中可变的,不稳定的东西封装起来(可能是封装成一个类),在封装的基础上对外部提供稳定的接口

里式替换原则

什么是里式替换原则?

  • 子类对象能够替换程序中父类出现的任何地方,并且保证原有程序的逻辑行为不变,以及正确性不被破坏
  • 人话:按照协议来设计

如何理解这里说的协议?

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

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

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

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

  • 子类违背了父类声明要实现的功能:父类提供一个按用户注册时间排序的方法,子类将这个方法修改成按照最后登录时间排序
  • 子类违背父类对输入、输出、异常 的约定:父类约定运行出错会返回空集合,而子类运行出错后却抛异常
  • 子类违背父类注释的说明:父类规定用户金额不能为负数,子类为VIP用户,允许透支一定额度。(此时可以修改父类注释)

接口隔离原则

什么是接口隔离原则?

  • 接口的调用者不应该被强迫依赖它不需要的接口

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

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

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

DRY 和 复用性

什么是DRY原则?

  • Don’t Repeat Yourself. 不要重复你自己

如何理解重复?

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

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

  • 功能语义重复:我们有两个函数,isVaildPassword 和 checkPasswordVaild,两个函数都是检测密码是否合法,那这两个函数即使命名和代码不同,我们也要消除这种情况。当然,如果两者的应用场景不同,两个函数的存在可能是合理的
  • 代码执行重复:我们在做一个功能的时候,很可能重复调用了同一个函数,这种情况就是代码执行重复。这种情况是需要消除的

如何提高代码的复用性?

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

  • 职责单一,高内聚:职责单一可以让我们很方便地把一段相关的逻辑独立出来,当我们需要这段逻辑的时候,可以直接使用这个职责单一的类
  • 降低耦合:如果一段代码的耦合度很高,有很多功能,如果我们想要使用其中的一部分代码,需要将这个耦合解耦
  • 业务代码和非业务代码分离:业务代码常常指向性很强,难以复用。而非业务代码常常更加通用
  • 通用代码下沉:按照分层的角度来说,越底层的代码通用性越高,我们可以将需要复用的代码放在更下层以方便调用。实际上,开发上通常是推荐同层调用和高层调用低层,而要避免低层调用高层,所以越低层越通用这个原则是非常有意义的

相关文章

网友评论

      本文标题:设计原则拾遗

      本文链接:https://www.haomeiwen.com/subject/wzsoeltx.html