重构,改善代码的既有设计——简化条件逻辑

简化条件逻辑

程序的大部分威力来自条件逻辑,但是程序的复杂度也大多来自条件逻辑。

分解条件表达式

动机

程序之中,复炸的条件逻辑是最常导致复杂度上升的地点之一,我必须编写代码来检查不同的条件分支,更具不同条件做不同的事,然后我就会得到一个相当长的函数。大型函数本身就会使代码的可读性下降,而条件逻辑会使代码更难阅读。

和任何大块头代码一样,我可以将它分解成多个独立的函数,将每个分支条件分解成新函数还可以带来更多好处:可以突出条件逻辑,更清楚的表明每个分支的作用,并且突出每个分支的原因。

做法

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

范例

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

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

我把条件判断提炼到一个独立的函数中,然后提炼条件判断真和假的分支

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

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

function summerCharge() {
  return quantity * plan.summerRage;
}

function regularCharge() {
  return quantity * plan.regularRage + plan.regularServiceCharge;
}

提炼完成后,可以用三元运算符重新安排条件语句

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

合并条件表达式

动机

有时我会发想这样一串条件检查:检查条件各不相同,最终行为却一致。如果发现这种情况,就应该使用“逻辑或”和“逻辑与”将它们合并为一个条件表达式。这样做有两个好处:第一是使这一次条件检查的用意更清晰,第二是这项重构往往可以为使用提炼函数做准备。

做法

  1. 确定这些条件表达式都没有副作用。
  2. 使用适当的逻辑运算符,将两个相关条件表达式合并成一个。
  3. 测试。
  4. 重复前面的合并过程,直到所有相关的条件表达式都合并到一起。
  5. 可以考虑对合并后的条件表达式实施提炼函数。

范例

有下面一段代码

function disabilityAmount(anEmployee) {
  if (anEmployee.seniority < 2) return 0;
  if (anEmployee.monthsDisabled > 12) return 0;
  if (anEmployee.isPartTime) return 0;
}

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

function disabilityAmount(anEmployee) {
  if (isNotEligableForDiasblility()) return 0;
}

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

范例:使用逻辑与

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

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

使用逻辑与运算符将其合并

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

以卫语句取代嵌套条件表达式

动机

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

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

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

做法

  1. 选中最外层需要被替换的条件逻辑,将其替换为卫语句。
  2. 测试。
  3. 有需要的话,重复上述步骤。
  4. 如果所有卫语句都引发相同的结果,可以使用合并条件表达式合并。

范例

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

function payAmount(employee) {
  let result;
  if (employee.isSeparated) {
    result = {
      amount: 0,
      reasonCode: "SEP",
    };
  } else {
    if (employee.isRetired) {
      result = {
        amount: 0,
        reasonCode: "RET",
      };
    } else {
      // 计算账单的逻辑
      lorem.ipsum(dolor.ditAmet);
      consectetur(adipiscing).elit();
      sed.do.eiusmod = tempor.incididunt.ut(labore) && dolore(magna.aliqua);
      ut.enim.ad(minim.veniam);
      result = someFinalComputation();
    }
  }
  return result;
}

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

function payAmount(employee) {
  if (employee.isSeparated)
    return {
      amount: 0,
      reasonCode: "SEP",
    };
  if (employee.isRetired)
    return {
      amount: 0,
      reasonCode: "RET",
    };
  // 计算账单的逻辑
  lorem.ipsum(dolor.ditAmet);
  consectetur(adipiscing).elit();
  sed.do.eiusmod = tempor.incididunt.ut(labore) && dolore(magna.aliqua);
  ut.enim.ad(minim.veniam);
  return someFinalComputation();
}

范例:讲条件反转

我们常常可以将条件表达式反转,从而实现以卫语句取代嵌套条件表达式

function adjustedCapital(anInstrument) {
  let result = 0;
  if (anInstrument.capital > 0) {
    if (anInstrument.capital > 0 && 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.capital <= 0 || anInstrument.duration <= 0) return result;
  result =
    (anInstrument.income / anInstrument.duration) *
    anInstrument.adjustmentFactor;
  return result;
}

两条语句引发的结果一样,所以我可以使用合并条件表达式将其合并

if (
  anInstrument.capital <= 0 ||
  anInstrument.capital <= 0 ||
  anInstrument.duration <= 0
)
  return result;

此时 result 为一个可变变量,我可以彻底移除它避免承担两份责任。

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

以多态取代条件表达式

动机

很多时候,我发现可以将条件逻辑拆分到不同的场景(或者叫高阶用例),从而拆解复杂的条件逻辑额。这种拆分有时用条件逻辑本身的结构就足以表达,但是用类和多态能把逻辑的拆分表述地更清晰。

一个常见的场景是:我可以构造一组类型,每个类型处理各自的一种条件逻辑。例如,图书、音乐、食品的处理方式不同,这是因为它们分属不同类型的商品。

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

多态是面向对象编程的关键特性之一,跟其他一切有用的特性一样,它也很容易被滥用,但如果发现如前所述的复杂条件逻辑,多态是改善这种情况的有力工具。

做法

  1. 如果现有的类尚不具备多态行为,就用工厂函数创建,令工厂函数返回恰当的对象实例。
  2. 在调用方代码中使用工厂函数获得对象实例。
  3. 将带有条件逻辑的函数移到超类中。
  4. 任选一个子类,在其中建立一个函数,使之覆写超类中容纳条件表达式的那个函数。将与该子类相关的条件表达式分支复制到新函数中,并对它进行适当调整。
  5. 重复上述过程,处理其他条件分支。
  6. 在超类函数中保留默认情况的逻辑。或者,如果超类是抽象的,就把该函数声明为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 'NorwegianVlueJParror':
            return (bird.voltage > 100) ? 'scorched' : 'beautiful'
        default:
            return 'unknown'
    }
}

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

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

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

function airSpeedVelocity(bird) {
    return new Bird(bird).airSpeedVelocity
}

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

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

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 'NorwegianVlueJParror':
            return new NorwegianBlueParrot(bird)
        default:
            return new Bird(bird)
    }
}

class Bird...
    // 超类函数用来处理默认情况
    get plumage() {
        return 'unknow'
    }
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 * bird.numberOfCoconuts
    }
}

class NorwegianVlueJParror extends Bird {
    get plumage() {
        return (this.voltage > 100) ? 'scorched' : 'beautiful'
    }
    get airSpeedVelocity() {
        return (bird.isNailed) ? 0 : 10 + bird.voltage / 10
    }
}

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

前面例子中,‘鸟’的类型体系是一个清晰的泛化体系:超类是抽象的“鸟”,子类是各种具体的鸟,但这并不是事件中使用继承的唯一方式。实际上这种方式很可能不是最常用或最好的方式。另一种使用继承的情况是:我想表达某个对象与另一个对象大体相似,但又有一些不同之处。

下面有这样一个例子,有一家评级机构,要对远洋航船的航行进行投资凭借,这家评级机构会给出“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" && 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" && hasChina(history)) {
    result += 3;
    if (history.length > 10) result += 4;
    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;
}

// 调用方代码
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) {
  return new Rating(voyage, history).value;
}

class Rating {
  constructor(voyage, history) {
    this.voyage = voyage;
    this.history = history;
  }

  get value() {
    const vpf = thsi.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(thisvoyage.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" && this.hasChina(history)) 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" && this.hasChina(history)) {
      result += 3;
      if (this.history.length > 10) result += 4;
      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 thsi.history.some((v) => "china" === v.zone);
  }
}

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

// 新建一个工厂函数用于在需要时返回变体类
function createRating(voyage, history) {
  if (voyage.zone === "china" && history.some((v) => "china" === v.zone))
    return new ExperiencedChinaRating(voyage, history);
  else return new Rating(voyage, history);
}

// 现在我要修改所有调用方代码,让它们使用该工厂函数,而不要直接调用构造函数。
function rating(voyage, history) {
  return createRating(voyage, history).value;
}

class ExperiencedChinaRating extends Rating {
  // 在子类中覆写 captainHistoryRisk 同时在超类中去除相同逻辑
  get captainHistoryRisk() {
    const result = super.captainHistoryRisk - 2;
    return Math.max(result, 0);
  }
}

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

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 += 4
        if (this.voyage.length > 12) result += 1
        if (this.voyage.length > 18) result -= 1
        return result
    }

严格来说,重构到这里已经结束了,但是我还想处理这个丑陋的新函数。这样一个难看的函数只会妨碍,而不是帮助别人理解其中的逻辑。

函数名中的 and 表示其中包含了两件事,所以我觉得应该将他们分开,我会用提炼函数历史航行数的相关逻辑提炼出来。

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

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

此时应该把超类中使用搬移语句到调用者

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
    }

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
}

引入特例

动机

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

处理这种情况的一个好办法是使用“特例”模式:创建一个特例元素,用以表达对这种特例的公用行为的处理。

做法

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

  1. 给重构目标添加检查特例的属性。令其返回false
  2. 创建一个特里对象,其中只有检查特例的属性,返回ture
  3. 与特例值做比对的代码运用提炼函数,确保所有客户端都是用这个新函数,而不再直接做特例值的比对。
  4. 将新的特里对象引入代码中,可以从函数调用中返回,也可以在变换函数中生成。
  5. 测试。
  6. 使用函数组合类函数组合成变换,把通用的特例处理逻辑都搬移到新建的特里对象中。
  7. 对特例比对函数使用内联函数,将其内联到仍然需要的地方。

范例

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

class Site...
    get customer() {
        return this._customer
    }

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 (aCustoemr === "unknown") custoemrName = "occupant";
else customerName = aCustomer.name;

// 客户端2
const plan =
  aCustoemr === "unknown" ? resistry.billingPlans.basic : aCustomer.billingPlan;

// 客户端3
if (aCustoemr !== "unknown") aCustomer.billingPlan = newPlan;

// 客户端4
const weeksDelinquent =
  aCustomer === "unknown"
    ? 0
    : aCustomer.paymentHistory.weeksDelinquentInLastYear;

浏览整个代码库,我看到有很多使用 site 对象的客户端在处理客户未知的情况下,大多数使用了同样的应对方式。到处都在检查这种特例,这些现象告诉我:是时候使用特例对象模式了

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

class Customer...
    get isUnknown() {
        return false
    }

// 然后我给”未知的顾客“专门创建一个类
// 注意这里没有声明为 Customer 的子类,在静态类型的编程语言中,需要声明为子类
class UnknownCustomer {
    get isUnknown() {
        return true
    }
}

但是改成这样我就需要将客户端所有期望得到 unknown 值的地方返回这个新的特例对象,并修改所有检查 unknown 值的地方,令其使用 isUnknown 函数。还好,遇到这种困境时,我会先对其使用提炼函数

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)) custoemrName = "occupant";
else customerName = aCustomer.name;

// 客户端2
const plan = isUnknown(aCustomer)
  ? resistry.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
    }

此时我可以做一次全文搜索,确保没有任何地方使用 unknown 字符串了。

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

此时,我可以逐一查看客户端检查特例的代码,看它们处理特例的逻辑,并考虑是否能用函数组合成类将其替换成一个共同的、符合预期的值。此时有多处客户端代码用字符串 occupant 来作为未知顾客的名字。

class UnknownCustomer...
    get name() {
        return 'occupant'
    }
// 处理代表‘计价套餐的 billingPlan 属性
// 特例对象是值对象,因此应该始终是不可变的
get billingPlan() {
    return registry.billingPlans.basic
}
set billingPlan(arg) {
    ...
}

// 客户端1
const customerName = aCustomer.name

// 客户端2
aCustomer.billingPlan = newPlan

// 客户端3
const plan = aCustomer.billingPlan

最后一个例子更麻烦一些,因为特例对象需要返回另一个对象,后者又有其自有的属性。一般的原则是:如果特里对象需要返回关联对象,被返回的通常也是特例对象。所以,我需要创建一个代表‘空支付记录’的特例类 NullPaymentHistory

class UnknownCustoemr...
    get paymentHistory() {
        return new NullPaymentHistory()
    }
class NullPaymentHistory...
    get weeksDelinquentInLastYear() {
        return 0
    }
// 客户端...
const weeksDelinquent = aCustomer.paymentHistory.weeksDelinquentInLastYear

假如此时有一处客户端代码未用 occupant 作为未知顾客的名字,那么我只能在客户端保留特里检查的逻辑。

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

范例:使用对象字面量

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

还是前面这个例子,但是这次没有客户端对 Customer 对象做更新操作:

class Site...
    get customer() {
        return this._customer
    }

class Customer...
    get name() {
        ...
    }
get billingPlan() {
    ...
}
get billingPlan(arg) {
    ...
}
get paymentHistory() {
    ...
}

// 客户端1
const aCustomer = site.customer
// ... lots of intervening code ...
let customerName
if (aCustoemr === 'unknown') custoemrName = 'occupant'
else customerName = aCustomer.name

// 客户端2
const plan = (aCustoemr === 'unknown') ?
    resistry.billingPlans.basic :
    aCustomer.billingPlan

// 客户端3
if (aCustoemr !== 'unknown') aCustomer.billingPlan = newPlan

// 客户端4
const weeksDelinquent = (aCustomer === 'unknown') ?
    0 :
    aCustomer.paymentHistory.weeksDelinquentInLastYear

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

class Customer...
    get isUnknown() {
        return false
    }
// 顶层作用域
function createUnknownCustomer() {
    return {
        isUnknown: true
    }
}

// 之后对检查特例的条件逻辑运用提炼函数
function isUnknown(arg) {
    return (arg === 'unknown')
}

// 客户端1
let customerName
if (isUnknown(aCustomer)) custoemrName = 'occupant'
else customerName = aCustomer.name

// 客户端2
const plan = (isUnknown(aCustomer)) ?
    resistry.billingPlans.basic :
    aCustomer.billingPlan

// 客户端3
if (!isUnknown(aCustomer)) aCustomer.billingPlan = newPlan

// 客户端4
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',
        bilingPlan: registry.billingPlans.basic,
        paymentHistoty: {
            weeksDelinquentInLastYear: 0
        }
    }
}

// 客户端1
const customerName = aCustomer.name

// 客户端2
const plan = aCustomer.billingPlan

// 客户端3
const weeksDelinquent = aCustomer.paymentHistory.weeksDelinquentInLastYear

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

范例:使用变换

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

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

// 有时顾客名字未知,此时标记的方式与前面一样:将 customer 字段标记为字符串 unknown。
{
    name: 'Warehouse Unit 15',
    location: 'Malden MA',
    // more
    customer: "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 weeksDelingquent = (isUnknown(aCustomer)) ?
    0 :
    aCustomer.paymentHistory.weeksDelinquentInLastYear

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

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

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

// 随后修改检查特例的条件逻辑,开始使用新的属性。
function isUnknown(aCustomer) {
  if (aCustomer === "unknown") return true;
  else return Customer.isUnknown;
}

测试,确保一切正常,然后针对特例使用函数组合成变换

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

  if (isUnknow(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;

// 客户端2
const plan = aCustomer.billingPlan;

// 客户端3
const weeksDelinquent = aCustomer.paymentHistory.weeksDelinquentInLastYear;

引入断言

动机

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

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

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

断言是一种很有价值的交流形式——它们告诉阅读者,程序在执行到这一点时,对当前状态做了何种假设。另外断言对调试也很有帮助。

做法

  1. 如果你发现代码假设某个断言始终为真,就加入一个断言明确说明这种情况。应为断言不会对系统运行造成任何影响,所以“加入断言”永远应该是行为保持的。

范例

下面是一个简单例子:顾客(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 {
            assert(this.discountRate >= 0)
            return aNumber - (this.discountRate * aNumber)
        }
    }

// 当然可以直接在设值函数中使用断言,这样能更快发现错误源头
set discountRate(aNumber) {
    assert(null === aNumber || aNumber >= 0)
    this._discountRate = aNumber
}

或许听来讽刺,只有当我认为断言绝对不会失败的时候,我才会使用断言。