曾用名:以字段取代子类(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";
}