image.png

许多学生在维护旧项目时遇到过复杂的业务逻辑嵌套在深层的 if-else 语句中。面对这样的乱象,简单地增量修改通常只会增加复杂性和降低可读性。那么,有没有固定的套路可以整理这些代码呢?这里分享三种简单而常见的重构方法。

什么是意大利面条代码?

所谓“意大利面条代码”在处理复杂业务过程时很常见。它通常具有以下特点:

  • 内容冗长
  • 结构混乱
  • 嵌套深

我们知道,主流编程语言都有函数或方法来组织代码。对于意大利面条代码,我们可以将其视为满足这些特点的函数。根据语言语义的不同,可以将其分为两种基本类型:

if…if 类型

这种代码结构看起来像这样:

function demo(a, b, c) {
  if (f(a, b, c)) {
    if (g(a, b, c)) {
      // ...
    }
    // ...
    if (h(a, b, c)) {
      // ...
    }
  }

  if (j(a, b, c)) {
    // ...
  }

  if (k(a, b, c)) {
    // ...
  }
}

其流程图如下:

image.png

通过自上而下嵌套 if 语句,单个函数内的控制流不断增长。不要以为控制流增长时,复杂性只是线性增加。我们知道,函数处理数据,每个 if 内通常都有数据处理逻辑。所以即使没有嵌套,如果有 3 个这样的 if 段,那么根据每个 if 是否执行,会有 2 ^ 3 = 8 种可能的数据状态。如果有 6 段,则会有 2 ^ 6 = 64 种状态。因此,随着项目规模的扩大,调试函数变得指数级困难!在数量级上,这与《人月神话》中分享的经验一致。

else if…else if 类型

这种代码控制流也很常见,看起来像这样:

function demo(a, b, c) {
  if (f(a, b, c)) {
    if (g(a, b, c)) {
      // ...
    } else if (h(a, b, c)) {
      // ...
    }
    // ...
  } else if (j(a, b, c)) {
    // ...
  } else if (k(a, b, c)) {
    // ...
  }
}

其流程图如下:

image.png

else if 最终只会进入其中一个分支,因此不会像前面提到的那样出现组合爆炸。然而,在深层嵌套中,复杂性依然很高。假设每层嵌套有 3 个 else if 语句,有 3 层嵌套,则会有 3 ^ 3 = 27 种可能的出口。如果每个出口对应一种处理数据的方式,那么在一个函数内封装这么多逻辑违背了单一职责原则。而且,这两种类型可以无缝结合,进一步增加复杂性和降低可读性。

但为什么在框架和库如此先进的时代,我们仍然经常遇到这样的代码?在我看来,可复用的模块确实帮助我们减少了模板代码;然而,不管业务逻辑封装得多好,开发者仍需要编写它。即使是简单的 if-else 语句,也能成倍增加控制流的复杂性。从这个角度看,如果没有基本的编程技巧,不管多快掌握优秀的框架和库,你可能仍然会写出混乱的项目。

重构策略

在上文中,我们讨论了两种面条代码,并定量展示了它们如何成倍增加控制流的复杂性。然而,在现代编程语言中,这种复杂性实际上是完全可控的。以下是列举的几种编程技巧来改善面条代码的场景。

image.png

基本情况

对于 if…if 类型的面条代码,可以通过基本函数拆分来解决复杂性增长的问题。下图中每个绿色框代表一个拆分出的新函数:

由于现代编程语言中放弃了 goto,无论控制流多复杂,函数体内代码的执行顺序总是自上而下的。因此,我们完全可以从上到下逐步将单体大函数拆分为多个小函数而不改变控制流逻辑,然后一个个调用它们。这是经验丰富的同事常用的技巧,具体代码实现这里不再详细阐述。

需要注意的是,这种方法中所谓的不改变控制流逻辑是指不需要改变业务逻辑执行的方式,只是将代码移出去并包裹一层函数。有些同学可能认为这种方法只是治标不治本——它只是将一段长面条切成几段短面条,没有本质区别。

但真的是这样吗?通过这种方法,我们可以将具有 64 种状态的大函数拆分为 6 个只返回 2 种状态的小函数,以及一个逐一调用它们的主函数。这样,每个函数的复杂性增长率从指数级降低为线性。

通过这种方式,我们解决了 if…if 类型的面条代码;那么 else if…else if 类型的呢?

查找表

对于 “else if…else if” 类型的面条代码,一种最简单的重构策略是使用所谓的查找表。它以键值对的形式封装每个 else if 中的逻辑:

const rules = {
  x: function (a, b, c) { /* ... */ },
  y: function (a, b, c) { /* ... */ },
  z: function (a, b, c) { /* ... */ }
};

function demo(a, b, c) {
  const action = determineAction(a, b, c);
  return rules[action](a, b, c);
}

每个 else if 中的逻辑被重写为一个独立的函数,然后我们可以按以下方式拆分过程:

image.png

对于本身支持反射的脚本语言来说,这是一个相对简单的技巧。然而,对于更复杂的 else if 条件,这种方法会将控制流复杂性重新集中到 determineAction 中,确定该走哪个分支。有没有更好的方法来处理这个问题呢?

责任链模式

在上文中,查找表是通过键值对实现的。当每个分支是一个简单判断时,如 else if (x === ‘foo’)foo 可以作为重构集合的键。然而,如果每个 else if 分支包含复杂的条件判断并且需要特定的执行顺序,我们可以使用责任链模式来更好地重构这种逻辑。

对于 else if,需要注意每个分支是自上而下判断的,最终只会执行其中一个。这意味着我们可以通过存储一个“判断规则”的数组来实现这种行为。如果一个规则匹配,则执行该规则对应的分支。我们称这样的数组为“责任链”,其模式下的执行过程如图所示:

image.png

在代码实现中,我们可以通过责任链数组定义等价于 else if 的规则。

const rules = [
  {
    match: function (a, b, c) { /* ... */ },
    action: function (a, b, c) { /* ... */ }
  },
  {
    match: function (a, b, c) { /* ... */ },
    action: function (a, b, c) { /* ... */ }
  },
  {
    match: function (a, b, c) { /* ... */ },
    action: function (a, b, c) { /* ... */ }
  }
  // ...
]

rules 中的每项都具有 matchaction 属性。此时我们可以将原来的 else if 函数重写为遍历责任链数组:

function demo (a, b, c) {
  for (let i = 0; i < rules.length; i++) {
    if (rules[i].match(a, b, c)) {
      return rules[i].action(a, b, c)
    }
  }
}

当每个责任被匹配时,原函数将直接返回,这也完全符合 else if 的语义。这样,我们实现了将复杂的 else if 逻辑拆分为单独的部分。

结尾

面条代码往往出现在无脑的“粗暴、快速、猛烈”风格的开发中。许多 bug 修复是通过粗暴地在这里添加一个 if 并在多处返回语句来完成的,再加上缺乏注释,这很容易导致代码可读性降低和复杂性增加。

然而,解决这个问题其实并不复杂。这些示例之所以简单,基本上是因为强大的高级编程

首发于公众号 大迁世界,欢迎关注。📝 每周一篇实用的前端文章 🛠️ 分享值得关注的开发工具 ❓ 有疑问?我来回答

本文 GitHub https://github.com/qq449245884/xiaozhi 已收录,有一线大厂面试完整考点、资料以及我的系列文章。


王大冶
68k 声望104.9k 粉丝