曾用名:以字段取代子类(Replace Subclass with Fields)

反向重构:以子类取代类型码(362)

class Person {
  get genderCode() {
    return "X";
  }
}
class Male extends Person {
  get genderCode() {
    return "M";
  }
}
class Female extends Person {
  get genderCode() {
    return "F";
  }
}
 
class Person {
  get genderCode() {
    return this._genderCode;
  }
}

动机

子类很有用,它们为数据结构的多样和行为的多态提供支持,它们是针对差异编程的好工具。但随着软件的演化,子类所支持的变化可能会被搬移到别处,甚至完全去除,这时子类就失去了价值。有时添加子类是为了应对未来的功能,结果构想中的功能压根没被构造出来,或者用了另一种方式构造,使该子类不再被需要了。

子类存在着就有成本,阅读者要花心思去理解它的用意,所以如果子类的用处太少,就不值得存在了。此时,最好的选择就是移除子类,将其替换为超类中的一个字段。

做法

使用以工厂函数取代构造函数(334),把子类的构造函数包装到超类的工厂函数中。

如果构造函数的客户端用一个数组字段来决定实例化哪个子类,可以把这个判断逻辑放到超类的工厂函数中。

如果有任何代码检查子类的类型,先用提炼函数(106)把类型检查逻辑包装起来,然后用搬移函数(198)将其搬到超类。每次修改后执行测试。

新建一个字段,用于代表子类的类型。

将原本针对子类的类型做判断的函数改为使用新建的类型字段。

删除子类。

测试。

本重构手法常用于一次移除多个子类,此时需要先把这些子类都封装起来(添加工厂函数、搬移类型检查),然后再逐个将它们折叠到超类中。

范例

一开始,代码中遗留了两个子类。

class Person…

constructor(name) {
 this._name = name;
}
get name()    {return this._name;}
get genderCode() {return "X";}
// snip
 
class Male extends Person {
 get genderCode() {return "M";}
}
 
class Female extends Person {
 get genderCode() {return "F";}
}

如果子类就干这点儿事,那真的没必要存在。不过,在移除子类之前,通常有必要检查使用方代码是否有依赖于特定子类的行为,这样的行为需要被搬移到子类中。在这个例子里,我找到一些客户端代码基于子类的类型做判断,不过这也不足以成为保留子类的理由。

客户端…

const numberOfMales = people.filter(p => p instanceof Male).length;

每当想要改变某个东西的表现形式时,我会先将当下的表现形式封装起来,从而尽量减小对客户端代码的影响。对于“创建子类对象”而言,封装的方式就是以工厂函数取代构造函数(334)。在这里,实现工厂有两种方式。

最直接的方式是为每个构造函数分别创建一个工厂函数。

function createPerson(name) {
  return new Person(name);
}
function createMale(name) {
  return new Male(name);
}
function createFemale(name) {
  return new Female(name);
}

虽然这是最直接的选择,但这样的对象经常是从输入源加载出来,直接根据性别代码创建对象。

function loadFromInput(data) {
 const result = [];
 data.forEach(aRecord => {
  let p;
  switch (aRecord.gender) {
   case 'M': p = new Male(aRecord.name); break;
   case 'F': p = new Female(aRecord.name); break;
   default: p = new Person(aRecord.name);
  }
  result.push(p);
 });
 return result;
}

有鉴于此,我觉得更好的办法是先用提炼函数(106)把“选择哪个类来实例化”的逻辑提炼成工厂函数。

function createPerson(aRecord) {
 let p;
 switch (aRecord.gender) {
  case 'M': p = new Male(aRecord.name); break;
  case 'F': p = new Female(aRecord.name); break;
  default: p = new Person(aRecord.name);
 }
 return p;
}
function loadFromInput(data) {
 const result = [];
 data.forEach(aRecord => {
  result.push(createPerson(aRecord));
 });
 return result;
}

提炼完工厂函数后,我会对这两个函数做些清理。先用内联变量(123)简化 createPerson 函数:

function createPerson(aRecord) {
  switch (aRecord.gender) {
    case "M":
      return new Male(aRecord.name);
    case "F":
      return new Female(aRecord.name);
    default:
      return new Person(aRecord.name);
  }
}

再用以管道取代循环(231)简化 loadFromInput 函数:

function loadFromInput(data) {
  return data.map(aRecord => createPerson(aRecord));
}

工厂函数封装了子类的创建逻辑,但代码中还有一处用到 instanceof 运算符——这从来不会是什么好味道。我用提炼函数(106)把这个类型检查逻辑提炼出来。

客户端…

const numberOfMales = people.filter(p => isMale(p)).length;
 
function isMale(aPerson) {return aPerson instanceof Male;}

然后用搬移函数(198)将其移到 Person 类。

class Person…

get isMale() {return this instanceof Male;}

客户端…

const numberOfMales = people.filter(p => p.isMale).length;

重构到这一步,所有与子类相关的知识都已经安全地包装在超类和工厂函数中。(对于“超类引用子类”这种情况,通常我会很警惕,不过这段代码用不了一杯茶的工夫就会被干掉,所以也不用太担心。)

现在,添加一个字段来表示子类之间的差异。既然有来自别处的一个类型代码,直接用它也无妨。

class Person…

constructor(name, genderCode) {
  this._name = name;
  this._genderCode = genderCode || "X";
}
 
get genderCode() {return this._genderCode;}

在初始化时先将其设置为默认值。(顺便说一句,虽然大多数人可以归类为男性或女性,但确实有些人不是这两种性别中的任何一种。忽视这些人的存在,是一个常见的建模错误。)

首先从“男性”的情况开始,将相关逻辑折叠到超类中。为此,首先要修改工厂函数,令其返回一个 Person 对象,然后修改所有 instanceof 检查逻辑,改为使用性别代码字段。

function createPerson(aRecord) {
  switch (aRecord.gender) {
    case "M":
      return new Person(aRecord.name, "M");
    case "F":
      return new Female(aRecord.name);
    default:
      return new Person(aRecord.name);
  }
}

class Person…

get isMale() {return "M" === this._genderCode;}

此时我可以测试,删除 Male 子类,再次测试,然后对 Female 子类也如法炮制。

function createPerson(aRecord) {
  switch (aRecord.gender) {
    case "M":
      return new Person(aRecord.name, "M");
    case "F":
      return new Person(aRecord.name, "F");
    default:
      return new Person(aRecord.name);
  }
}

类型代码的分配有点儿失衡,默认情况没有类型代码,这种情况让我很烦心。未来阅读代码的人会一直好奇背后的原因。所以我更愿意现在做点儿修改,给所有情况都平等地分配类型代码——只要不会引入额外的复杂性就好。

function createPerson(aRecord) {
  switch (aRecord.gender) {
    case "M":
      return new Person(aRecord.name, "M");
    case "F":
      return new Person(aRecord.name, "F");
    default:
      return new Person(aRecord.name, "X");
  }
}

class Person…

constructor(name, genderCode) {
  this._name = name;
  this._genderCode = genderCode || "X";
}