改善代码设计之简化条件表达式

121 阅读13分钟

程序的大部分威力来自条件逻辑,但,程序的复杂度也大多来自条件逻辑。借助简化条件来把条件逻辑变得更容易理解。

下面分别来介绍4种问题场景和对应的解决办法。

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

范例

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

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;
}
  1. 合并条件表达式(Consolidate Conditional Expression)

场景回顾

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

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

条件语句的合并理由也同时指出了不要合并的理由:如果认为这些检查的确彼此独立,的确不应该被视为同一次检查,就不会进行优化。

做法

  1. 确定这些条件表达式都没有副作用。
  2. 如果某个条件表达式有副作用,可以先用将查询函数和修改函数分离处理。
  3. 使用适当的逻辑运算符,将两个相关条件表达式合并为一个。
  4. 顺序执行的条件表达式用逻辑或来合并,嵌套的 if 语句用逻辑与来合并。
  5. 测试。
  6. 重复前面的合并过程,直到所有相关的条件表达式都合并到一起。
  7. 可以考虑对合并后的条件表达式实施提炼函数。

暂时无法在飞书文档外展示此内容

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

范例

看下面的代码片段:

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

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

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)
  && (anEmployee.seniority > 10)) return 1;
return 0.5;

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

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

场景回顾

条件表达式通常有两种风格。

  1. 两个条件分支都属于正常行为。
  2. 只有一个条件分支是正常行为,另一个分支则是异常的情况。

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

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

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

做法

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

暂时无法在飞书文档外展示此内容

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

范例

下面的代码用于计算要支付给员工(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
   ...
   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
  ...
 }
 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
 ...
 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
 ...
 return someFinalComputation();
}

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

范例2:将条件反转

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

function adjustedCapital(anInstrument) {
 let result = 0;
 if (anInstrument.capital > 0) {
  if (anInstrument.interestRate > 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.interestRate > 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.interestRate > 0 && 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;
}

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

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) {
  let result = 0;
 if (   anInstrument.capital     <= 0
   || anInstrument.interestRate <= 0
   || anInstrument.duration   <= 0) return 0;
 return (anInstrument.income / anInstrument.duration) * anInstrument.adjustmentFactor;
}
  1. 以多态取代条件表达式(Replace Conditional with Polymorphism)

场景回顾

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

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

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

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

做法

  1. 如果现有的类尚不具备多态行为,就用工厂函数创建之,令工厂函数返回恰当的对象实例。
  2. 在调用方代码中使用工厂函数获得对象实例。
  3. 将带有条件逻辑的函数移到超类中。
  4. 如果条件逻辑还未提炼至独立的函数,首先对其使用提炼函数
  5. 任选一个子类,在其中建立一个函数,使之覆写超类中容纳条件表达式的那个函数。将与该子类相关的条件表达式分支复制到新函数中,并对它进行适当调整。
  6. 重复上述过程,处理其他条件分支。
  7. 在超类函数中保留默认情况的逻辑。或者,如果超类应该是抽象的,就把该函数声明为 abstract,或在其中直接抛出异常,表明计算责任都在子类中。

范例

FormField 类代表表单中的字段,根据字段类型选择相应的校验器(例如,邮箱、数字等)。每个字段都有一个关联的校验器,校验器根据不同的字段类型执行不同的校验规则。这种设计利用了多态性,让我们能够轻松地为不同类型的字段添加新的校验器,保持代码的可扩展性和灵活性。

// 基类 Validator
class Validator {
  validate(value) {
    return "默认校验通过";
  }
}

// 子类 EmailValidator
class EmailValidator extends Validator {
  validate(value) {
    if (/^\w+([.-]?\w+)*@\w+([.-]?\w+)*(.\w{2,3})+$/.test(value)) {
      return "邮箱格式正确";
    } else {
      return "邮箱格式错误";
    }
  }
}

// 子类 NumberValidator
class NumberValidator extends Validator {
  validate(value) {
    if (!isNaN(value)) {
      return "输入是数字";
    } else {
      return "输入不是数字";
    }
  }
}

// 表单字段类
class FormField {
  constructor(type) {
    this.type = type;
    this.validator = this.getValidator();
  }

  getValidator() {
    switch (this.type) {
      case 'email':
        return new EmailValidator();
      case 'number':
        return new NumberValidator();
      default:
        return new Validator();
    }
  }

  validateInput(value) {
    return this.validator.validate(value);
  }
}

// 创建表单字段实例并进行校验
const emailField = new FormField('email');
const numberField = new FormField('number');
const textField = new FormField('text');

console.log(emailField.validateInput("test@example.com")); // 输出 "邮箱格式正确"
console.log(emailField.validateInput("testexample.com")); // 输出 "邮箱格式错误"

console.log(numberField.validateInput(123)); // 输出 "输入是数字"
console.log(numberField.validateInput("abc")); // 输出 "输入不是数字"

console.log(textField.validateInput("Some text")); // 输出 "默认校验通过"

汇总:

最后,总结一下4种场景下分别对应的解决办法。

问题场景解决办法
复杂的条件表达式分解条件表达式
逻辑组合合并条件表达式
在主要处理逻辑之前先做检查卫语句取代嵌套条件表达式
switch 逻辑处理了几种情况多态取代条件表达式