包含旧重构:以 State/Strategy 取代类型码(Replace Type Code with State/Strategy)

包含旧重构:提炼子类(Extract Subclass)

反向重构:移除子类(369)

function createEmployee(name, type) {
  return new Employee(name, type);
}
 
 
function createEmployee(name, type) {
  switch (type) {
    case "engineer": return new Engineer(name);
    case "salesman": return new Salesman(name);
    case "manager": return new Manager (name);
}

动机

软件系统经常需要表现“相似但又不同的东西”,比如员工可以按职位分类(工程师、经理、销售),订单可以按优先级分类(加急、常规)。表现分类关系的第一种工具是类型码字段——根据具体的编程语言,可能实现为枚举、符号、字符串或者数字。类型码的取值经常来自给系统提供数据的外部服务。

大多数时候,有这样的类型码就够了。但也有些时候,我可以再多往前一步,引入子类。继承有两个诱人之处。首先,你可以用多态来处理条件逻辑。如果有几个函数都在根据类型码的取值采取不同的行为,多态就显得特别有用。引入子类之后,我可以用以多态取代条件表达式(272)来处理这些函数。

另外,有些字段或函数只对特定的类型码取值才有意义,例如“销售目标”只对“销售”这类员工才有意义。此时我可以创建子类,然后用字段下移(361)把这样的字段放到合适的子类中去。当然,我也可以加入验证逻辑,确保只有当类型码取值正确时才使用该字段,不过子类的形式能更明确地表达数据与类型之间的关系。

在使用以子类取代类型码时,我需要考虑一个问题:应该直接处理携带类型码的这个类,还是应该处理类型码本身呢?以前面的例子来说,我是应该让“工程师”成为“员工”的子类,还是应该在“员工”类包含“员工类别”属性、从后者继承出“工程师”和“经理”等子类型呢?直接的子类继承(前一种方案)比较简单,但职位类别就不能用在其他场合了。另外,如果员工的类别是可变的,那么也不能使用直接继承的方案。如果想在“员工类别”之下创建子类,可以运用以对象取代基本类型(174)把类型码包装成“员工类别”类,然后对其使用以子类取代类型码(362)。

做法

自封装类型码字段。

任选一个类型码取值,为其创建一个子类。覆写类型码类的取值函数,令其返回该类型码的字面量值。

创建一个选择器逻辑,把类型码参数映射到新的子类。

如果选择直接继承的方案,就用以工厂函数取代构造函数(334)包装构造函数,把选择器逻辑放在工厂函数里;如果选择间接继承的方案,选择器逻辑可以保留在构造函数里。

测试。

针对每个类型码取值,重复上述“创建子类、添加选择器逻辑”的过程。每次修改后执行测试。

去除类型码字段。

测试。

使用函数下移(359)和以多态取代条件表达式(272)处理原本访问了类型码的函数。全部处理完后,就可以移除类型码的访问函数。

范例

这个员工管理系统的例子已经被用烂了……

class Employee…

constructor(name, type){
  this.validateType(type);
  this._name = name;
  this._type = type;
}
validateType(arg) {
  if (!["engineer", "manager", "salesman"].includes(arg))
    throw new Error(`Employee cannot be of type ${arg}`);
}
toString() {return `${this._name} (${this._type})`;}

第一步是用封装变量(132)将类型码自封装起来。

class Employee…

get type() {return this._type;}
toString() {return `${this._name} (${this.type})`;}

请注意,toString 函数的实现中去掉了 this._type 的下划线,改用新建的取值函数了。

我选择从工程师(“engineer”)这个类型码开始重构。我打算采用直接继承的方案,也就是继承 Employee 类。子类很简单,只要覆写类型码的取值函数,返回适当的字面量值就行了。

class Engineer extends Employee {
  get type() {
    return "engineer";
  }
}

虽然 JavaScript 的构造函数也可以返回其他对象,但如果把选择器逻辑放在这儿,它会与字段初始化逻辑相互纠缠,搞得一团混乱。所以我会先运用以工厂函数取代构造函数(334),新建一个工厂函数以便安放选择器逻辑。

function createEmployee(name, type) {
  return new Employee(name, type);
}

然后我把选择器逻辑放在工厂函数中,从而开始使用新的子类。

function createEmployee(name, type) {
  switch (type) {
    case "engineer":
      return new Engineer(name, type);
  }
  return new Employee(name, type);
}

测试,确保一切运转正常。不过由于我的偏执,我随后会修改 Engineer 类中覆写的 type 函数,让它返回另外一个值,再次执行测试,确保会有测试失败,这样我才能肯定:新建的子类真的被用到了。然后我把 type 函数的返回值改回正确的状态,继续处理别的类型。我一次处理一个类型,每次修改后都执行测试。

class Salesman extends Employee {
  get type() {
    return "salesman";
  }
}
 
class Manager extends Employee {
  get type() {
    return "manager";
  }
}
 
function createEmployee(name, type) {
  switch (type) {
    case "engineer":
      return new Engineer(name, type);
    case "salesman":
      return new Salesman(name, type);
    case "manager":
      return new Manager(name, type);
  }
  return new Employee(name, type);
}

全部修改完成后,我就可以去掉类型码字段及其在超类中的取值函数(子类中的取值函数仍然保留)。

class Employee…

constructor(name, type){
 this.validateType(type);
 this._name = name;
 this._type = type;
}
 
get type() {return this._type;}
toString() {return `${this._name} (${this.type})`;}

测试,确保一切工作正常,我就可以移除验证逻辑,因为分发逻辑做的是同一回事。

class Employee…

constructor(name, type){
 this.validateType(type);
 this._name = name;
}
function createEmployee(name, type) {
 switch (type) {
  case "engineer": return new Engineer(name, type);
  case "salesman": return new Salesman(name, type);
  case "manager":  return new Manager (name, type);
  default: throw new Error(`Employee cannot be of type ${type}`);
 }
 return new Employee(name, type);
}

现在,构造函数的类型参数已经没用了,用改变函数声明(124)把它干掉。

class Employee…

constructor(name, type){
 this._name = name;
}
 
function createEmployee(name, type) {
 switch (type) {
  case "engineer": return new Engineer(name, type);
  case "salesman": return new Salesman(name, type);
  case "manager": return new Manager (name, type);
  default: throw new Error(`Employee cannot be of type ${type}`);
 }
}

子类中获取类型码的访问函数——get type 函数——仍然留着。通常我会希望把这些函数也干掉,不过可能需要多花点儿时间,因为有其他函数使用了它们。我会用以多态取代条件表达式(272)和函数下移(359)来处理这些访问函数。到某个时候,已经没有代码使用类型码的访问函数了,我再用移除死代码(237)给它们送终。

范例:使用间接继承

还是前面这个例子,我们回到最起初的状态,不过这次我已经有了“全职员工”和“兼职员工”两个子类,所以不能再根据员工类别代码创建子类了。另外,我可能需要允许员工类别动态调整,这也会导致不能使用直接继承的方案。

class Employee…

constructor(name, type){
 this.validateType(type);
 this._name = name;
 this._type = type;
}
validateType(arg) {
 if (!["engineer", "manager", "salesman"].includes(arg))
  throw new Error(`Employee cannot be of type ${arg}`);
}
get type()    {return this._type;}
set type(arg) {this._type = arg;}
 
get capitalizedType() {
 return this._type.charAt(0).toUpperCase() + this._type.substr(1).toLowerCase();
}
toString() {
 return `${this._name} (${this.capitalizedType})`;
}

这次的 toString 函数要更复杂一点,以便稍后展示用。

首先,我用以对象取代基本类型(174)包装类型码。

class EmployeeType {
  constructor(aString) {
    this._value = aString;
  }
  toString() {
    return this._value;
  }
}

class Employee…

constructor(name, type){
 this.validateType(type);
 this._name = name;
 this.type = type;
}
validateType(arg) {
 if (!["engineer", "manager", "salesman"].includes(arg))
  throw new Error(`Employee cannot be of type ${arg}`);
}
get typeString()  {return this._type.toString();}
get type()    {return this._type;}
set type(arg) {this._type = new EmployeeType(arg);}
 
get capitalizedType() {
 return this.typeString.charAt(0).toUpperCase()
  + this.typeString.substr(1).toLowerCase();
}
toString() {
 return `${this._name} (${this.capitalizedType})`;
}

然后使用以子类取代类型码(362)的老套路,把员工类别代码变成子类。

class Employee…

set type(arg) {this._type = Employee.createEmployeeType(arg);}
 
 static createEmployeeType(aString) {
  switch(aString) {
   case "engineer": return new Engineer();
   case "manager":  return new Manager ();
   case "salesman": return new Salesman();
   default: throw new Error(`Employee cannot be of type ${aString}`);
  }
 }
 
class EmployeeType {
}
class Engineer extends EmployeeType {
 toString() {return "engineer";}
}
class Manager extends EmployeeType {
 toString() {return "manager";}
}
class Salesman extends EmployeeType {
 toString() {return "salesman";}
}

如果重构到此为止的话,空的 EmployeeType 类可以去掉。但我更愿意留着它,用来明确表达各个子类之间的关系。并且有一个超类,也方便把其他行为搬移进去,例如我专门放在 toString 函数里的“名字大写”逻辑,就可以搬到超类。

class Employee…

toString() {
  return `${this._name} (${this.type.capitalizedName})`;
}

class EmployeeType…

get capitalizedName() {
return this.toString().charAt(0).toUpperCase()
  + this.toString().substr(1).toLowerCase();
}

熟悉本书第 1 版的读者大概能看出,这个例子来自第 1 版的以 State/Strategy 取代类型码重构手法。现在我认为这是以间接继承的方式使用以子类取代类型码,所以就不再将其作为一个单独的重构手法了。(而且我也一直不喜欢那个老重构手法的名字。)