第 10 章 简化条件逻辑

程序的大部分威力来自条件逻辑,但很不幸,程序的复杂度也大多来自条件逻辑。我经常借助重构把条件逻辑变得更容易理解。我常用分解条件表达式(260)处理复杂的条件表达式,用合并条件表达式(263)厘清逻辑组合。我会用以卫语句取代嵌套条件表达式(266)清晰表达“在主要处理逻辑之前先做检查”的意图。如果我发现一处 switch 逻辑处理了几种情况,可以考虑拿出以多态取代条件表达式(272)重构手法。

很多条件逻辑是用于处理特殊情况的,例如处理 null 值。如果对某种特殊情况的处理逻辑大多相同,那么可以用引入特例(289)(常被称作引入空对象)消除重复代码。另外,虽然我很喜欢去除条件逻辑,但如果我想明确地表述(以及检查)程序的状态,引入断言(302)是一个不错的补充。

10.1 分解条件表达式(Decompose Conditional)

if (!aDate.isBefore(plan.summerStart) && !aDate.isAfter(plan.summerEnd))
 charge = quantity * plan.summerRate;
else
 charge = quantity * plan.regularRate + plan.regularServiceCharge;


if (summer())
 charge = summerCharge();
else
 charge = regularCharge();

动机

程序之中,复杂的条件逻辑是最常导致复杂度上升的地点之一。我必须编写代码来检查不同的条件分支,根据不同的条件做不同的事,然后,我很快就会得到一个相当长的函数。大型函数本身就会使代码的可读性下降,而条件逻辑则会使代码更难阅读。在带有复杂条件逻辑的函数中,代码(包括检查条件分支的代码和真正实现功能的代码)会告诉我发生的事,但常常让我弄不清楚为什么会发生这样的事,这就说明代码的可读性的确大大降低了。

和任何大块头代码一样,我可以将它分解为多个独立的函数,根据每个小块代码的用途,为分解而得的新函数命名,并将原函数中对应的代码改为调用新函数,从而更清楚地表达自己的意图。对于条件逻辑,将每个分支条件分解成新函数还可以带来更多好处:可以突出条件逻辑,更清楚地表明每个分支的作用,并且突出每个分支的原因。

本重构手法其实只是提炼函数(106)的一个应用场景。但我要特别强调这个场景,因为我发现它经常会带来很大的价值。

做法

对条件判断和每个条件分支分别运用提炼函数(106)手法。

范例

假设我要计算购买某样商品的总价(总价=数量 × 单价),而这个商品在冬季和夏季的单价是不同的:

if (!aDate.isBefore(plan.summerStart) && !aDate.isAfter(plan.summerEnd))
 charge = quantity * plan.summerRate;
else
 charge = quantity * plan.regularRate + plan.regularServiceCharge;

我把条件判断提炼到一个独立的函数中:

if (summer())
 charge = quantity * plan.summerRate;
else
 charge = quantity * plan.regularRate + plan.regularServiceCharge;

function summer() {
 return !aDate.isBefore(plan.summerStart) && !aDate.isAfter(plan.summerEnd);
}

然后提炼条件判断为真的分支:

if (summer())
 charge = summerCharge();
else
 charge = quantity * plan.regularRate + plan.regularServiceCharge;

function summer() {
 return !aDate.isBefore(plan.summerStart) && !aDate.isAfter(plan.summerEnd);
}
function summerCharge() {
 return quantity * plan.summerRate;
}

最后提炼条件判断为假的分支:

if (summer())
 charge = summerCharge();
else
 charge = regularCharge();

function summer() {
 return !aDate.isBefore(plan.summerStart) && !aDate.isAfter(plan.summerEnd);
}
function summerCharge() {
 return quantity * plan.summerRate;
}
function regularCharge() {
 return quantity * plan.regularRate + plan.regularServiceCharge;
}

提炼完成后,我喜欢用三元运算符重新安排条件语句。

  charge = summer() ? summerCharge() : regularCharge();

function summer() {
 return !aDate.isBefore(plan.summerStart) && !aDate.isAfter(plan.summerEnd);
}
function summerCharge() {
 return quantity * plan.summerRate;
}
function regularCharge() {
 return quantity * plan.regularRate + plan.regularServiceCharge;
}

10.2 合并条件表达式(Consolidate Conditional Expression)

if (anEmployee.seniority < 2) return 0;
if (anEmployee.monthsDisabled > 12) return 0;
if (anEmployee.isPartTime) return 0;


if (isNotEligibleForDisability()) return 0;

function isNotEligibleForDisability() {
 return ((anEmployee.seniority < 2)
     || (anEmployee.monthsDisabled > 12)
     || (anEmployee.isPartTime));
}

动机

有时我会发现这样一串条件检查:检查条件各不相同,最终行为却一致。如果发现这种情况,就应该使用“逻辑或”和“逻辑与”将它们合并为一个条件表达式。

之所以要合并条件代码,有两个重要原因。首先,合并后的条件代码会表述“实际上只有一次条件检查,只不过有多个并列条件需要检查而已”,从而使这一次检查的用意更清晰。当然,合并前和合并后的代码有着相同的效果,但原先代码传达出的信息却是“这里有一些各自独立的条件测试,它们只是恰好同时发生”。其次,这项重构往往可以为使用提炼函数(106)做好准备。将检查条件提炼成一个独立的函数对于厘清代码意义非常有用,因为它把描述“做什么”的语句换成了“为什么这样做”。

条件语句的合并理由也同时指出了不要合并的理由:如果我认为这些检查的确彼此独立,的确不应该被视为同一次检查,我就不会使用本项重构。

做法

确定这些条件表达式都没有副作用。

如果某个条件表达式有副作用,可以先用将查询函数和修改函数分离(306)处理。

使用适当的逻辑运算符,将两个相关条件表达式合并为一个。

顺序执行的条件表达式用逻辑或来合并,嵌套的 if 语句用逻辑与来合并。

测试。

重复前面的合并过程,直到所有相关的条件表达式都合并到一起。

可以考虑对合并后的条件表达式实施提炼函数(106)。

范例

在走读代码的过程中,我看到了下面的代码片段:

function disabilityAmount(anEmployee) {
 if (anEmployee.seniority < 2) return 0;
 if (anEmployee.monthsDisabled > 12) return 0;
 if (anEmployee.isPartTime) return 0;
 // compute the disability amount

这里有一连串的条件检查,都指向同样的结果。既然结果是相同的,就应该把这些条件检查合并成一条表达式。对于这样顺序执行的条件检查,可以用逻辑或运算符来合并。

function disabilityAmount(anEmployee) {
 if ((anEmployee.seniority < 2)
   || (anEmployee.monthsDisabled > 12)) return 0;
 if (anEmployee.isPartTime) return 0;
 // compute the disability amount

测试,然后把下一个条件检查也合并进来:

function disabilityAmount(anEmployee) {
 if ((anEmployee.seniority < 2)
   || (anEmployee.monthsDisabled > 12)
   || (anEmployee.isPartTime)) return 0;
 // compute the disability amount

合并完成后,再对这句条件表达式使用提炼函数(106)。

function disabilityAmount(anEmployee) {
 if (isNotEligableForDisability()) return 0;
 // compute the disability amount

function isNotEligableForDisability() {
 return ((anEmployee.seniority < 2)
     || (anEmployee.monthsDisabled > 12)
     || (anEmployee.isPartTime));
}

范例:使用逻辑与

上面的例子展示了用逻辑或合并条件表达式的做法。不过,我有可能遇到需要逻辑与的情况。例如,嵌套 if 语句的情况:

if (anEmployee.onVacation)
 if (anEmployee.seniority > 10)
  return 1;
return 0.5;

可以用逻辑与运算符将其合并。

if ((anEmployee.onVacation)
  &amp;&amp; (anEmployee.seniority > 10)) return 1;
return 0.5;

如果原来的条件逻辑混杂了这两种情况,我也会根据需要组合使用逻辑与和逻辑或运算符。在这种时候,代码很可能变得混乱,所以我会频繁使用提炼函数(106),把代码变得可读。

10.3 以卫语句取代嵌套条件表达式(Replace Nested Conditional with Guard Clauses)

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();
}

动机

根据我的经验,条件表达式通常有两种风格。第一种风格是:两个条件分支都属于正常行为。第二种风格则是:只有一个条件分支是正常行为,另一个分支则是异常的情况。

这两类条件表达式有不同的用途,这一点应该通过代码表现出来。如果两条分支都是正常行为,就应该使用形如 if...else...的条件表达式;如果某个条件极其罕见,就应该单独检查该条件,并在该条件为真时立刻从函数中返回。这样的单独检查常常被称为“卫语句”(guard clauses)。

以卫语句取代嵌套条件表达式的精髓就是:给某一条分支以特别的重视。如果使用 if-then-else 结构,你对 if 分支和 else 分支的重视是同等的。这样的代码结构传递给阅读者的消息就是:各个分支有同样的重要性。卫语句就不同了,它告诉阅读者:“这种情况不是本函数的核心逻辑所关心的,如果它真发生了,请做一些必要的整理工作,然后退出。”

“每个函数只能有一个入口和一个出口”的观念,根深蒂固于某些程序员的脑海里。我发现,当我处理他们编写的代码时,经常需要使用以卫语句取代嵌套条件表达式。现今的编程语言都会强制保证每个函数只有一个入口,至于“单一出口”规则,其实不是那么有用。在我看来,保持代码清晰才是最关键的:如果单一出口能使这个函数更清楚易读,那么就使用单一出口;否则就不必这么做。

做法

选中最外层需要被替换的条件逻辑,将其替换为卫语句。

测试。

有需要的话,重复上述步骤。

如果所有卫语句都引发同样的结果,可以使用合并条件表达式(263)合并之。

范例

下面的代码用于计算要支付给员工(employee)的工资。只有还在公司上班的员工才需要支付工资,所以这个函数需要检查两种“员工已经不在公司上班”的情况。

function payAmount(employee) {
 let result;
 if(employee.isSeparated) {
  result = {amount: 0, reasonCode:"SEP"};
 }
 else {
  if (employee.isRetired) {
   result = {amount: 0, reasonCode: "RET"};
  }
  else {
   // logic to compute amount
   lorem.ipsum(dolor.sitAmet);1
   consectetur(adipiscing).elit();
   sed.do.eiusmod = tempor.incididunt.ut(labore) &amp;&amp; dolore(magna.aliqua);
   ut.enim.ad(minim.veniam);
   result = someFinalComputation();
  }
 }
 return result;
}

嵌套的条件逻辑让我们看不清代码真实的含义。只有当前两个条件表达式都不为真的时候,这段代码才真正开始它的主要工作。所以,卫语句能让代码更清晰地阐述自己的意图。

一如既往地,我喜欢小步前进,所以我先处理最顶上的条件逻辑。

function payAmount(employee) {
 let result;
 if (employee.isSeparated) return {amount: 0, reasonCode: "SEP"};
 if (employee.isRetired) {
  result = {amount: 0, reasonCode: "RET"};
 }
 else {
  // logic to compute amount
  lorem.ipsum(dolor.sitAmet);
  consectetur(adipiscing).elit();
  sed.do.eiusmod = tempor.incididunt.ut(labore) &amp;&amp; dolore(magna.aliqua);
  ut.enim.ad(minim.veniam);
  result = someFinalComputation();
 }
 return result;
}

做完这步修改,我执行测试,然后继续下一步。

function payAmount(employee) {
 let result;
 if (employee.isSeparated) return {amount: 0, reasonCode: "SEP"};
 if (employee.isRetired)   return {amount: 0, reasonCode: "RET"};
 // logic to compute amount
 lorem.ipsum(dolor.sitAmet);
 consectetur(adipiscing).elit();
 sed.do.eiusmod = tempor.incididunt.ut(labore) &amp;&amp; dolore(magna.aliqua);
 ut.enim.ad(minim.veniam);
 result = someFinalComputation();
 return result;
}

此时,result 变量已经没有用处了,所以我把它删掉:

function payAmount(employee) {
 let result;
 if (employee.isSeparated) return {amount: 0, reasonCode: "SEP"};
 if (employee.isRetired)   return {amount: 0, reasonCode: "RET"};
 // logic to compute amount
 lorem.ipsum(dolor.sitAmet);
 consectetur(adipiscing).elit();
 sed.do.eiusmod = tempor.incididunt.ut(labore) &amp;&amp; dolore(magna.aliqua);
 ut.enim.ad(minim.veniam);
 return someFinalComputation();
}

能减少一个可变变量总是好的。

范例:将条件反转

审阅本书第 1 版的初稿时,Joshua Kerievsky 指出:我们常常可以将条件表达式反转,从而实现以卫语句取代嵌套条件表达式。为了拯救我可怜的想象力,他还好心帮我想了一个例子:

function adjustedCapital(anInstrument) {
 let result = 0;
 if (anInstrument.capital > 0) {
  if (anInstrument.interestRate > 0 &amp;&amp; anInstrument.duration > 0) {
   result = (anInstrument.income / anInstrument.duration) * anInstrument.adjustmentFactor;
  }
 }
 return result;
}

同样地,我逐一进行替换。不过这次在插入卫语句时,我需要将相应的条件反转过来:

function adjustedCapital(anInstrument) {
 let result = 0;
 if (anInstrument.capital <= 0) return result;
 if (anInstrument.interestRate > 0 &amp;&amp; anInstrument.duration > 0) {
  result = (anInstrument.income / anInstrument.duration) * anInstrument.adjustmentFactor;
 }
 return result;
}

下一个条件稍微复杂一点,所以我分两步进行反转。首先加入一个逻辑非操作:

function adjustedCapital(anInstrument) {
 let result = 0;
 if (anInstrument.capital <= 0) return result;
 if (!(anInstrument.interestRate > 0 &amp;&amp; anInstrument.duration > 0)) return result;
 result = (anInstrument.income / anInstrument.duration) * anInstrument.adjustmentFactor;
 return result;
}

但是在这样的条件表达式中留下一个逻辑非,会把我的脑袋拧成一团乱麻,所以我把它简化成下面这样:

  function adjustedCapital(anInstrument) {
 let result = 0;
 if (anInstrument.capital <= 0) return result;
 if (anInstrument.interestRate <= 0 || anInstrument.duration <= 0) return result;
 result = (anInstrument.income / anInstrument.duration) * anInstrument.adjustmentFactor;
 return result;
}

这两行逻辑语句引发的结果一样,所以我可以用合并条件表达式(263)将其合并。

function adjustedCapital(anInstrument) {
 let result = 0;
 if (   anInstrument.capital      <= 0
   || anInstrument.interestRate <= 0
   || anInstrument.duration     <= 0) return result;
 result = (anInstrument.income / anInstrument.duration) * anInstrument.adjustmentFactor;
 return result;
}

此时 result 变量做了两件事:一开始我把它设为 0,代表卫语句被触发时的返回值;然后又用最终计算的结果给它赋值。我可以彻底移除这个变量,避免用一个变量承担两重责任,而且又减少了一个可变变量。

function adjustedCapital(anInstrument) {
 if (   anInstrument.capital     <= 0
   || anInstrument.interestRate <= 0
   || anInstrument.duration   <= 0) return 0;
 return (anInstrument.income / anInstrument.duration) * anInstrument.adjustmentFactor;
}

1 “lorem.ipsum……”是一篇常见于排版设计领域的文章,其内容为不具可读性的字符组合,目的是使阅读者只专注于观察段落的字型和版型。——译者注

10.4 以多态取代条件表达式(Replace Conditional with Polymorphism)

switch (bird.type) {
 case 'EuropeanSwallow':
  return "average";
 case 'AfricanSwallow':
  return (bird.numberOfCoconuts > 2) ? "tired" : "average";
 case 'NorwegianBlueParrot':
  return (bird.voltage > 100) ? "scorched" : "beautiful";
 default:
  return "unknown";


class EuropeanSwallow {
 get plumage() {
  return "average";
 }
class AfricanSwallow {
 get plumage() {
   return (this.numberOfCoconuts > 2) ? "tired" : "average";
 }
class NorwegianBlueParrot {
 get plumage() {
   return (this.voltage > 100) ? "scorched" : "beautiful";
}

动机

复杂的条件逻辑是编程中最难理解的东西之一,因此我一直在寻求给条件逻辑添加结构。很多时候,我发现可以将条件逻辑拆分到不同的场景(或者叫高阶用例),从而拆解复杂的条件逻辑。这种拆分有时用条件逻辑本身的结构就足以表达,但使用类和多态能把逻辑的拆分表述得更清晰。

一个常见的场景是:我可以构造一组类型,每个类型处理各自的一种条件逻辑。例如,我会注意到,图书、音乐、食品的处理方式不同,这是因为它们分属不同类型的商品。最明显的征兆就是有好几个函数都有基于类型代码的 switch 语句。若果真如此,我就可以针对 switch 语句中的每种分支逻辑创建一个类,用多态来承载各个类型特有的行为,从而去除重复的分支逻辑。

另一种情况是:有一个基础逻辑,在其上又有一些变体。基础逻辑可能是最常用的,也可能是最简单的。我可以把基础逻辑放进超类,这样我可以首先理解这部分逻辑,暂时不管各种变体,然后我可以把每种变体逻辑单独放进一个子类,其中的代码着重强调与基础逻辑的差异。

多态是面向对象编程的关键特性之一。跟其他一切有用的特性一样,它也很容易被滥用。我曾经遇到有人争论说所有条件逻辑都应该用多态取代。我不赞同这种观点。我的大部分条件逻辑只用到了基本的条件语句——if/else 和 switch/case,并不需要劳师动众地引入多态。但如果发现如前所述的复杂条件逻辑,多态是改善这种情况的有力工具。

做法

如果现有的类尚不具备多态行为,就用工厂函数创建之,令工厂函数返回恰当的对象实例。

在调用方代码中使用工厂函数获得对象实例。

将带有条件逻辑的函数移到超类中。

如果条件逻辑还未提炼至独立的函数,首先对其使用提炼函数(106)。

任选一个子类,在其中建立一个函数,使之覆写超类中容纳条件表达式的那个函数。将与该子类相关的条件表达式分支复制到新函数中,并对它进行适当调整。

重复上述过程,处理其他条件分支。

在超类函数中保留默认情况的逻辑。或者,如果超类应该是抽象的,就把该函数声明为 abstract,或在其中直接抛出异常,表明计算责任都在子类中。

范例

我的朋友有一群鸟儿,他想知道这些鸟飞得有多快,以及它们的羽毛是什么样的。所以我们写了一小段程序来判断这些信息。

function plumages(birds) {
 return new Map(birds.map(b => [b.name, plumage(b)]));
}
function speeds(birds) {
 return new Map(birds.map(b => [b.name, airSpeedVelocity(b)]));
}

function plumage(bird) {
 switch (bird.type) {
 case 'EuropeanSwallow':
  return "average";
 case 'AfricanSwallow':
  return (bird.numberOfCoconuts > 2) ? "tired" : "average";
 case 'NorwegianBlueParrot':
  return (bird.voltage > 100) ? "scorched" : "beautiful";
 default:
  return "unknown";
 }
}

function airSpeedVelocity(bird) {
 switch (bird.type) {
 case 'EuropeanSwallow':
  return 35;
 case 'AfricanSwallow':
  return 40 - 2 * bird.numberOfCoconuts;
 case 'NorwegianBlueParrot':
  return (bird.isNailed) ? 0 : 10 + bird.voltage / 10;
 default:
  return null;
 }
}

有两个不同的操作,其行为都随着“鸟的类型”发生变化,因此可以创建出对应的类,用多态来处理各类型特有的行为。

我先对 airSpeedVelocity 和 plumage 两个函数使用函数组合成类(144)。

function plumage(bird) {
 return new Bird(bird).plumage;
}

function airSpeedVelocity(bird) {
 return new Bird(bird).airSpeedVelocity;
}
class Bird {
 constructor(birdObject) {
  Object.assign(this, birdObject);
 }
 get plumage() {
  switch (this.type) {
  case 'EuropeanSwallow':
   return "average";
  case 'AfricanSwallow':
   return (this.numberOfCoconuts > 2) ? "tired" : "average";
  case 'NorwegianBlueParrot':
   return (this.voltage > 100) ? "scorched" : "beautiful";
  default:
   return "unknown";
  }
 }
 get airSpeedVelocity() {
  switch (this.type) {
  case 'EuropeanSwallow':
   return 35;
  case 'AfricanSwallow':
   return 40 - 2 * this.numberOfCoconuts;
  case 'NorwegianBlueParrot':
   return (this.isNailed) ? 0 : 10 + this.voltage / 10;
  default:
   return null;
  }
 }
}

然后针对每种鸟创建一个子类,用一个工厂函数来实例化合适的子类对象。

function plumage(bird) {
  return createBird(bird).plumage;
}

function airSpeedVelocity(bird) {
  return createBird(bird).airSpeedVelocity;
}

function createBird(bird) {
  switch (bird.type) {
    case "EuropeanSwallow":
      return new EuropeanSwallow(bird);
    case "AfricanSwallow":
      return new AfricanSwallow(bird);
    case "NorweigianBlueParrot":
      return new NorwegianBlueParrot(bird);
    default:
      return new Bird(bird);
  }
}
class EuropeanSwallow extends Bird {}

class AfricanSwallow extends Bird {}

class NorwegianBlueParrot extends Bird {}

现在我已经有了需要的类结构,可以处理两个条件逻辑了。先从 plumage 函数开始,我从 switch 语句中选一个分支,在适当的子类中覆写这个逻辑。

class EuropeanSwallow...

get plumage() {
  return "average";
}

class Bird...

get plumage() {
 switch (this.type) {
 case 'EuropeanSwallow':
  throw "oops";
 case 'AfricanSwallow':
  return (this.numberOfCoconuts > 2) ? "tired" : "average";
 case 'NorwegianBlueParrot':
  return (this.voltage > 100) ? "scorched" : "beautiful";
 default:
  return "unknown";
 }
}

在超类中,我把对应的逻辑分支改为抛出异常,因为我总是偏执地担心出错。

此时我就可以编译并测试。如果一切顺利的话,我可以接着处理下一个分支。

class AfricanSwallow...

get plumage() {
   return (this.numberOfCoconuts > 2) ? "tired" : "average";
}

然后是挪威蓝鹦鹉(Norwegian Blue)的分支。

class NorwegianBlueParrot...

get plumage() {
   return (this.voltage >100) ? "scorched" : "beautiful";
}

超类函数保留下来处理默认情况。

class Bird...

get plumage() {
  return "unknown";
}

airSpeedVelocity 也如法炮制。完成以后,代码大致如下(我还对顶层的 airSpeedVelocity 和 plumage 函数做了内联处理):

function plumages(birds) {
 return new Map(birds
         .map(b => createBird(b))
         .map(bird => [bird.name, bird.plumage]));
}
function speeds(birds) {
 return new Map(birds
         .map(b => createBird(b))
         .map(bird => [bird.name, bird.airSpeedVelocity]));
}

function createBird(bird) {
 switch (bird.type) {
 case 'EuropeanSwallow':
  return new EuropeanSwallow(bird);
 case 'AfricanSwallow':
  return new AfricanSwallow(bird);
 case 'NorwegianBlueParrot':
  return new NorwegianBlueParrot(bird);
 default:
  return new Bird(bird);
 }
}

class Bird {
 constructor(birdObject) {
  Object.assign(this, birdObject);
 }
 get plumage() {
  return "unknown";
 }
 get airSpeedVelocity() {
  return null;
 }
}
class EuropeanSwallow extends Bird {
 get plumage() {
  return "average";
 }
 get airSpeedVelocity() {
  return 35;
 }
}
class AfricanSwallow extends Bird {
 get plumage() {
  return (this.numberOfCoconuts > 2) ? "tired" : "average";
 }
 get airSpeedVelocity() {
  return 40 - 2 * this.numberOfCoconuts;
 }
}
class NorwegianBlueParrot extends Bird {
 get plumage() {
  return (this.voltage > 100) ? "scorched" : "beautiful";
 }
 get airSpeedVelocity() {
  return (this.isNailed) ? 0 : 10 + this.voltage / 10;
 }
}

看着最终的代码,可以看出 Bird 超类并不是必需的。在 JavaScript 中,多态不一定需要类型层级,只要对象实现了适当的函数就行。但在这个例子中,我愿意保留这个不必要的超类,因为它能帮助阐释各个子类与问题域之间的关系。

范例:用多态处理变体逻辑

在前面的例子中,“鸟”的类型体系是一个清晰的泛化体系:超类是抽象的“鸟”,子类是各种具体的鸟。这是教科书(包括我写的书)中经常讨论的继承和多态,但并不是实践中使用继承的唯一方式。实际上,这种方式很可能不是最常用或最好的方式。另一种使用继承的情况是:我想表达某个对象与另一个对象大体类似,但又有一些不同之处。

下面有一个这样的例子:有一家评级机构,要对远洋航船的航行进行投资评级。这家评级机构会给出“A”或者“B”两种评级,取决于多种风险和盈利潜力的因素。在评估风险时,既要考虑航程本身的特征,也要考虑船长过往航行的历史。

function rating(voyage, history) {
 const vpf = voyageProfitFactor(voyage, history);
 const vr = voyageRisk(voyage);
 const chr = captainHistoryRisk(voyage, history);
 if (vpf * 3 > (vr + chr * 2)) return "A";
 else return "B";
}
function voyageRisk(voyage) {
 let result = 1;
 if (voyage.length > 4) result += 2;
 if (voyage.length > 8) result += voyage.length - 8;
 if (["china", "east-indies"].includes(voyage.zone)) result += 4;
 return Math.max(result, 0);
}
function captainHistoryRisk(voyage, history) {
 let result = 1;
 if (history.length < 5) result += 4;
 result += history.filter(v => v.profit < 0).length;
 if (voyage.zone === "china" &amp;&amp; hasChina(history)) result -= 2;
 return Math.max(result, 0);
}
function hasChina(history) {
 return history.some(v => "china" === v.zone);
}
function voyageProfitFactor(voyage, history) {
 let result = 2;
 if (voyage.zone === "china") result += 1;
 if (voyage.zone === "east-indies") result += 1;
 if (voyage.zone === "china" &amp;&amp; hasChina(history)) {
  result += 3;
  if (history.length > 10) result += 1;
  if (voyage.length > 12) result += 1;
  if (voyage.length > 18) result -= 1;
 }
 else {
  if (history.length > 8) result += 1;
  if (voyage.length > 14) result -= 1;
 }
 return result;
}

voyageRisk 和 captainHistoryRisk 两个函数负责打出风险分数,voyageProfitFactor 负责打出盈利潜力分数,rating 函数将 3 个分数组合到一起,给出一次航行的综合评级。

调用方的代码大概是这样:

const voyage = { zone: "west-indies", length: 10 };
const history = [
  { zone: "east-indies", profit: 5 },
  { zone: "west-indies", profit: 15 },
  { zone: "china", profit: -2 },
  { zone: "west-africa", profit: 7 },
];

const myRating = rating(voyage, history);

代码中有两处同样的条件逻辑,都在询问“是否有到中国的航程”以及“船长是否曾去过中国”。

function rating(voyage, history) {
 const vpf = voyageProfitFactor(voyage, history);
 const vr = voyageRisk(voyage);
 const chr = captainHistoryRisk(voyage, history);
 if (vpf * 3 > (vr + chr * 2)) return "A";
 else return "B";
}
function voyageRisk(voyage) {
 let result = 1;
 if (voyage.length > 4) result += 2;
 if (voyage.length > 8) result += voyage.length - 8;
 if (["china", "east-indies"].includes(voyage.zone)) result += 4;
 return Math.max(result, 0);
}
function captainHistoryRisk(voyage, history) {
 let result = 1;
 if (history.length < 5) result += 4;
 result += history.filter(v => v.profit < 0).length;
 if (voyage.zone === "china" &amp;&amp; hasChina(history)) result -= 2;
 return Math.max(result, 0);
}
function hasChina(history) {
 return history.some(v => "china" === v.zone);
}
function voyageProfitFactor(voyage, history) {
 let result = 2;
 if (voyage.zone === "china") result += 1;
 if (voyage.zone === "east-indies") result += 1;
 if (voyage.zone === "china" &amp;&amp; hasChina(history)) {
  result += 3;
  if (history.length > 10) result += 1;
  if (voyage.length > 12) result += 1;
  if (voyage.length > 18) result -= 1;
 }
 else {
  if (history.length > 8) result += 1;
  if (voyage.length > 14) result -= 1;
 }
 return result;
}

我会用继承和多态将处理“中国因素”的逻辑从基础逻辑中分离出来。如果还要引入更多的特殊逻辑,这个重构就很有用——这些重复的“中国因素”会混淆视听,让基础逻辑难以理解。

起初代码里只有一堆函数,如果要引入多态的话,我需要先建立一个类结构,因此我首先使用函数组合成类(144)。这一步重构的结果如下所示:

function rating(voyage, history) {
 return new Rating(voyage, history).value;
}

class Rating {
 constructor(voyage, history) {
  this.voyage = voyage;
  this.history = history;
 }
 get value() {
  const vpf = this.voyageProfitFactor;
  const vr = this.voyageRisk;
  const chr = this.captainHistoryRisk;
  if (vpf * 3 > (vr + chr * 2)) return "A";
  else return "B";
 }
 get voyageRisk() {
  let result = 1;
  if (this.voyage.length > 4) result += 2;
  if (this.voyage.length > 8) result += this.voyage.length - 8;
  if (["china", "east-indies"].includes(this.voyage.zone)) result += 4;
  return Math.max(result, 0);
 }
 get captainHistoryRisk() {
  let result = 1;
  if (this.history.length < 5) result += 4;
  result += this.history.filter(v => v.profit < 0).length;
  if (this.voyage.zone === "china" &amp;&amp; this.hasChinaHistory) result -= 2;
  return Math.max(result, 0);
 }
 get voyageProfitFactor() {
  let result = 2;

  if (this.voyage.zone === "china") result += 1;
  if (this.voyage.zone === "east-indies") result += 1;
  if (this.voyage.zone === "china" &amp;&amp; this.hasChinaHistory) {
   result += 3;
   if (this.history.length > 10) result += 1;
   if (this.voyage.length > 12) result += 1;
   if (this.voyage.length > 18) result -= 1;
  }
  else {
   if (this.history.length > 8) result += 1;
   if (this.voyage.length > 14) result -= 1;
  }
  return result;
 }
 get hasChinaHistory() {
  return this.history.some(v => "china" === v.zone);
 }
}

于是我就有了一个类,用来安放基础逻辑。现在我需要另建一个空的子类,用来安放与超类不同的行为。

class ExperiencedChinaRating extends Rating {}

然后,建立一个工厂函数,用于在需要时返回变体类。

function createRating(voyage, history) {
 if (voyage.zone === "china" &amp;&amp; history.some(v => "china" === v.zone))
  return new ExperiencedChinaRating(voyage, history);
 else return new Rating(voyage, history);
}

我需要修改所有调用方代码,让它们使用该工厂函数,而不要直接调用构造函数。还好现在调用构造函数的只有 rating 函数一处。

function rating(voyage, history) {
  return createRating(voyage, history).value;
}

有两处行为需要移入子类中。我先处理 captainHistoryRisk 中的逻辑。

class Rating...

get captainHistoryRisk() {
 let result = 1;
 if (this.history.length < 5) result += 4;
 result += this.history.filter(v => v.profit < 0).length;
 if (this.voyage.zone === "china" &amp;&amp; this.hasChinaHistory) result -= 2;
 return Math.max(result, 0);
}

在子类中覆写这个函数。

class ExperiencedChinaRating

get captainHistoryRisk() {
  const result = super.captainHistoryRisk - 2;
  return Math.max(result, 0);
}

class Rating...

get captainHistoryRisk() {
 let result = 1;
 if (this.history.length < 5) result += 4;
 result += this.history.filter(v => v.profit < 0).length;
 if (this.voyage.zone === "china" &amp;&amp; this.hasChinaHistory) result -= 2;
 return Math.max(result, 0);
}

分离 voyageProfitFactor 函数中的变体行为要更麻烦一些。我不能直接从超类中删掉变体行为,因为在超类中还有另一条执行路径。我又不想把整个超类中的函数复制到子类中。

class Rating...

get voyageProfitFactor() {
 let result = 2;

 if (this.voyage.zone === "china") result += 1;
 if (this.voyage.zone === "east-indies") result += 1;
 if (this.voyage.zone === "china" &amp;&amp; this.hasChinaHistory) {
  result += 3;
  if (this.history.length > 10) result += 1;
  if (this.voyage.length > 12) result += 1;
  if (this.voyage.length > 18) result -= 1;
 }
 else {
  if (this.history.length > 8) result += 1;
  if (this.voyage.length > 14) result -= 1;
 }
 return result;
}

所以我先用提炼函数(106)将整个条件逻辑块提炼出来。

class Rating...

get voyageProfitFactor() {
 let result = 2;

 if (this.voyage.zone === "china") result += 1;
 if (this.voyage.zone === "east-indies") result += 1;
 result += this.voyageAndHistoryLengthFactor;
 return result;
}
get voyageAndHistoryLengthFactor() {
 let result = 0;
 if (this.voyage.zone === "china" &amp;&amp; this.hasChinaHistory) {
  result += 3;
  if (this.history.length > 10) result += 1;
  if (this.voyage.length > 12) result += 1;
  if (this.voyage.length > 18) result -= 1;
 }
 else {
  if (this.history.length > 8) result += 1;
  if (this.voyage.length > 14) result -= 1;
 }
 return result;
}

函数名中出现“And”字样是一个很不好的味道,不过我会暂时容忍它,先聚焦子类化操作。

class Rating...

get voyageAndHistoryLengthFactor() {
 let result = 0;
 if (this.history.length > 8) result += 1;
 if (this.voyage.length > 14) result -= 1;
 return result;
}

class ExperiencedChinaRating...

get voyageAndHistoryLengthFactor() {
 let result = 0;
 result += 3;
 if (this.history.length > 10) result += 1;
 if (this.voyage.length > 12) result += 1;
 if (this.voyage.length > 18) result -= 1;
 return result;
}

严格说来,重构到这儿就结束了——我已经把变体行为分离到了子类中,超类的逻辑理解和维护起来更简单了,只有在进入子类代码时我才需要操心变体逻辑。子类的代码表述了它与超类的差异。

但我觉得至少应该谈谈如何处理这个丑陋的新函数。引入一个函数以便子类覆写,这在处理这种“基础和变体”的继承关系时是常见操作。但这样一个难看的函数只会妨碍——而不是帮助——别人理解其中的逻辑。

函数名中的“And”字样说明其中包含了两件事,所以我觉得应该将它们分开。我会用提炼函数(106)把“历史航行数”(history length)的相关逻辑提炼出来。这一步提炼在超类和子类中都要发生,我首先从超类开始。

class Rating...

get voyageAndHistoryLengthFactor() {
 let result = 0;
 result += this.historyLengthFactor;
 if (this.voyage.length > 14) result -= 1;
 return result;
}
get historyLengthFactor() {
 return (this.history.length > 8) ? 1 : 0;
}

然后在子类中也如法炮制。

class ExperiencedChinaRating...

get voyageAndHistoryLengthFactor() {
 let result = 0;
 result += 3;
 result += this.historyLengthFactor;
 if (this.voyage.length > 12) result += 1;
 if (this.voyage.length > 18) result -= 1;
 return result;
}
get historyLengthFactor() {
 return (this.history.length > 10) ? 1 : 0;
}

然后在超类中使用搬移语句到调用者(217)。

class Rating...

get voyageProfitFactor() {
 let result = 2;
 if (this.voyage.zone === "china") result += 1;
 if (this.voyage.zone === "east-indies") result += 1;
 result += this.historyLengthFactor;
 result += this.voyageAndHistoryLengthFactor;
 return result;
}

get voyageAndHistoryLengthFactor() {
 let result = 0;
 result += this.historyLengthFactor;
 if (this.voyage.length > 14) result -= 1;
 return result;
}

class ExperiencedChinaRating...

get voyageAndHistoryLengthFactor() {
 let result = 0;
 result += 3;
 result += this.historyLengthFactor;
 if (this.voyage.length > 12) result += 1;
 if (this.voyage.length > 18) result -= 1;
 return result;
}

再用函数改名(124)改掉这个难听的名字。

class Rating...

get voyageProfitFactor() {
 let result = 2;
 if (this.voyage.zone === "china") result += 1;
 if (this.voyage.zone === "east-indies") result += 1;
 result += this.historyLengthFactor;
 result += this.voyageLengthFactor;
 return result;
}

get voyageLengthFactor() {
 return (this.voyage.length > 14) ? - 1: 0;
}

改为三元表达式,以简化 voyageLengthFactor 函数。

class ExperiencedChinaRating...

get voyageLengthFactor() {
 let result = 0;
 result += 3;
 if (this.voyage.length > 12) result += 1;
 if (this.voyage.length > 18) result -= 1;
 return result;
}

最后一件事:在“航程数”(voyage length)因素上加上 3 分,我认为这个逻辑不合理,应该把这 3 分加在最终的结果上。

class ExperiencedChinaRating...

get voyageProfitFactor() {
  return super.voyageProfitFactor + 3;
}

get voyageLengthFactor() {
  let result = 0;
  result += 3;
  if (this.voyage.length > 12) result += 1;
  if (this.voyage.length > 18) result -= 1;
  return result;
}

重构结束,我得到了如下代码。首先,我有一个基本的 Rating 类,其中不考虑与“中国经验”相关的复杂性:

class Rating {
 constructor(voyage, history) {
  this.voyage = voyage;
  this.history = history;
 }
 get value() {
  const vpf = this.voyageProfitFactor;
  const vr = this.voyageRisk;
  const chr = this.captainHistoryRisk;
  if (vpf * 3 > (vr + chr * 2)) return "A";
  else return "B";
 }
 get voyageRisk() {
  let result = 1;
  if (this.voyage.length > 4) result += 2;
  if (this.voyage.length > 8) result += this.voyage.length - 8;
  if (["china", "east-indies"].includes(this.voyage.zone)) result += 4;
  return Math.max(result, 0);
 }
 get captainHistoryRisk() {
  let result = 1;
  if (this.history.length < 5) result += 4;
  result += this.history.filter(v => v.profit < 0).length;
  return Math.max(result, 0);
 }
 get voyageProfitFactor() {
  let result = 2;
  if (this.voyage.zone === "china") result += 1;
  if (this.voyage.zone === "east-indies") result += 1;
  result += this.historyLengthFactor;
  result += this.voyageLengthFactor;
  return result;
 }
 get voyageLengthFactor() {
  return (this.voyage.length > 14) ? - 1: 0;
 }
 get historyLengthFactor() {
  return (this.history.length > 8) ? 1 : 0;
 }
}

与“中国经验”相关的代码则清晰表述出在基本逻辑之上的一系列变体逻辑:

class ExperiencedChinaRating extends Rating {
 get captainHistoryRisk() {
  const result = super.captainHistoryRisk - 2;
  return Math.max(result, 0);
 }
 get voyageLengthFactor() {
  let result = 0;
  if (this.voyage.length > 12) result += 1;
  if (this.voyage.length > 18) result -= 1;
  return result;
 }
 get historyLengthFactor() {
  return (this.history.length > 10) ? 1 : 0;
 }
 get voyageProfitFactor() {
  return super.voyageProfitFactor + 3;
 }
}

10.5 引入特例(Introduce Special Case)

曾用名:引入 Null 对象(Introduce Null Object)

if (aCustomer === "unknown") customerName = "occupant";


class UnknownCustomer {
  get name() {return "occupant";}

动机

一种常见的重复代码是这种情况:一个数据结构的使用者都在检查某个特殊的值,并且当这个特殊值出现时所做的处理也都相同。如果我发现代码库中有多处以同样方式应对同一个特殊值,我就会想要把这个处理逻辑收拢到一处。

处理这种情况的一个好办法是使用“特例”(Special Case)模式:创建一个特例元素,用以表达对这种特例的共用行为的处理。这样我就可以用一个函数调用取代大部分特例检查逻辑。

特例有几种表现形式。如果我只需要从这个对象读取数据,可以提供一个字面量对象(literal object),其中所有的值都是预先填充好的。如果除简单的数值之外还需要更多的行为,就需要创建一个特殊对象,其中包含所有共用行为所对应的函数。特例对象可以由一个封装类来返回,也可以通过变换插入一个数据结构。

一个通常需要特例处理的值就是 null,这也是这个模式常被叫作“Null 对象”(Null Object)模式的原因——我喜欢说:Null 对象是特例的一种特例。

做法

我们从一个作为容器的数据结构(或者类)开始,其中包含一个属性,该属性就是我们要重构的目标。容器的客户端每次使用这个属性时,都需要将其与某个特例值做比对。我们希望把这个特例值替换为代表这种特例情况的类或数据结构。

给重构目标添加检查特例的属性,令其返回 false。

创建一个特例对象,其中只有检查特例的属性,返回 true。

对“与特例值做比对”的代码运用提炼函数(106),确保所有客户端都使用这个新函数,而不再直接做特例值的比对。

将新的特例对象引入代码中,可以从函数调用中返回,也可以在变换函数中生成。

修改特例比对函数的主体,在其中直接使用检查特例的属性。

测试。

使用函数组合成类(144)或函数组合成变换(149),把通用的特例处理逻辑都搬移到新建的特例对象中。

特例类对于简单的请求通常会返回固定的值,因此可以将其实现为字面记录(literal record)。

对特例比对函数使用内联函数(115),将其内联到仍然需要的地方。

范例

一家提供公共事业服务的公司将自己的服务安装在各个场所(site)。

class Site...

get customer() {return this._customer;}

代表“顾客”的 Customer 类有多个属性,我只考虑其中 3 个。

class Customer...

get name()           {...}
get billingPlan()    {...}
set billingPlan(arg) {...}
get paymentHistory() {...}

大多数情况下,一个场所会对应一个顾客,但有些场所没有与之对应的顾客,可能是因为之前的住户搬走了,而新搬来的住户我还不知道是谁。这种情况下,数据记录中的 customer 字段会被填充为字符串"unknown"。因为这种情况时有发生,所以 Site 对象的客户端必须有办法处理“顾客未知”的情况。下面是一些示例代码片段。

客户端 1...

const aCustomer = site.customer;
// ... lots of intervening code ...
let customerName;
if (aCustomer === "unknown") customerName = "occupant";
else customerName = aCustomer.name;

客户端 2...

const plan =
  aCustomer === "unknown" ? registry.billingPlans.basic : aCustomer.billingPlan;

客户端 3...

if (aCustomer !== "unknown") aCustomer.billingPlan = newPlan;

客户端 4...

const weeksDelinquent =
  aCustomer === "unknown"
    ? 0
    : aCustomer.paymentHistory.weeksDelinquentInLastYear;

浏览整个代码库,我看到有很多使用 Site 对象的客户端在处理“顾客未知”的情况,大多数都用了同样的应对方式:用"occupant"(居民)作为顾客名,使用基本的计价套餐,并认为这家顾客没有欠费。到处都在检查这种特例,再加上对特例的处理方式高度一致,这些现象告诉我:是时候使用特例对象(Special Case Object)模式了。

我首先给 Customer 添加一个函数,用于指示“这个顾客是否未知”。

class Customer...

get isUnknown() {return false;}

然后我给“未知的顾客”专门创建一个类。

class UnknownCustomer {
  get isUnknown() {
    return true;
  }
}

注意,我没有把 UnknownCustomer 类声明为 Customer 的子类。在其他编程语言(尤其是静态类型的编程语言)中,我会需要继承关系。但 JavaScript 是一种动态类型语言,按照它的子类化规则,这里不声明继承关系反而更好。

下面就是麻烦之处了。我必须在所有期望得到"unknown"值的地方返回这个新的特例对象,并修改所有检查"unknown"值的地方,令其使用新的 isUnknown 函数。一般而言,我总是希望细心安排修改过程,使我可以每次做一点小修改,然后马上测试。但如果我修改了 Customer 类,使其返回 UnknownCustomer 对象(而非"unknown"字符串),那么就必须同时修改所有客户端,让它们不要检查"unknown"字符串,而是调用 isUnknown 函数——这两个修改必须一次完成。我感觉这一大步修改就像一大块难吃的食物一样难以下咽。

还好,遇到这种困境时,有一个常用的技巧可以帮忙。如果有一段代码需要在很多地方做修改(例如我们这里的“与特例做比对”的代码),我会先对其使用提炼函数(106)。

function isUnknown(arg) {
  if (!(arg instanceof Customer || arg === "unknown"))
    throw new Error(`investigate bad value: <${arg}>`);
  return arg === "unknown";
}

我会放一个陷阱,捕捉意料之外的值。如果在重构过程中我犯了错误,引入了奇怪的行为,这个陷阱会帮我发现。

现在,凡是检查未知顾客的地方,都可以改用这个函数了。我可以逐一修改这些地方,每次修改之后都可以执行测试。

客户端 1...

let customerName;
if (isUnknown(aCustomer)) customerName = "occupant";
else customerName = aCustomer.name;

没用多久,就全部修改完了。

客户端 2...

const plan = isUnknown(aCustomer)
  ? registry.billingPlans.basic
  : aCustomer.billingPlan;

客户端 3...

if (!isUnknown(aCustomer)) aCustomer.billingPlan = newPlan;

客户端 4...

const weeksDelinquent = isUnknown(aCustomer)
  ? 0
  : aCustomer.paymentHistory.weeksDelinquentInLastYear;

将所有调用处都改为使用 isUnknown 函数之后,就可以修改 Site 类,令其在顾客未知时返回 UnknownCustomer 对象。

class Site...

get customer() {
  return (this._customer === "unknown") ? new UnknownCustomer() : this._customer;
}

然后修改 isUnknown 函数的判断逻辑。做完这步修改之后我可以做一次全文搜索,应该没有任何地方使用"unknown"字符串了。

客户端 1...

function isUnknown(arg) {
  if (!(arg instanceof Customer || arg instanceof UnknownCustomer))
    throw new Error(`investigate bad value: <${arg}>`);
  return arg.isUnknown;
}

测试,以确保一切运转如常。

现在,有趣的部分开始了。我可以逐一查看客户端检查特例的代码,看它们处理特例的逻辑,并考虑是否能用函数组合成类(144)将其替换为一个共同的、符合预期的值。此刻,有多处客户端代码用字符串"occupant"来作为未知顾客的名字,就像下面这样。

客户端 1...

let customerName;
if (isUnknown(aCustomer)) customerName = "occupant";
else customerName = aCustomer.name;

我可以在 UnknownCustomer 类中添加一个合适的函数。

class UnknownCustomer...

get name() {return "occupant";}

然后我就可以去掉所有条件代码。

客户端 1...

const customerName = aCustomer.name;

测试通过之后,我可能会用内联变量(123)把 customerName 变量也消除掉。

接下来处理代表“计价套餐”的 billingPlan 属性。

客户端 2...

const plan = isUnknown(aCustomer)
  ? registry.billingPlans.basic
  : aCustomer.billingPlan;

客户端 3...

if (!isUnknown(aCustomer)) aCustomer.billingPlan = newPlan;

对于读取该属性的行为,我的处理方法跟前面处理 name 属性一样——找到通用的应对方式,并在 UnknownCustomer 中使用之。至于对该属性的写操作,当前的代码没有对未知顾客调用过设值函数,所以在特例对象中,我会保留设值函数,但其中什么都不做。

class UnknownCustomer...

get billingPlan()  {return registry.billingPlans.basic;}
set billingPlan(arg) { /* ignore */ }

读取的例子...

const plan = aCustomer.billingPlan;

更新的例子...

aCustomer.billingPlan = newPlan;

特例对象是值对象,因此应该始终是不可变的,即便它们替代的原对象本身是可变的。

最后一个例子则更麻烦一些,因为特例对象需要返回另一个对象,后者又有其自己的属性。

客户端...

const weeksDelinquent = isUnknown(aCustomer)
  ? 0
  : aCustomer.paymentHistory.weeksDelinquentInLastYear;

一般的原则是:如果特例对象需要返回关联对象,被返回的通常也是特例对象。所以,我需要创建一个代表“空支付记录”的特例类 NullPaymentHistory。

class UnknownCustomer...

get paymentHistory() {return new NullPaymentHistory();}

class NullPaymentHistory...

get weeksDelinquentInLastYear() {return 0;}

客户端...

const weeksDelinquent = aCustomer.paymentHistory.weeksDelinquentInLastYear;

我继续查看客户端代码,寻找是否有能用多态行为取代的地方。但也会有例外情况——客户端不想使用特例对象提供的逻辑,而是想做一些别的处理。我可能有 23 处客户端代码用"occupant"作为未知顾客的名字,但还有一处用了别的值。

客户端...

const name = !isUnknown(aCustomer) ? aCustomer.name : "unknown occupant";

这种情况下,我只能在客户端保留特例检查的逻辑。我会对其做些修改,让它使用 aCustomer 对象身上的 isUnknown 函数,也就是对全局的 isUnknown 函数使用内联函数(115)。

客户端...

const name = aCustomer.isUnknown ? "unknown occupant" : aCustomer.name;

处理完所有客户端代码后,全局的 isUnknown 函数应该没人再调用了,可以用移除死代码(237)将其移除。

范例:使用对象字面量

我们在上面处理的其实是一些很简单的值,却要创建一个这样的类,未免有点儿大动干戈。但在上面这个例子中,我必须创建这样一个类,因为 Customer 类是允许使用者更新其内容的。但如果面对一个只读的数据结构,我就可以改用字面量对象(literal object)。

还是前面这个例子——几乎完全一样,除了一件事:这次没有客户端对 Customer 对象做更新操作:

class Site...

get customer() {return this._customer;}

class Customer...

get name()           {...}
get billingPlan()    {...}
set billingPlan(arg) {...}
get paymentHistory() {...}

客户端 1...

const aCustomer = site.customer;
// ... lots of intervening code ...
let customerName;
if (aCustomer === "unknown") customerName = "occupant";
else customerName = aCustomer.name;

客户端 2...

const plan =
  aCustomer === "unknown" ? registry.billingPlans.basic : aCustomer.billingPlan;

客户端 3...

const weeksDelinquent =
  aCustomer === "unknown"
    ? 0
    : aCustomer.paymentHistory.weeksDelinquentInLastYear;

和前面的例子一样,我首先在 Customer 中添加 isUnknown 属性,并创建一个包含同名字段的特例对象。这次的区别在于,特例对象是一个字面量。

class Customer...

get isUnknown() {return false;}

顶层作用域...

function createUnknownCustomer() {
  return {
    isUnknown: true,
  };
}

然后我对检查特例的条件逻辑运用提炼函数(106)。

function isUnknown(arg) {
  return arg === "unknown";
}

客户端 1...

let customerName;
if (isUnknown(aCustomer)) customerName = "occupant";
else customerName = aCustomer.name;

客户端 2...

const plan = isUnknown(aCustomer)
  ? registry.billingPlans.basic
  : aCustomer.billingPlan;

客户端 3...

const weeksDelinquent = isUnknown(aCustomer)
  ? 0
  : aCustomer.paymentHistory.weeksDelinquentInLastYear;

修改 Site 类和做条件判断的 isUnknown 函数,开始使用特例对象。

class Site...

get customer() {
  return (this._customer === "unknown") ? createUnknownCustomer() : this._customer;
}

顶层作用域...

function isUnknown(arg) {
  return arg.isUnknown;
}

然后把“以标准方式应对特例”的地方都替换成使用特例字面量的值。首先从“名字”开始:

function createUnknownCustomer() {
  return {
    isUnknown: true,
    name: "occupant",
  };
}

客户端 1...

const customerName = aCustomer.name;

接着是“计价套餐”:

function createUnknownCustomer() {
  return {
    isUnknown: true,
    name: "occupant",
    billingPlan: registry.billingPlans.basic,
  };
}

客户端 2...

const plan = aCustomer.billingPlan;

同样,我可以在字面量对象中创建一个嵌套的空支付记录对象:

function createUnknownCustomer() {
  return {
    isUnknown: true,
    name: "occupant",
    billingPlan: registry.billingPlans.basic,
    paymentHistory: {
      weeksDelinquentInLastYear: 0,
    },
  };
}

客户端 3...

const weeksDelinquent = aCustomer.paymentHistory.weeksDelinquentInLastYear;

如果使用了这样的字面量,应该使用诸如 Object.freeze 的方法将其冻结,使其不可变。通常,我还是喜欢用类多一点。

范例:使用变换

前面两个例子都涉及了一个类,其实本重构手法也同样适用于记录,只要增加一个变换步骤即可。

假设我们的输入是一个简单的记录结构,大概像这样:

{
 name: "Acme Boston",
 location: "Malden MA",
 // more site details
 customer: {
  name: "Acme Industries",
  billingPlan: "plan-451",
  paymentHistory: {
   weeksDelinquentInLastYear: 7
   //more
  },
  // more
 }
}

有时顾客的名字未知,此时标记的方式与前面一样:将 customer 字段标记为字符串"unknown"。

{
name: "Warehouse Unit 15",
location: "Malden MA",
// more site details
customer: "unknown",
}

客户端代码也类似,会检查“未知顾客”的情况:

客户端 1...

const site = acquireSiteData();
const aCustomer = site.customer;
// ... lots of intervening code ...
let customerName;
if (aCustomer === "unknown") customerName = "occupant";
else customerName = aCustomer.name;

客户端 2...

const plan =
  aCustomer === "unknown" ? registry.billingPlans.basic : aCustomer.billingPlan;

客户端 3...

const weeksDelinquent =
  aCustomer === "unknown"
    ? 0
    : aCustomer.paymentHistory.weeksDelinquentInLastYear;

我首先要让 Site 数据结构经过一次变换,目前变换中只做了深复制,没有对数据做任何处理。

客户端 1...

const rawSite = acquireSiteData();
const site = enrichSite(rawSite);
const aCustomer = site.customer;
// ... lots of intervening code ...
let customerName;
if (aCustomer === "unknown") customerName = "occupant";
else customerName = aCustomer.name;

function enrichSite(inputSite) {
  return _.cloneDeep(inputSite);
}

然后对“检查未知顾客”的代码运用提炼函数(106)。

function isUnknown(aCustomer) {
  return aCustomer === "unknown";
}

客户端 1...

const rawSite = acquireSiteData();
const site = enrichSite(rawSite);
const aCustomer = site.customer;
// ... lots of intervening code ...
let customerName;
if (isUnknown(aCustomer)) customerName = "occupant";
else customerName = aCustomer.name;

客户端 2...

const plan = isUnknown(aCustomer)
  ? registry.billingPlans.basic
  : aCustomer.billingPlan;

客户端 3...

const weeksDelinquent = isUnknown(aCustomer)
  ? 0
  : aCustomer.paymentHistory.weeksDelinquentInLastYear;

然后开始对 Site 数据做增强,首先是给 customer 字段加上 isUnknown 属性。

function enrichSite(aSite) {
  const result = _.cloneDeep(aSite);
  const unknownCustomer = {
    isUnknown: true,
  };

  if (isUnknown(result.customer)) result.customer = unknownCustomer;
  else result.customer.isUnknown = false;
  return result;
}

随后修改检查特例的条件逻辑,开始使用新的属性。原来的检查逻辑也保留不动,所以现在的检查逻辑应该既能应对原来的 Site 数据,也能应对增强后的 Site 数据。

function isUnknown(aCustomer) {
  if (aCustomer === "unknown") return true;
  else return aCustomer.isUnknown;
}

测试,确保一切正常,然后针对特例使用函数组合成变换(149)。首先把“未知顾客的名字”的处理逻辑搬进增强函数。

function enrichSite(aSite) {
  const result = _.cloneDeep(aSite);
  const unknownCustomer = {
    isUnknown: true,
    name: "occupant",
  };

  if (isUnknown(result.customer)) result.customer = unknownCustomer;
  else result.customer.isUnknown = false;
  return result;
}

客户端 1...

const rawSite = acquireSiteData();
const site = enrichSite(rawSite);
const aCustomer = site.customer;
// ... lots of intervening code ...
const customerName = aCustomer.name;

测试,然后是“未知顾客的计价套餐”的处理逻辑。

function enrichSite(aSite) {
  const result = _.cloneDeep(aSite);
  const unknownCustomer = {
    isUnknown: true,
    name: "occupant",
    billingPlan: registry.billingPlans.basic,
  };

  if (isUnknown(result.customer)) result.customer = unknownCustomer;
  else result.customer.isUnknown = false;
  return result;
}

客户端 2...

const plan = aCustomer.billingPlan;

再次测试,然后处理最后一处客户端代码。

function enrichSite(aSite) {
  const result = _.cloneDeep(aSite);
  const unknownCustomer = {
    isUnknown: true,
    name: "occupant",
    billingPlan: registry.billingPlans.basic,
    paymentHistory: {
      weeksDelinquentInLastYear: 0,
    },
  };

  if (isUnknown(result.customer)) result.customer = unknownCustomer;
  else result.customer.isUnknown = false;
  return result;
}

客户端 3...

const weeksDelinquent = aCustomer.paymentHistory.weeksDelinquentInLastYear;

10.6 引入断言(Introduce Assertion)

  if (this.discountRate)
  base = base - (this.discountRate * base);


  assert(this.discountRate>= 0);
if (this.discountRate)
  base = base - (this.discountRate * base);

动机

常常会有这样一段代码:只有当某个条件为真时,该段代码才能正常运行。例如,平方根计算只对正值才能进行,又例如,某个对象可能假设一组字段中至少有一个不等于 null。

这样的假设通常并没有在代码中明确表现出来,你必须阅读整个算法才能看出。有时程序员会以注释写出这样的假设,而我要介绍的是一种更好的技术——使用断言明确标明这些假设。

断言是一个条件表达式,应该总是为真。如果它失败,表示程序员犯了错误。断言的失败不应该被系统任何地方捕捉。整个程序的行为在有没有断言出现的时候都应该完全一样。实际上,有些编程语言中的断言可以在编译期用一个开关完全禁用掉。

我常看见有人鼓励用断言来发现程序中的错误。这固然是一件好事,但却不是使用断言的唯一理由。断言是一种很有价值的交流形式——它们告诉阅读者,程序在执行到这一点时,对当前状态做了何种假设。另外断言对调试也很有帮助。而且,因为它们在交流上很有价值,即使解决了当下正在追踪的错误,我还是倾向于把断言留着。自测试的代码降低了断言在调试方面的价值,因为逐步逼近的单元测试通常能更好地帮助调试,但我仍然看重断言在交流方面的价值。

做法

如果你发现代码假设某个条件始终为真,就加入一个断言明确说明这种情况。

因为断言应该不会对系统运行造成任何影响,所以“加入断言”永远都应该是行为保持的。

范例

下面是一个简单的例子:折扣。顾客(customer)会获得一个折扣率(discount rate),可以用于所有其购买的商品。

class Customer...

applyDiscount(aNumber) {
  return (this.discountRate)
    ? aNumber - (this.discountRate * aNumber)
    : aNumber;
}

这里有一个假设:折扣率永远是正数。我可以用断言明确标示出这个假设。但在一个三元表达式中没办法很简单地插入断言,所以我首先要把这个表达式转换成 if-else 的形式。

class Customer...

applyDiscount(aNumber) {
  if (!this.discountRate) return aNumber;
  else return aNumber - (this.discountRate * aNumber);
}

现在我就可以轻松地加入断言了。

class Customer...

applyDiscount(aNumber) {
  if (!this.discountRate) return aNumber;
  else {
    assert(this.discountRate >= 0);
    return aNumber - (this.discountRate * aNumber);
  }
}

对这个例子而言,我更愿意把断言放在设值函数上。如果在 applyDiscount 函数处发生断言失败,我还得先费力搞清楚非法的折扣率值起初是从哪儿放进去的。

class Customer...

set discountRate(aNumber) {
  assert(null === aNumber || aNumber >= 0);
  this._discountRate = aNumber;
}

真正引起错误的源头有可能很难发现——也许是输入数据中误写了一个减号,也许是某处代码做数据转换时犯了错误。像这样的断言对于发现错误源头特别有帮助。

注意,不要滥用断言。我不会使用断言来检查所有“我认为应该为真”的条件,只用来检查“必须为真”的条件。滥用断言可能会造成代码重复,尤其是在处理上面这样的条件逻辑时。所以我发现,很有必要去掉条件逻辑中的重复,通常可以借助提炼函数(106)手法。

我只用断言预防程序员的错误。如果要从某个外部数据源读取数据,那么所有对输入值的检查都应该是程序的一等公民,而不能用断言实现——除非我对这个外部数据源有绝对的信心。断言是帮助我们跟踪 bug 的最后一招,所以,或许听来讽刺,只有当我认为断言绝对不会失败的时候,我才会使用断言。

results matching ""

    No results matching ""