曾用名:以函数对象取代函数(Replace Method with Method Object)

反向重构:以函数取代命令(344)

function score(candidate, medicalExam, scoringGuide) {
  let result = 0;
  let healthLevel = 0;
  // long body code
}
 
class Scorer {
  constructor(candidate, medicalExam, scoringGuide) {
    this._candidate = candidate;
    this._medicalExam = medicalExam;
    this._scoringGuide = scoringGuide;
  }
 
  execute() {
    this._result = 0;
    this._healthLevel = 0;
    // long body code
  }
}

动机

函数,不管是独立函数,还是以方法(method)形式附着在对象上的函数,是程序设计的基本构造块。不过,将函数封装成自己的对象,有时也是一种有用的办法。这样的对象我称之为“命令对象”(command object),或者简称“命令”(command)。这种对象大多只服务于单一函数,获得对该函数的请求,执行该函数,就是这种对象存在的意义。

与普通的函数相比,命令对象提供了更大的控制灵活性和更强的表达能力。除了函数调用本身,命令对象还可以支持附加的操作,例如撤销操作。我可以通过命令对象提供的方法来设值命令的参数值,从而支持更丰富的生命周期管理能力。我可以借助继承和钩子对函数行为加以定制。如果我所使用的编程语言支持对象但不支持函数作为一等公民,通过命令对象就可以给函数提供大部分相当于一等公民的能力。同样,即便编程语言本身并不支持嵌套函数,我也可以借助命令对象的方法和字段把复杂的函数拆解开,而且在测试和调试过程中可以直接调用这些方法。

所有这些都是使用命令对象的好理由,所以我要做好准备,一旦有需要,就能把函数重构成命令。不过我们不能忘记,命令对象的灵活性也是以复杂性作为代价的。所以,如果要在作为一等公民的函数和命令对象之间做个选择,95%的时候我都会选函数。只有当我特别需要命令对象提供的某种能力而普通的函数无法提供这种能力时,我才会考虑使用命令对象。

跟软件开发中的很多词汇一样,“命令”这个词承载了太多含义。在这里,“命令”是指一个对象,其中封装了一个函数调用请求。这是遵循《设计模式》[gof]一书中的命令模式(command pattern)。在这个意义上,使用“命令”一词时,我会先用完整的“命令对象”一词设定上下文,然后视情况使用简略的“命令”一词。在命令与查询分离原则(command-query separation principle)中也用到了“命令”一词,此时“命令”是一个对象所拥有的函数,调用该函数可以改变对象可观察的状态。我尽量避免使用这个意义上的“命令”一词,而更愿意称其为“修改函数”(modifier)或者“改变函数”(mutator)。

做法

为想要包装的函数创建一个空的类,根据该函数的名字为其命名。

使用搬移函数(198)把函数移到空的类里。

保持原来的函数作为转发函数,至少保留到重构结束之前才删除。

遵循编程语言的命名规范来给命令对象起名。如果没有合适的命名规范,就给命令对象中负责实际执行命令的函数起一个通用的名字,例如“execute”或者“call”。

可以考虑给每个参数创建一个字段,并在构造函数中添加对应的参数。

范例

JavaScript 语言有很多缺点,但把函数作为一等公民对待,是它最正确的设计决策之一。在不具备这种能力的编程语言中,我经常要费力为很常见的任务创建命令对象,JavaScript 则省去了这些麻烦。不过,即便在 JavaScript 中,有时也需要用到命令对象。

一个典型的应用场景就是拆解复杂的函数,以便我理解和修改。要想真正展示这个重构手法的价值,我需要一个长而复杂的函数,但这写起来太费事,你读起来也麻烦。所以我在这里展示的函数其实很短,并不真的需要本重构手法,还望读者权且包涵。下面的函数用于给一份保险申请评分。

function score(candidate, medicalExam, scoringGuide) {
  let result = 0;
  let healthLevel = 0;
  let highMedicalRiskFlag = false;
 
  if (medicalExam.isSmoker) {
    healthLevel += 10;
    highMedicalRiskFlag = true;
  }
  let certificationGrade = "regular";
  if (scoringGuide.stateWithLowCertification(candidate.originState)) {
    certificationGrade = "low";
    result -= 5;
  } // lots more code like this
  result -= Math.max(healthLevel - 5, 0);
  return result;
}

我首先创建一个空的类,用搬移函数(198)把上述函数搬到这个类里去。

function score(candidate, medicalExam, scoringGuide) {
  return new Scorer().execute(candidate, medicalExam, scoringGuide);
}
 
class Scorer {
  execute(candidate, medicalExam, scoringGuide) {
    let result = 0;
    let healthLevel = 0;
    let highMedicalRiskFlag = false;
 
    if (medicalExam.isSmoker) {
      healthLevel += 10;
      highMedicalRiskFlag = true;
    }
    let certificationGrade = "regular";
    if (scoringGuide.stateWithLowCertification(candidate.originState)) {
      certificationGrade = "low";
      result -= 5;
    } // lots more code like this
    result -= Math.max(healthLevel - 5, 0);
    return result;
  }
}

大多数时候,我更愿意在命令对象的构造函数中传入参数,而不让 execute 函数接收参数。在这样一个简单的拆解场景中,这一点带来的影响不大;但如果我要处理的命令需要更复杂的参数设置周期或者大量定制,上述做法就会带来很多便利:多个命令类可以分别从各自的构造函数中获得各自不同的参数,然后又可以排成队列挨个执行,因为它们的 execute 函数签名都一样。

我可以每次搬移一个参数到构造函数。

function score(candidate, medicalExam, scoringGuide) {
  return new Scorer(candidate).execute(candidate, medicalExam, scoringGuide);
}

class Scorer…

constructor(candidate){
 this._candidate = candidate;
}
 
execute (candidate, medicalExam, scoringGuide) {
 let result = 0;
 let healthLevel = 0;
 let highMedicalRiskFlag = false;
 
 if (medicalExam.isSmoker) {
  healthLevel += 10;
  highMedicalRiskFlag = true;
 }
 let certificationGrade = "regular";
 if (scoringGuide.stateWithLowCertification(this._candidate.originState)) {
  certificationGrade = "low";
  result -= 5;
 }
 // lots more code like this
 result -= Math.max(healthLevel - 5, 0);
 return result;
}

继续处理其他参数:

function score(candidate, medicalExam, scoringGuide) {
  return new Scorer(candidate, medicalExam, scoringGuide).execute();
}

class Scorer…

constructor(candidate, medicalExam, scoringGuide){
 this._candidate = candidate;
 this._medicalExam = medicalExam;
 this._scoringGuide = scoringGuide;
}
execute () {
 let result = 0;
 let healthLevel = 0;
 let highMedicalRiskFlag = false;
 
 if (this._medicalExam.isSmoker) {
  healthLevel += 10;
  highMedicalRiskFlag = true;
 }
 let certificationGrade = "regular";
 if (this._scoringGuide.stateWithLowCertification(this._candidate.originState)) {
  certificationGrade = "low";
  result -= 5;
 }
 // lots more code like this
 result -= Math.max(healthLevel - 5, 0);
 return result;
}

以命令取代函数的重构到此就结束了,不过之所以要做这个重构,是为了拆解复杂的函数,所以我还是大致展示一下如何拆解。下一步是把所有局部变量都变成字段,我还是每次修改一处。

class Scorer…

constructor(candidate, medicalExam, scoringGuide){
 this._candidate = candidate;
 this._medicalExam = medicalExam;
 this._scoringGuide = scoringGuide;
}
 
execute () {
 this._result = 0;
 let healthLevel = 0;
 let highMedicalRiskFlag = false;
 
 if (this._medicalExam.isSmoker) {
  healthLevel += 10;
  highMedicalRiskFlag = true;
 }
 let certificationGrade = "regular";
 if (this._scoringGuide.stateWithLowCertification(this._candidate.originState)) {
  certificationGrade = "low";
  this._result -= 5;
 }
 // lots more code like this
 this._result -= Math.max(healthLevel - 5, 0);
 return this._result;
}

重复上述过程,直到所有局部变量都变成字段。(“把局部变量变成字段”这个重构手法是如此简单,以至于我都没有在重构名录中给它一席之地。对此我略感愧疚。)

class Scorer…

constructor(candidate, medicalExam, scoringGuide){
 this._candidate = candidate;
 this._medicalExam = medicalExam;
 this._scoringGuide = scoringGuide;
}
 
execute () {
 this._result = 0;
 this._healthLevel = 0;
 this._highMedicalRiskFlag = false;
 
 if (this._medicalExam.isSmoker) {
  this._healthLevel += 10;
  this._highMedicalRiskFlag = true;
 }
 this._certificationGrade = "regular";
 if (this._scoringGuide.stateWithLowCertification(this._candidate.originState)) {
  this._certificationGrade = "low";
  this._result -= 5;
 }
 // lots more code like this
 this._result -= Math.max(this._healthLevel - 5, 0);
 return this._result;
}

现在函数的所有状态都已经移到了命令对象中,我可以放心使用提炼函数(106)等重构手法,而不用纠结于局部变量的作用域之类问题。

class Scorer…

execute () {
 this._result = 0;
 this._healthLevel = 0;
 this._highMedicalRiskFlag = false;
 
 this.scoreSmoking();
 this._certificationGrade = "regular";
 if (this._scoringGuide.stateWithLowCertification(this._candidate.originState)) {
  this._certificationGrade = "low";
  this._result -= 5;
 }
 // lots more code like this
 this._result -= Math.max(this._healthLevel - 5, 0);
 return this._result;
 }
scoreSmoking() {
 if (this._medicalExam.isSmoker) {
  this._healthLevel += 10;
  this._highMedicalRiskFlag = true;
 }
}

这样我就可以像处理嵌套函数一样处理命令对象。实际上,在 JavaScript 中运用此重构手法时,的确可以考虑用嵌套函数来代替命令对象。不过我还是会使用命令对象,不仅因为我对命令对象更熟悉,而且还因为我可以针对命令对象中任何一个函数进行测试和调试。