卓越工程布道:掌握条件判断的模式

简介: 本文是普适性的经验分享,并非按规范局限在 JavaScript 前端视角 做出的总结,除JavaScript外还深入结合了ActionScript 3.0、PHP、C / C++、Basic非纯粹OOP领域语言的经验。

1. 背景(前断言)

想实现可维护性高、语义性强的“条件判断”并不是容易的事情。

条件判断是对因果的表达,通常情况下写“if / else”是实现条件判断最简单、直接的方法,但滥用这种写法,面临的复杂度也就越高,甚至有一种专门的指标(CYC / 圈复杂度)衡量该复杂度问题。

在软件设计领域,条件判断是可以被分类的——不同类型的条件判断具有不同的语义,进而有相应的模式及实践方法。

但可惜的是这些模式方法往往横跨多种理论,并且有不同的理论支撑和术语表达,相对晦涩,只通过看别人的代码、学习几种编程语言或设计模式是远远不够的。尤其对于前端当下不纯粹的OOP实践现状来说更加艰难,甚至生搬、误用OOP的设计模式。

因此我们需要学习掌握一些写条件判断的模式,以帮助我们改善“if / else”滥用所带来的复杂度问题。为了大家可以快速入门,掌握不同类型的条件判断及其模式,需要先了解一些理论知识。

首先,我们必须先了解如何把看似随意的条件判断逻辑用严谨的理论来表达,然后再讲如何分类条件判断逻辑,再是消除不必要的“if / else”,尽量能让大家思维转变的过程自然畅通。

1.1 本文的由来

本文是普适性的经验分享,并非按规范局限在 JavaScript 前端视角 做出的总结,除JavaScript外至少深入结合了以下非纯粹OOP领域语言的经验:

  1. ActionScript 3.0:这是ECMAScript 4 规范的实现,与ES6+、TypeScript相比ECMAScript 4 更加严格而且更早,业界应用广泛且积累厚重,有重要参考意义;
  2. PHP:非严格OOP但大规模应用于Web的语言,在前后端分离历史阶段具有重要意义,甚至是前端熟知的JSX的发展也离不开XHP这一PHP非官方分支;
  3. C / C++:早期状态和消息管理、绘图方案不健全时,有大量的表驱动、策略模式应用,卫述、断言是常态;
  4. Basic:结构化编程,逻辑组织不当、难以形成大规模应用是其特点,但极限编程也有模式应用;

1.2 设计模式消歧义

有人会对设计模式的命名敏感,担心本文的设计模式与OOP的设计模式名称一样,会有阅读网上强行凑数出来的前端设计模式文档的不适感,这里想请大家放心:

本着不随意创造概念增加大家理解成本的原则,本文中的设计模式名称和概念都采用自OOP的设计模式,但只取其本质意义,在实践上并不会仿照OOP那一套。

接下来请忘记OOP,忘记与之相关的设计模式,记住JavaScript中函数是第一公民。


2. 理论支撑(内功心法)

2.1 霍尔逻辑 (Hoare logic)

大家在大学里有学过离散数学的应该对一阶逻辑(谓词逻辑)还有些印象,一阶逻辑用于表达或分析我们日常编写的程序来说,理论性大于实用性,原因是程序通常不能被简单地转换为严谨的一阶逻辑公式。

因此这里简单介绍另一种更加贴近我们日常编码的逻辑分析方法,即霍尔逻辑 (Hoare logic),这是一种建立在一阶逻辑基础之上的公理语义,可以使用数理逻辑推理规则即公理 (Axiom Schema) 来验证程序逻辑的正确性,以及推导等价逻辑结构。

image.png

2.2 断言 (Assertion)、不变式 (invariant) 与卫 (Guard)

image.png

断言和不变式可以理解为是霍尔理论中霍尔三元一阶逻辑的实践,接下来将基于断言、不变式和卫,来理解相关的编程方法和模式。

这些特殊的条件判断在契约式编程(Design by Contract)防御性编程(Defencive Design)中都有相应理论支撑和应用实践。

软件开发领域曾有一个GIGO原则(Garbage in, Garbage out),说的是为一个程序输入一些垃圾数据,它就输出垃圾数据。但如今大规模程序协同关系复杂、链路长,以这种原则实现的代码安全性极差。

而下面要提的契约式编程防御性编程则是对GIGO原则的破立,它们要做的是让程序能够尽量安全的执行,即:“垃圾进,什么都不出”、“进来垃圾、出去错误提示”或者“不允许垃圾进来”。

2.3 契约式编程 (Design by Contract)

契约式编程对代码编写过程非常严格,就像在按契约进行编码,例如对方法(Method)的契约式编程一般包含以下部分:

image.png

可以看到,以契约性编程的思想去看待程序设计,我们得到了很多类型的条件判断:

1、2、3在刚刚的霍尔逻辑中已经介绍了断言和不变式;

4、5这通常在强类型语言中有优势,这类语言已经帮助我们做好条件判断了,不需要特别做判断,即,当调用参数与签名形参不符时,直接报错,这个判断是语言层面保障的;

6、7这在一些如Java、C#之类的语言中,也会给出一些很好的补充,可以在生产侧声明可能会抛出的异常来取代在消费侧写条件判断,当在消费侧尝试捕获一个不存在的异常类型时就直接发生错误,而不必等到运行时。同样地,利用一些特殊作用域语法来限制可能导致副作用的写法,也可以通过语法检查器就检查出来原本需要写if / else条件判断的结果;

上述契约编程以一例说明:

// 7. 性能上的保障
@timeout(300, "Execute timeout.")
// 4. 可接受和不可接受的值或类型,5. 返回的值或类型,以及它们的含义
void assignNormalize(uint64 addr, float value)
  throws GpuException, AssertException {
  // 1. 先验条件(P / 前断言)
  assert(loc != null, "Not a valid addr");
  assert(value != null, "Not a valid value");

  // 3. 不变式
  invariant(value != 0, "Cannot divide by zero.")

  // 6. 副作用(使用dispose模式管理)
  const disposed = using(Gpu, (gpu, mem) => {
    // 1. 先验条件(P / 前断言)
    assert(mem.writable(addr, UINT64_LEN));

    const result = gpu.normalize(mem.get(addr), value);
    mem.assign(addr, result);
    // 2. 后验条件(P / 后断言)
    assert(result == mem.read(addr, UINT64_LEN));
  })
  
  // 2. 后验条件(Q / 后断言)
  assert(disposed, "Dispose failed.");
}

void cosumer(uint64 baseLoc) {
  // 1. 先验条件(P / 前断言)
  assert(baseLoc > 0x100000000);
  try {
    assignNormalize(baseLoc + 0x1FFFF, 0x10);
  }
  // 4. 这里编译时报错,在assignNormalize是未提及此类异常
  catch(MathException e) {
    // ...
  }
  catch(GpuException e) {
    // ...
  }
}

这个例子是典型的利用契约编程的“契约”,并结合语言特性去完成的。因此契约编程是依赖语言特性的,否则无法得到有效约束,这也是契约编程相关技术(如Eiffel语言,忠实的契约编程实践者)落后于时代的因素之一,但这不妨碍我们学习它的思想。

Eiffel示例:

image.png

接下来介绍一个大家更加熟悉可能已经在用的概念,也就是防御性编程。

2.4 防御性编程 (Defensive Design)

防御性编程的概念来源于防御性驾驶,即作为一个司机就要承担起保护自己的责任,哪怕是别人犯错了也不能威胁到自己,防御性编程也如此,会收集各种假设的异常场景并消除之,有时防御性编程会和驱动测试开发放在一起讨论,但事实区别很大。

防御性编程的核心思想是认为程序都会有问题,而应用防御性编程通常被看作减少墨菲定律效力的方法。

先以一例契约式编程来举例:

function divide(a, b) {
  // 前断言 P start
  assert(!isNaN(a)); // 断言a不是NaN
  assert(!isNaN(b)); // 断言b不是NaN
  // P end --- 以上是divide逻辑成立的必要条件

  // C start
  const result = expensiveHighPrecisionDivide(a, b);
  // C end

  // 后断言 Q start --- 以下是divide逻辑成立并结束的必要条件
  assert(!isNaN(result))
  // Q end
  return result;
}

这个契约式编程虽然保障了逻辑成立的必要条件,但他并不关心一些可能存在的有场景,其中有太多墨菲定律带来的不确认性,可以通过改写为防御性编程来尽量避免:

function divide (a, b) {
  // 前断言 P start
  assert(!isNaN(a)); // 断言a不是NaN
  assert(!isNaN(b)); // 断言b不是NaN
  // P end --- 以上是divide逻辑成立的必要条件

  // C start

  // 防御性编程的一段 start,消除墨菲定律效力或者尽早退出
  if (b === 0) {
     return Infinity;
  }

  if (b === 1) {
     return a;
  }

  if (a === b) {
      return 1;
  }
  // 防御性编程的一段 end

  const result = expensiveHighPrecisionDivide(a, b);

  // C end

  // 后断言 Q start --- 以下是divide逻辑成立并结束的必要条件
  assert(!isNaN(result))
  // Q end
  return result;
}

防御性编程与契约式编程相比非常相似,但它并不会以契约去强制开发者做什么,违反契约就直接异常,而是交由开发者去思考哪些有必要抛异常哪些又应该容错。

以上案例的assert在日常开发中都会写为if return这种形式,但我们仍然需要识别、理解这些写在方法中的特殊位置的 if / else,要知道这些 if / else其实是断言或不变式,它们是非常重要的一类特殊条件判断。

契约式编程的问题是严格到死板,而防御性编程最大的问题在于灵活到滥用,目前看来重灾区在于Optional Chaining:

if (a?.b?.c) {
  result = a?.b; // 上下确定了a.b.c一定存在,这里滥用了Optional Chaining
}

2.5 小结

image.png

综合以上,推荐的方法结构是:

function sample() {
  /* 前断言 P {
    这里是方法能够运行的前提
  } */

  /* 防御 {
    消除异常、性能、安全等问题
  } */

  /* 逻辑正文 C */
}

断言和防御都是if...else,但前断言一定比防御要靠前。


3. 设计模式(外功招式)

以下设计模式的应用是基于以上理论形成的,在谈设计模式前,假设你已经很明白上述理论阐述的断言、卫的所在位置和特点了,也清楚它们与普通if / else之间的区别。否则需要回到上面重新阅读理解这些概念,否则继续学习下面的知识很容易变成死记硬背。

3.1 卫述 (Guard Clause) / 保镖模式 (Bouncer Pattern)

在断言中提到的卫(guard)if return相结合即构成卫述(也称为卫语句,Guard Clause):

if (guard) return; // 卫述

卫述(Guard Clause)和保镖模式(Bouncer Pattern)是相同的概念,是较常见的一种设计模式,其核心思路是通过检查前提条件使方法提前退出(Early Quit)

提前退出特性可以删除嵌套,使得代码更扁平。举例,使用卫述来简化深层嵌套:

// ❌ 改进前
function getPayAmount() {
  let result;
  if (isDead){
    result = deadAmount();
  } else {
    if (isSeparated){
      result = separatedAmount();
    } else {
      if (isRetired){
        result = retiredAmount();
      } else{
        result = normalPayAmount();
      }
    }
  }
  return result;
}
// ✅ 改进后
function getPayAmount() {
  if (isDead){
    return deadAmount();
  }
  if (isSeparated){
    return separatedAmount();
  }
  if (isRetired){
    return retiredAmount();
  }
  return normalPayAmount();
}

3.1.1卫述评估表

活用这种模式可以简化代码复杂度、增强可读性。当满足下列评估表时就可以使用:

image.png

3.1.2卫述与断言的区别


卫述与断言能力近似,甚至在大部分场景中的编码形式相同。

但断言是保障逻辑正确、程序正确运行的理论范式。由断言可以发展出如契约式编程、防御性编程、测试驱动编程、卫述式编程等等具体实施方法,而卫述则是单纯的指按场景尽早返回结果,是一种具体的实践模式,本身不保障逻辑正确。

image.png

若卫述放在断言P前或断言Q之后,可能程序执行结果相同但逻辑并不严谨,容易在迭代中逻辑失真并趋向混乱。

function divide (a, b) {
  // P start
  assert(!isNaN(a)); // 断言a不是NaN
  assert(!isNaN(b)); // 断言b不是NaN
  // P end --- 以上是divide逻辑成立的必要条件

  // C start

    // 下面有一些卫述,表达了一些场景
  if (b === 0) {
       return Infinity;
  }

  if (b === 1) {
     return a;
  }

  if (a === b) {
    return 1;
  }

  const result = expensiveHighPrecisionDivide(a, b);

  // C end

  // Q start -- 以下是后断言,是divide逻辑成立的必要条件
  assert(!isNaN(result));
  // Q end

  return result;
}

消除断言assert,改为一般形式的开发,则为:

function divide (a, b) {
    // P start
    if (isNaN(a)) return; // 断言a不是NaN
    if (isNaN(b)) return; // 断言b不是NaN
    // P end --- 以上是divide逻辑成立的必要条件

    // C start

    // 下面有一些卫述,表达了一些场景
    if (b === 0) {
       return Infinity;
    }

    if (b === 1) {
       return a;
    }

    if (a === b) {
    return 1;
    }

    const result = expensiveHighPrecisionDivide(a, b);

    // C end

    // Q start -- 以下是后断言,是divide逻辑成立的必要条件
    if (isNaN(result)) return;
    // Q end

    return result;
}

看起来与一般前端代码无异了,但如果混淆断言和卫述,把卫述放到断言前:

function divide (a, b) {
  
    // 卫述 start --- 下面有一些卫述,表达了一些场景
    if (b === 0) {
       return Infinity;
    }

    if (b === 1) {
       return a;
    }

    if (a === b) {
    return 1;
    }
    // 卫述 end
  
    // P start
    if (isNaN(a)) return; // 断言a不是NaN
    if (isNaN(b)) return; // 断言b不是NaN
    // P end --- 以上是divide逻辑成立的必要条件

    // C start


    const result = expensiveHighPrecisionDivide(a, b);

    // C end

    // Q start -- 以下是后断言,是divide逻辑成立的必要条件
    if (isNaN(result)) return;
    // Q end

    return result;
}

虽然这种代码正常执行,甚至与原代码等价,但从维护角度上,如果开发者重构涉及到18行一定会提心吊胆:是什么导致了a、b还有isNaN的情况?这明显不是divide的场景!我需要向上排查。

而断言在卫述顺序之前则完全没有这种问题,我们始终在一个线性编程的所谓Happy Road上,因此区分断言和卫述是非常必要的。

3.2 极限编程下的模式应用

这是一个用108行代码写作的主角为马里奥的小游戏,它是在仅支持4086 Bytes内存空间的BASIC环境里编写及运行的:

image.png

从下面节选的部分源码中,我们还是可以看到在那个资源匮乏的年代,甚至写if...else也是一种奢望,程序员会利用一些办法节省空间让代码能够跑起来,比如下面的代码实现了条件判断但并没有使用if...else节省了很多Bytes,也是接下来要讲的经典模式:


; 生成一系列key
READ X,Y,A,B,C,D
DATA 120,140,0,1,2,3
; 将key映射到value上
DEF SPRITE A,(0,1,0,1,0)=CHR$(1)+CHR$(0)+CHR$(3)+CHR#(2)
DEF SPRITE B,(0,1,0,0,0)=CHR$(0)+CHR$(1)+CHR$(2)+CHR#(3)
DEF SPRITE C,(0,1,0,1,0)=CHR$(5)+CHR$(4)+CHR$(7)+CHR#(6)
DEF SPRITE D,(0,1,0,0,0)=CHR$(4)+CHR$(5)+CHR$(6)+CHR#(7)
...
; 函数调用
SPRITE A,X,Y
SPRITE B,X,Y
SPRITE C,X,Y
...

3.3 表驱动 (Table-Driven Method)

表驱动方法是一种编程模式(Scheme),其的核心思想是将数据代码如控制变量、参数等从逻辑代码中分离出来,并以模块化形式存放于外部表中,以此来概括逻辑、简化变更。

以一例说明,改进前逻辑与数据混合在一起,不论是编写还是后续维护都比较复杂;改进后,逻辑与数据分离,可单独维护数据或者逻辑,数据有数据的组织方法,逻辑也有逻辑的维护方式:

image.png

现代高级语言的JSON、HashMap等,还可以支持存储更加复杂的数据结构:

const CITY_TABLE = {
  "Paris": {
    "country": "France",
    "population": 2140526,
    "area": 105.4,
    "famousAttractions": ["Eiffel Tower", "Louvre Museum", "Notre-Dame Cathedral"],
    "timezone": "Europe/Paris"
  },
  "Reykjavik": {
    "country": "Iceland",
    "population": 123300,
    "area": 273,
    "famousAttractions": ["Blue Lagoon", "Hallgrimskirkja", "Golden Circle"],
    "timezone": "Atlantic/Reykjavik"
  },
  "Marrakech": {
    "country": "Morocco",
    "population": 928850,
    "area": 230,
    "famousAttractions": ["Jardin Majorelle", "Bahia Palace", "Koutoubia Mosque"],
    "timezone": "Africa/Casablanca"
  }
};

const COUNTRY_TABLE = {
  "France": {
    "population": 67221998,
    "area": 551695,
    "officialLanguage": ["French"],
    "capital": "Paris"
  },
  "Iceland": {
    "population": 356991,
    "area": 103000,
    "officialLanguage": ["Icelandic"],
    "capital": "Reykjavik"
  },
  "Morocco": {
    "population": 36910560,
    "area": 446550,
    "officialLanguage": ["Arabic", "Berber"],
    "capital": "Rabat"
  }
}

const cityInfo =  CITY_INDEXED_TABLE[city];
const countryInfo = COUNTRY_TABLE[cityInfo.country];

上面的例子都是简单的索引表,实际使用中有些场景对表格数据的维护成本较高,甚至形成下钻表、决策表,或者拥有复杂类型键的Map去形成表驱动,这些情况要谨慎分析,确认表和逻辑的分离、维护成本是否符合预期。

3.3.1知识拓展

表驱动有多好用?很多硬件设计师在开发软件逻辑(比如开发驱动)时都习惯使用表驱动来表达复杂逻辑。以一则数字电路中经典的flip-flop为例,下图这是一个时钟 SR 触发器,由4个NAND门构成,S(Set置位)、R(Reset复位)、CLK(Clock时钟信号),单看逻辑比较复杂:

image.png

如果给一张表,甚至不用学习数字电路都能理解里面的逻辑,这张表有专有名词叫Functional Model:

image.png

即只有当CLK为上升沿且改变输入信号 S 和 R时,才会影响输出,否则输入信号一直被锁定在输出中。

这东西是我们前端非常熟悉的“debounce”,CLK就是debounce的timeout参数,S/R是回调函数入参,Q是出参。

3.3.2表驱动评估表

建议按下面的评估表,确认应用表驱动方法的必要性:

image.png

3.4 策略模式 (Strategy Pattern)

策略模式在一般OOP语言的实践中大多以多态 (Polymorphism) 形式出现。

但在前端,JavaScript以函数为第一公民,而且JavaScript服务于多变的UI开发,只有少数场景(如SDK等底层、通用能力)会主动使用多态等OOP特性。

也因为不完整的OOP特性,如Overload通过参数列表的差异来区分调用不同签名的函数的多态行为,是无法自动完成的,要依赖手动判断编写,实用性大打折扣。

因此对前端来说,生搬硬套Java那样用多态去实践策略模式并不是好办法,这是一个Java使用多态实践策略模式的例子:

class Bird {
  // ...
  double getSpeed() {
    switch (type) {
      case EUROPEAN:
        return getBaseSpeed();
      case AFRICAN:
        return getBaseSpeed() - getLoadFactor() * numberOfCoconuts;
      case NORWEGIAN_BLUE:
        return (isNailed) ? 0 : getBaseSpeed(voltage);
    }
    throw new RuntimeException("Should be unreachable");
  }
}
abstract class Bird {
  // ...
  abstract double getSpeed();
}

class European extends Bird {
  double getSpeed() {
    return getBaseSpeed();
  }
}
class African extends Bird {
  double getSpeed() {
    return getBaseSpeed() - getLoadFactor() * numberOfCoconuts;
  }
}
class NorwegianBlue extends Bird {
  double getSpeed() {
    return (isNailed) ? 0 : getBaseSpeed(voltage);
  }
}

// Somewhere in client code
speed = bird.getSpeed();

可以想象到前端在业务UI开发中以这种方式实践策略模式有多痛苦,因此我们要找更简洁的方案去实践策略模式,下一例以一则充满条件判断的函数为例,用策略模式改写:

// 改写前
function getSpeed(type): number {
  switch (type) {
    case EUROPEAN:
      return getBaseSpeed();
    case AFRICAN:
      return getBaseSpeed() - getLoadFactor() * numberOfCoconuts;
    case NORWEGIAN_BLUE:
      return (isNailed) ? 0 : getBaseSpeed(voltage);
  }
}
// 以策略模式改写后
const SPEED_METHODS = {
  EUROPEAN: () => getBaseSpeed(),
  AFRICAN: () => getBaseSpeed() - getLoadFactor() * numberOfCoconuts,
  NORWEGIAN_BLUE: () => (isNailed) ? 0 : getBaseSpeed(voltage),
};

function getSpeed(type) {
  return SPEED_METHODS[type]?.();
}

策略模式的实践方式非常像表驱动方法,若以表驱动方法比较:表驱动方法将数据逻辑分离并独立管理,而策略模式则是将判断逻辑执行逻辑分离并独立管理。

3.4.1 策略模式评估表

image.png

3.5 责任链模式 (Chain of Responsibility Pattern)

很遗憾的是,网上大量责任链模式的案例都生搬了OOP实践,在函数为第一公民的JavaScript的现实开发中很难被采用,因此建议不要在前端日常业务开发中使用OOP的责任链模式实践,这会带来理解和应用的负担。

这里提供一种更加实用的方案,使责任链模式在面向函数的开发中更加简单、直观,将成链的责任抽象为函数式调用关系。

责任链的构成分为三步:

  1. 职责分离:将原本混杂在一起的职责边界划分清楚,形成多个的具备单一职责的函数;

image.png

2、构造责任链:将这些单一职责函数按顺序排列到数组中,形成责任链;

image.png

3、执行责任链:编写一个操纵函数,遍历责任链的各个函数并执行,设定终止执行的条件。

image.png

形成代码如:

// 场景1,大量任务先后执行
if (a()) return;
if (b()) return;
if (c()) return;
if (d()) return;
// ...
const processors = [a,b,c,d];
const manipulator = processors => {
  for (const processor of processors) {
    if (processor()) {
      return;
    }
  }
}
// 场景2,大量任务嵌套执行
if (a()) {
  if (b()) {
    if (c()) {
      if (d()) {
        // ...
      }
    }
  }
}
const processors = [a,b,c,d];
const manipulator = processors => {
  for (const processor of processors) {
    if (!processor()) {
      return;
    }
  }
}

可见,manipulator的实现决定了责任链执行的顺序。

3.5.1 用责任链重构逻辑

下面给一个具体例子,说明责任链是如何建立和运行的:

const exponents = ["e", "E"];
const numbers = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'];
const operators = ['-', '+'];
const chars = [ ...numbers, ...exponents, ...operators, '.' ];

const isNumber = function (s) {
  // 前断言
  if (typeof s !== "string") {
    return;
  }

  // 防御
  s = s.trim();
  if (s.length === 0) {
    return false;
  }

  let hasPoint = false;
  let hasExponent = false;
  let hasNumber = false;
  let numberAfterExponent = true;
  
  for (let i = 0; i < s.length; i++) {
    // 不变式,断言每个s[i]都在指定集合内
    if (!chars.includes(s[i])) {
      return false;
    }

    // 卫述开始
    if (s[i] === "." && (hasExponent || hasPoint)) {
      return false;
    }

    if (exponents.includes(s[i]) && (hasExponent || !hasNumber)) {
      return false;
    }

    if (operators.includes(s[i]) && !e.includes(s[i - 1]) && i !== 0) {
      return false;
    }

    if (s[i] === ".") {
      hasPoint = s[i] === ".";
    }
    // 卫述结束

    if (numbers.includes(s[i])) {
      hasNumber = true;
      numberAfterExponent = true;
    }

    if (exponents.includes(s[i])) {
      numberAfterExponent = false;
      hasExponent = true;
    }
  }

  return hasNumber && numberAfterExponent;
};

这个函数已经使用卫述优化,但仍然难以维护,原因是每一个卫都有一定的关联性,后续对卫的维护是需要分析所有的逻辑,因此感觉整体复杂。

如何优化?分析后发现可以将逻辑归纳为对数字、对小数、对操作符等等的单独处理函数,然后再由一个Manipulator去控制它们,即:

// 用责任链模式优化

const exponents = ["e", "E"];
const numbers = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'];
const operators = ['-', '+'];
const chars = [ ...numbers, ...exponents, ...operators, '.' ];

// Step 1. 职责分离
interface IProcessor {
  ctx => boolean;
}

const checkNumeric: IProcessor = (ctx) => {
  if (numbers.includes(ctx.s[i])) {
    ctx.hasNumber = true;
    ctx.numberAfterExponent = true;
  }
}

const checkDecimal: IProcessor = (ctx) => {
  if (ctx.s[i] !== ".") {
    return;
  }

  if (ctx.hasExponent || ctx.hasPoint) {
    return false;
  }

  ctx.hasPoint = true;
}

const checkExponent: IProcessor = (ctx) => {
  if (!exponentSign.includes(ctx.s[i])) {
    return;
  }

  if (ctx.hasExponent || !ctx.hasNumber) {
    return false;
  }

  ctx.numberAfterExponent = false;
  ctx.hasExponent = true;
}

const checkOperator: IProcessor = (ctx) => {
  // 不变式,断言每个s[i]都在指定集合内
  if (!operators.includes(ctx.s[i])) {
    return;
  }
  
  if (ctx.i !== 0 && !exponents.includes(ctx.s.charAt(i - 1))) {
    return false;
  }
}

// Step 2. 构造责任链
const processors: IProcessor[] = [
  checkNumeric,
  checkDecimal,
  checkExponent,
  checkOperator,
];

// Step 3. 执行责任链
const manipulator = ctx => {
  for (const processor of processors) {
    if (processor(ctx) === false) {
      return false;
    }
  }
  return true;
}

const isNumber = s => {
  // 前断言
  if (typeof s !== "string") {
    return;
  }

  // 防御
  s = s.trim();
  if (s.length === 0) {
    return false;
  }

  const ctx = {
    s: s.trim(),
    hasPoint: false,
    hasExponent: false,
    hasNumber: false,
    numberAfterExponent: true,
  };

  for (ctx.i = 0; ctx.i < s.length; ctx.i++) {
    // 不变式,断言每个s[i]都在指定集合内
    if (!chars.includes(ctx.s[i])) {
      return false;
    }

    // 消费责任链函数
    if (!manipulator(ctx)) {
      return false;
    }
  }

  return ctx.hasNumber && ctx.numberAfterExponent;
}

3.5.2 写卫述不行吗?

小A说:“这个例子在只有几个Processor时显得过重,很难看明白。”

// Step 2. 构造并执行责任链
const processors: IProcessor[] = [
  checkNumeric,
  checkDecimal,
  checkExponent,
  checkOperator,
];

// Step 3. 执行责任链
const manipulator = ctx => {
  for (const processor of processors) {
    if (processor(ctx) !== false) {
      return;
    }
  }
}

小A说:“我决定用卫述直接写成这样,看起来语义性更好!”

// Step 2+3. 构造并执行责任链
const manipulator = ctx => {
  if (checkNumeric(ctx) === false) {
    return false;
  }
  if (checkDecimal(ctx) === false) {
    return false;
  }
  if (checkExponent(ctx) === false) {
    return false;
  }
  if (checkOperator(ctx) === false) {
    return false;
  }
}

当然可以,不用拘泥形式!责任链模式本质在于归纳、拆分、重组逻辑秩序。如果读者认为Manipulator这个流程秩序的操作者用卫述写语义性更好,那当然可以,甚至可以用表驱动、策略模式都可以,下面给出几种更加典型、复杂的形式,对如何写出易用的Manipulator的挑战也更大。

3.5.3 责任链的典型形态

3.5.3.1 Pipeline形态

image.png

这种形态的特点是,processor与processor之间呈简单的Pipeline形式,只用一个迭代器Manipulator即可操作整个流程。

Manipulator可以选用适合流程的迭代器,比如every、some、Promise.all、Promise.race等等,上面的例子就是这种最简单的形态。

3.5.3.2 Route形态

image.png

这种形态的特点是流程呈树状,由多组processors形成的routeline构成,routeline之间的逻辑非常简单,可以由一个Manipulator + Table来控制整个流程。

Manipulator除了基础的迭代器外,还允许在不同的routeline中做转向控制,常用的做法是用表驱动或者策略模式实现。

典型的案例有 react-router-dom,利用表驱动 / 策略模式组装、渲染route,一个简单的Route示例:

const ROUTES = {
  line1: [a, b, c],
  line2: [a, b, d, e],
  line3: [d, e, f, g],
};

const manipulator = (line) => {
  for (const processor of ROUTES[line]) {
    processor()
  }
}

3.5.3.3 Middleware形态

image.png

与Routeline类似,但每一条子链之间的逻辑可能非常复杂,无法由一个Manipulator控制整个流程,需要多个Manipulator协作整个过程。

每个Manipulator除了迭代、转向之外,还需要把一些流程控制权力下放给Processor,在某些场景中常被称为Middleware。典型的案例有 Express (Node.js) 的 Middleware,给一个真实的示例

3.5.4 责任链模式评估表

建议按以下评估表确认是否可以使用责任链模式:

image.png

3.6 小结

注意这不是口诀,不理解理论仅照搬可能会误用

  • 写 if / else 前,判断是断言还是卫述;
  • 坚持卫述表达,尽早退出;
  • 数据、逻辑混合?走表驱动方法;
  • 判断、执行逻辑混合?走策略模式;
  • 逻辑像任务流水线?走责任链模式。


4. 写在后面(后断言)

本文给出的是与条件判断相关的基础知识,如果想了解的知识不在里面,有几种可能性:

  1. 读者已经融会贯通这些与条件判断相关的理论和模式,并有自己的实践理论方法;
  2. 读者可能直接跳过理论,直接到设计模式看代码去了,对理论支撑部分理解不够,主观臆断了。

还有很多设计模式经典但没讲,理由可能是:

  1. 贵精不贵多:本文的模式非常基础、非常重要,若能把文中模式熟练应用,别的模式甚至不用也无所谓;
  2. 普适性不高:GoF中提的那么多模式,也不是每种在业务开发场景中都有用武之地;
  3. 普适性太高:一些经典模式已经形成专用的框架或库了,大家都在用,对此也没有问题,学习使用这些框架或库即可掌握,如:Redux、RXJS、EventEmitter……

期待大家在写 if...else 前能多思考再落键,编写时纠结下断言、思考下是否可以用卫述、表驱动、策略模式、责任链做优化。


作者 | 偏左

来源 | 阿里云开发者公众号


作者介绍
目录