2021-11-21
可否用数组 includes 来取代条件集联判断
最近写 bug 有看到类似下面的代码:
00if ([a, b].includes(type)) {
01  // 说明 type 值落在数组 [a, b] 中
02  // 逻辑上等价于 type === a || type === b
03}
这样绕一层不是偶然,问过相关写过的朋友,有的是因为这样可以绕过不友好的度量工具错杀这类横向可拓展的逻辑,有的是觉得这样更简短,可读性更高。

# 横向可拓展 vs 意大利面式

一般来说,正常思路写很难写出上面的 includes hack,更多是用表驱动 / switch 语句等类似手段去处理,而大多数人这样写是为了应对代码复杂度度量而出现的 hack 手段,因为 includes 复杂度会大大降低,而 || 集联会导致复杂度暴涨,而前者降低的原因也很简单:编译器分析不了 includes 的语义,只把他当成函数调用,没有当作集联或来做具体分析造成。
此处用 includes 只是骗过了度量工具,没有解决问题。
00// ⭕️ 横向可拓展的条件集
01if (
02  type === a
03  || type === b
04  || type === c
05  || type === d
06) {}
07
08// ❌ 🍜 意大利面式条件耦合
09if (
10   (type === a || name === 'eczn')
11   && age >= 24
12   && company === 'google'
13   || bxxxTime >= userLastBxxxTime
14) {}
大部分人都会觉得后面那个才叫做复杂度,而前者这种横向可拓展的复杂度并不高,而对于前者,很多人可能会用表驱动 / switch 去处理,或者说 includes 不也挺好的嘛,而实际上,很多场景我们只有两个条件:
00if (type === a || type === b) { /* ... */ }
01const conditionMap = { a: true, b: true }
02if (conditionMap[type]) { /* ... */ }
从可读性的角度上来看,这类处理是不是有点太过度设计/过度封装了?

# 考察 includes 的语义

includes 仅是判断有无,直接用于此处本身意思上就不对上,阅读者读的时候得自己转换一层意思:
  1. 眼球读取到 如果 type 包含在 [a, b] 之中 ... 则 ...
  2. 经过人脑翻译后意思等价于 如果 type === a || type === b ... 则 ...
高可读性代码里能避免的逻辑负担就尽量避免,此处用 includes 是可避免的,因此不推荐写 includes 直接 || 展开会更好。
从这个观点来看,只有在动态条件(即条件数组会在业务场景中变长变短)的情况下才必须用 includes 做动态集联或的判断,此外,includes 有更多的运行时开销,也不推荐(尤其是可以编译成二进制包的语言来说更不推荐)

# 考察 includes 的类型

从业界多年的实践来看,由人来证明代码里的类型是靠不住的,编译器的证明更靠得住,所以这几年 js python 之类都开始搞静态类型系统,而新兴的编程语言更是清一色全部都有强大的静态类型 (除了 golang doge)
为了更好的利用编译器对类型命题作证明的能力,我们更应该写更具体的类型给编译器做分析。
从这个角度上来看,我们应当先考察 includes 的类型:
00// ts 源码对 includes 的声明
01interface Array<T> {
02  includes(searchElement: T, fromIndex?: number): boolean;
03}
很明显,includes 甚至要求搜索的元素跟数组是同一个类型,这说明下面的语句是有问题的:
00declare const retcodes: number[];
01if (retcodes.includes('111')) {
02  // 这里过不了编译,会报错
03  // 因为 string 不满足 retcodes 的元素限制
04} else {
05  // 业务 else 场景
06}
这种场景在业务中很常见,我们要判断用户操作不属于某个条件集,就应该做个弹窗提示啥的
而此处既然期望用 includes 来做条件集联或的判断,那么此处我们就不应该对 “用户操作” 做限制才对—— 亦即以下代码里 用户操作 action 的类型 应当不被限制才对,它可能是数字,甚至是 null, undeinfed 之类的,我们应该将其当作 unknown 处理
00if (
01  action === 'A'
02  || action === 'B'
03  || action === 'C'
04) {
05  // 业务 if-true 场景
06} else {
07  // 业务 else 场景
08}
而大部分情况我们会将 action 声明为 string,然后将条件集声明为 string[],从而逃过了这个问题,也因此,目前 includes 无法实现如下静态分析/推断:
00if (
01  ['a', 'b'].includes(c)
02) {
03  // c 在这里应当推断为 'a' | 'b'
04} else {
05  // 如果 c 的类型是 'a' | 'b' | 'c'
06  // 那么这里 c 的应当推断为 'c'
07}
那么有没有办法魔改,可以改下声明:
00declare global {
01  interface Array<T> {
02    ecznIncludes(elem: unknown): elem is T;
03  }
04}
05// @ts-ignore 后面扩大化了, 忽略
06Array.prototype.ecznIncludes = Array.prototype.includes;
07
08// test
09declare const c: unknown;
10if (['a', 'b'].ecznIncludes(c)) {
11  type C = typeof c; // C is string
12}

# 其他语言如何处理 ?

模式匹配解决一切 “横向可拓展” 的问题:
00-- haskell
01fib n | n == 0 = 1
02      | n == 1 = 1
03      | n >= 2 = fib (n-1) + fib (n-2)
00;; lisp 家族: 形如下面的结构
01(define (mapRetcodeMessage code)
02  (match code
03    ((0 "成功")
04     (1 "空间不足")
05     (2 "用户无权限")
06     ('default "接口错误请重试"))))
00// Swift 两位二进制 转 十进制字符串
01let bool1 = 1
02let bool2 = 0
03switch (bool1, bool2) {
04  case (0, 0): print("0")
05  case (0, 1): print("1")
06  case (1, 0): print("2")
07  case (1, 1): print("3")
08}

# 一个推荐的做法

现阶段 TypeScript 并不支持 if 或 switch 里的模式匹配,因此解决这类问题可能需要一点特别的技巧,比如借助 enum 反射、结合表驱动的思想来做:
00enum UserOperation {
01  OPEN = 1,
02  CLOSE = 2,
03  // 注: ts 不支持 enum string value 的反射, 所以这里是数字
04  // 如果一定要字符串,请保证 key 名和 value 一致, 如 OPEN = 'OPEN'
05}
06
07console.log('UserOperation', UserOperation);
08
09function onUserClick1(o: UserOperation) {
10  if (UserOperation[o]) {
11    console.log('合法 operation, keyName 是', UserOperation[o]);
12  } else {
13    console.log('非法 operation');
14  }
15}
16
17onUserClick1(UserOperation.OPEN);

# 末尾

  1. 度量工具应该需要支持豁免横向可拓展的 if 判断的复杂度计数
  2. 考虑到可读性以及静态类型,综上 [].includes 做集联条件判断并不好
  3. TypeScript 或 tc39 理应推出更强大的模式匹配,而不是仅满足于解构赋值这类基础的模式匹配