设计要有度——KISS、YAGNI和简单设计原则

《设计要有度——KISS、YAGNI和简单设计原则》源站链接,阅读体验更佳~

我们前面介绍的单一职责原则和DRY原则,我认为是我们需要无条件去遵循的,这两个设计原则放到任何场景下都是不可或缺的,同时这两个设计原则也是让我们的代码一直保持高质量的基础。而且其他的设计原则以及设计模式中,可能或多或少都会有它们的影子。

但是我们在之前的文章中不止一次提到过,软件设计是对抗软件规模提升的算法,而算法是不能乱用的,需要因地制宜。软件设计也是一样的,我们不能为了设计而设计,软件设计只是手段,提高代码质量才是目的,我们不能本末倒置,这就要求我们设计要有度。

其实我们在软件设计中最容易犯的错误并不是设计不足,反而是过度设计,尤其是经验不是特别丰富的开发者在初学软件设计的时候,都会有用力过猛的倾向,在实现某个功能的时候,往往会想的太多,给自己加戏,为将来有可能会出现的某些特性付出过多的设计成本,造成代码的过度泛化

如何避免过度设计是每个学习软件设计的人都需要耐心锤炼的能力,所以在软件设计的原则中也出现了一些能够帮助我们把握软件设计的度的设计原则——KISS和YAGNI原则。这两个设计原则从不同的方面阐述了如何把握好设计的度,避免过度设计。这篇文章中,我们将会介绍两个同样非常基础的设计原则。

其中的KISS原则比较经典,大家应该都听说过,而YAGNI大家可能听说的就比较少了,但是它理解起来也不难。

理解这两个原则时候,经常会有一个共同的问题,那就是,看一眼就感觉懂了,但深究的话,又有很多细节问题不是很清楚。比如,怎么理解 KISS 原则中“简单”两个字?什么样的代码才算“简单”?怎样的代码才算“复杂”?如何才能写出“简单”的代码?YAGNI 原则跟 KISS 原则说的是一回事吗?接下来我们就来介绍一下这两个软件设计原则。

KISS原则

首先就是KISS原则,它的英文描述有好几个版本,比如:

  • Keep it simple and Stupid.
  • Keep it simple and short.
  • Keep it simple and straightforward.

其中流传最为广泛的就是第一种说法。不过,仔细观察一下我们就会发现,所有版本的描述意思其实是差不多的,翻译成中文就是:尽量保持简单。

KISS原则算是一个非常万金油的设计原则,很多程序员都知道这条原则,然而,很少人知道这条原则其实是出自美国海军。所以,它的适用范围远比我们以为的程序员社区要广泛得多。无论是制定一个目标,还是设计一个产品,抑或是管理一个公司,我们都可以用 KISS 作为一个统一的原则指导自己的工作。而我们这里,主要针对的则是代码编写的场景。

我们知道,代码的可读性和可维护性是衡量代码质量非常重要的两个标准,而KISS就是维持代码可读性和可维护性的重要手段。代码足够简单,也就意味着很容易读懂,bug 比较难隐藏。即便出现 bug,修复起来也比较简单。

什么样的代码才是“Simple and Stupid”的

KISS原则中只是提到要让我们的代码设计尽量保持“Simple and Stupid”,但是却没有提出什么样的代码才是“Simple and Stupid”的,更没有给出明确的方法论。而什么样的代码才是简单的这个问题本身也是一个非常主观的问题,不同的开发者编码水平不同和编码习惯都不尽相同,所以在面对同样的代码的时候,对代码简单程度的认知可能也是不同的。

正是因为这种主观性,导致我们很难定量地描述一段代码是否符合KISS原则。通常我们会认为代码行数越少的代码越容易符合KISS,逻辑复杂的代码更容易违反KISS,但是这样的说法太过绝对了,我们也不止一次提到过,评价一段代码的质量的时候一定要结合代码的场景进行分析,下面我们就来举两个简单的例子。

代码行数越少就越简单吗?

我们先看下面的代码:

// 第一种实现方式: 使用正则表达式
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;
}

上面的Java代码中有三个函数,它们的功能是完全一样的,都是用来校验输入的字符串是不是一个合法的IP地址。一个合法的 IP 地址由四个数字组成,并且通过“.”来进行分割。每组数字的取值范围是 0~255。第一组数字比较特殊,不允许为 0。

上面的三个函数都实现了IP地址校验的功能,但是它们采用了完全不同的实现方式。V1版本的函数使用了正则表达式,V2版本的函数使用了现有的函数库实现,而V3版本则直接硬编码实现,从代码量上来看,这三个函数的代码量是依次增加的。那么对比这三个函数,哪个函数更加符合KISS原则呢?

第一种实现方式利用了正则表达式,它只用了三个语句就实现了IP地址校验的功能,它的代码量是最少的,那它是不是最符合KISS原则的呢?答案是否定的。它的代码量虽然不大,但是它使用了正则表达式,而正则表达式本身其实是比较难读的,而且正则表达式本身是比较难以书写的,单是写出没有bug的正则表达式就不是一件特别容易的事,而如果我们对正则表达式的理解不是特别深刻,那么还很有可能写出性能有问题的正则表达式。可以看到,引入了正则表达式之后,虽然我们把校验规则都浓缩到了这一行正则表达式中,代码量得到了压缩,但是代码的可读性和可维护性其实是受到了负面的影响的。

第二种实现方式使用了 StringUtils 类、Integer 类提供的一些现成的工具函数,来处理 IP 地址字符串。第三种实现方式,不使用任何工具函数,而是通过逐一处理 IP 地址中的字符,来判断是否合法。从代码行数上来说,这两种方式差不多。但是,第三种要比第二种更加有难度,更容易写出 bug。从可读性上来说,第二种实现方式的代码逻辑更清晰、更好理解。所以,在这两种实现方式中,第二种实现方式更加“简单”,更加符合 KISS 原则。

不过,你可能会说,第三种实现方式虽然实现起来稍微有点复杂,但性能要比第二种实现方式高一些啊。从性能的角度来说,选择第三种实现方式是不是更好些呢?

在回答这个问题之前,我先解释一下,为什么说第三种实现方式性能会更高一些。一般来说,工具类的功能都比较通用和全面,所以,在代码实现上,需要考虑和处理更多的细节,执行效率就会有所影响。而第三种实现方式,完全是自己操作底层字符,只针对 IP 地址这一种格式的数据输入来做处理,没有太多多余的函数调用和其他不必要的处理逻辑,所以,在执行效率上,这种类似定制化的处理代码方式肯定比通用的工具类要高些。

不过,尽管第三种实现方式性能更高些,但我还是更倾向于选择第二种实现方法。那是因为第三种实现方式实际上是一种过度优化。除非 isValidIpAddress() 函数是影响系统性能的瓶颈代码,否则,这样优化的投入产出比并不高,增加了代码实现的难度、牺牲了代码的可读性,性能上的提升却并不明显。

逻辑复杂就一定违反KISS原则吗?

刚刚我们提到,并不是代码行数越少就越“简单”,还要考虑逻辑复杂度、实现难度、代码的可读性等。那如果一段代码的逻辑复杂、实现难度大、可读性也不太好,是不是就一定违背 KISS 原则呢?在回答这个问题之前,我们先来看下面这段代码:

// KMP algorithm: a, b分别是主串和模式串;n, m分别是主串和模式串的长度。
public static int kmp(char[] a, int n, char[] b, int m) {
  int[] next = getNexts(b, m);
  int j = 0;
  for (int i = 0; i < n; ++i) {
    while (j > 0 && a[i] != b[j]) { // 一直找到a[i]和b[j]
      j = next[j - 1] + 1;
    }
    if (a[i] == b[j]) {
      ++j;
    }
    if (j == m) { // 找到匹配模式串的了
      return i - m + 1;
    }
  }
  return -1;
}

// b表示模式串,m表示模式串的长度
private static int[] getNexts(char[] b, int m) {
  int[] next = new int[m];
  next[0] = -1;
  int k = -1;
  for (int i = 1; i < m; ++i) {
    while (k != -1 && b[k + 1] != b[i]) {
      k = next[k];
    }
    if (b[k + 1] == b[i]) {
      ++k;
    }
    next[i] = k;
  }
  return next;
}

上面的代码是KMP字符串匹配算法的一个实现。这段代码逻辑复杂、实现难度大、可读性也不是很好,但是它却并不违反KISS原则。为什么呢?

KMP 算法以快速高效著称。当我们需要处理长文本字符串匹配问题(几百 MB 大小文本内容的匹配),或者字符串匹配是某个产品的核心功能(比如 Vim、Word 等文本编辑器),又或者字符串匹配算法是系统性能瓶颈的时候,我们就应该选择尽可能高效的 KMP 算法。而 KMP 算法本身具有逻辑复杂、实现难度大、可读性差的特点。本身就复杂的问题,用复杂的方法解决,并不违背 KISS 原则。

不过,平时的项目开发中涉及的字符串匹配问题,大部分都是针对比较小的文本。在这种情况下,直接调用编程语言提供的现成的字符串匹配函数就足够了。如果非得用 KMP 算法、BM 算法来实现字符串匹配,那就真的违背 KISS 原则了。也就是说,同样的代码,在某个业务场景下满足 KISS 原则,换一个应用场景可能就不满足了。

如何写出符合KISS原则的代码

那么我们到底如何才能写出符合KISS原则的代码呢?下面我根据自己日常的开发简单总结了几点:

  • 不要使用比较冷门的技术来实现代码。冷门意味着掌握的人不会很多。
  • 不要过度优化,更不要炫技、炫技一时爽,维护火葬场。比如不要使用语言中过于高级的语法。不要过度使用一些奇技淫巧(比如,位运算代替算术运算、复杂的条件语句代替 if-else、使用一些过于底层的函数等)来优化代码,牺牲代码的可读性。
  • 不要重复造轮子,要善于使用已经有的工具类库。经验证明,自己去实现这些类库,出 bug 的概率会更高,维护的成本也比较高。

实际上,代码是否足够简单是一个挺主观的评判。同样的代码,有的人觉得简单,有的人觉得不够简单。而往往自己编写的代码,自己都会觉得够简单。所以,评判代码是否简单,还有一个很有效的间接方法,那就是 code review。如果在 code review 的时候,同事对你的代码有很多疑问,那就说明你的代码有可能不够“简单”,需要优化啦。

其次,一定要摆正自己的思想,不要觉得简单的东西就没有技术含量。实际上,越是能用简单的方法解决复杂的问题,越能体现一个人的能力。就像我们写文章一样,过于追求华丽辞藻的文章反而会变成不接地气的下下之作。

YAGNI原则

上面,我们已经简单介绍了一下KISS原则,现在,我们再来介绍一下YAGNI原则,顺便思考一个问题——“YAGNI和KISS这两个原则说的是同一件事吗?”

YAGNI 原则的英文全称是:You aren’t gonna need it。直译就是:你不会需要它。这条原则也算是万金油了。当用在软件开发中的时候,它的意思是:不要去设计当前用不到的功能;不要去编写当前用不到的代码。实际上,这条原则的核心思想就是:不要做过度设计。

比如,我们的系统暂时只用 Redis 存储配置信息,以后可能会用到 ZooKeeper。根据 YAGNI 原则,在未用到 ZooKeeper 之前,我们没必要提前编写这部分代码。当然,这并不是说我们就不需要考虑代码的扩展性。我们还是要预留好扩展点,等到需要的时候,再去实现 ZooKeeper 存储配置信息这部分代码。

我们对上面例子的描述虽然简单,但是却可以体现出YAGNI原则的核心思想——不要想着一步到位,好的代码架构是迭代出来的,小步快走才是王道。同时YAGNI原则也并不是完全禁止我们对代码进行泛化,而是泛化要合理,比如上面的存储配置文件的例子,虽然目前的需求只是用Redis来存储配置信息,但是我们也不能直接把实现写死,而是应该把存储配置文件的功能进行一定的封装,为将来有可能的变化留好扩展点。

从刚刚的分析我们可以看出,YAGNI 原则跟 KISS 原则并非一回事儿。KISS 原则讲的是“如何做”的问题(尽量保持简单),而 YAGNI 原则说的是“要不要做”的问题(当前不需要的就不要做)。

简单设计原则

行文至此,我们已经介绍了分离关注点、单一职责原则、DRY原则、KISS原则和YAGNI原则,而我个人认为,这些原则综合起来就奠定了我们进行软件设计的基调——简单设计。

简单设计原则来自极限编程社区,它的提出者是Kent Beck(软件开发方法学的泰山北斗,是最早研究软件开发的模式和重构的人之一,是敏捷开发的开创者之一,更是极限编程和测试驱动开发的创始人,同时还是JUnit的作者,对当今世界的软件开发影响深远)。

简单设计之所以叫简单设计,因为它只包含了四条规则:

  • 通过所有测试;
  • 消除重复;
  • 表达出程序员的意图;
  • 让类和方法的数量最小化。

这 4 条规则看起来很简单,但想做到,对于很多人来说,是一个非常大的挑战。我们来逐一地看下每条规则。

第 1 条是保证系统能够按照预期工作,其实,这一点对于大多数项目而言,已经是很高的要求了。怎么才能知道系统按照预期工作,那就需要有配套的自动化测试。大多数项目并不拥有自己的自动化测试,更何况是在开发阶段使用的单元测试,尤其是还得保证测试覆盖了大多数场景。

在极限编程(XP)实践中,想要拥有这种测试,最好是能够以测试驱动开发(Test Driven Development,简称 TDD)的方式工作。而你要想做好 TDD,最根本的还是要懂设计,否则,你的代码就是不可测的,想给它写测试就是难上加难的事情。

到目前为止,我们也还没有介绍过测试对于软件设计的重要性,这一点我会在后面介绍重构相关的文章中进行补充。

第 2 条,消除重复,正如 DRY 原则所说的,你得能够发现重复,这需要你对分离关注点有着深刻的认识。

第 3 条,表达出程序员的意图,我们需要编写有表达性的代码,这也需要你对“什么是有表达性的代码”有认识,其实这也是KISS原则的基本要求。

第 4 条,让类和方法的数量最小化,则告诉我们不要过度设计,除非你已经看到这个地方必须要做一个设计,比如,留下适当的扩展点,否则,就不要做,而这正是YAGNI原则的要求。

但是,有一点我们需要知道,能做出过度设计的前提,是已经懂得了设计的各种知识,这时才需要用简单设计的标准对自己进行约束。所以,所谓的简单设计,对大多数人而言,并不“简单”。简单设计,是 Kent Beck 这样的大师级程序员在经历了足够的积累,返璞归真之后提出的设计原则,它确实可以指导我们的日常工作,但前提是,我们需要把基础打牢。

总结

上文我们简单介绍了KISS原则和YAGNI原则,其中KISS原则占用的篇幅比YAGNI原则要多得多,这是为什么呢?

我们上面也提到了,KISS原则是指导我们如何做的,而YAGNI原则是指导我们要不要做的。**KISS是在实现某个功能是时候致力于构建简单直接而有效的方案,而YAGNI原则讨论的则是是否实现这个功能。**也就是说,KISS原则会涉及更多的编码方法论的讨论,所以它的篇幅长一些也是无可厚非的。

同时,这篇文章中我们简单介绍了一下极限编程社区中提出的简单设计原则,它其实是我们进行软件设计的基调,同时也综合了我们前面介绍的分离关注点、单一职责原则、DRY原则、KISS原则和YAGNI原则。

**简单设计虽然称为简单设计,但其实是对软件设计的最高要求,这需要我们具有一定的功底。**我之所以在介绍设计原则相关的文章中一开始就抛出单一职责原则、DRY原则、KISS原则以及YAGNI原则,是想要提醒自己,这几个基本原则是几乎在所有的软件设计场景中都需要考虑和遵循的,它是保证我们软件设计质量的基本原则。而我们在后面介绍其他的软件设计原则的时候,可能或多或少都会跟着几个基本的设计原则有所关联。

以上,就是我对KISS原则、YAGNI原则和简单设计原则的基本理解,感谢你耐心读完。本人深知自己技术水平和表达能力有限,文章中一定存在不足和错误,欢迎与我进行交流(laomst@163.com),跟我一起讨论,修改文中的不足和错误,感谢您的阅读。

参考资料

[1]王争.设计模式之美[M].北京:人民邮电出版社,2022.6:100-104.

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

劳码识途

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值