曾用名:引入 Null 对象(Introduce Null Object)
if (aCustomer === "unknown") customerName = "occupant";
class UnknownCustomer {
get name() {return "occupant";}动机
一种常见的重复代码是这种情况:一个数据结构的使用者都在检查某个特殊的值,并且当这个特殊值出现时所做的处理也都相同。如果我发现代码库中有多处以同样方式应对同一个特殊值,我就会想要把这个处理逻辑收拢到一处。
处理这种情况的一个好办法是使用“特例”(Special Case)模式:创建一个特例元素,用以表达对这种特例的共用行为的处理。这样我就可以用一个函数调用取代大部分特例检查逻辑。
特例有几种表现形式。如果我只需要从这个对象读取数据,可以提供一个字面量对象(literal object),其中所有的值都是预先填充好的。如果除简单的数值之外还需要更多的行为,就需要创建一个特殊对象,其中包含所有共用行为所对应的函数。特例对象可以由一个封装类来返回,也可以通过变换插入一个数据结构。
一个通常需要特例处理的值就是 null,这也是这个模式常被叫作“Null 对象”(Null Object)模式的原因——我喜欢说:Null 对象是特例的一种特例。
做法
我们从一个作为容器的数据结构(或者类)开始,其中包含一个属性,该属性就是我们要重构的目标。容器的客户端每次使用这个属性时,都需要将其与某个特例值做比对。我们希望把这个特例值替换为代表这种特例情况的类或数据结构。
给重构目标添加检查特例的属性,令其返回 false。
创建一个特例对象,其中只有检查特例的属性,返回 true。
对“与特例值做比对”的代码运用提炼函数(106),确保所有客户端都使用这个新函数,而不再直接做特例值的比对。
将新的特例对象引入代码中,可以从函数调用中返回,也可以在变换函数中生成。
修改特例比对函数的主体,在其中直接使用检查特例的属性。
测试。
使用函数组合成类(144)或函数组合成变换(149),把通用的特例处理逻辑都搬移到新建的特例对象中。
特例类对于简单的请求通常会返回固定的值,因此可以将其实现为字面记录(literal record)。
对特例比对函数使用内联函数(115),将其内联到仍然需要的地方。
范例
一家提供公共事业服务的公司将自己的服务安装在各个场所(site)。
class Site…
get customer() {return this._customer;}代表“顾客”的 Customer 类有多个属性,我只考虑其中 3 个。
class Customer…
get name() {...}
get billingPlan() {...}
set billingPlan(arg) {...}
get paymentHistory() {...}大多数情况下,一个场所会对应一个顾客,但有些场所没有与之对应的顾客,可能是因为之前的住户搬走了,而新搬来的住户我还不知道是谁。这种情况下,数据记录中的 customer 字段会被填充为字符串”unknown”。因为这种情况时有发生,所以 Site 对象的客户端必须有办法处理“顾客未知”的情况。下面是一些示例代码片段。
客户端 1…
const aCustomer = site.customer;
// ... lots of intervening code ...
let customerName;
if (aCustomer === "unknown") customerName = "occupant";
else customerName = aCustomer.name;客户端 2…
const plan =
aCustomer === "unknown" ? registry.billingPlans.basic : aCustomer.billingPlan;客户端 3…
if (aCustomer !== "unknown") aCustomer.billingPlan = newPlan;客户端 4…
const weeksDelinquent =
aCustomer === "unknown"
? 0
: aCustomer.paymentHistory.weeksDelinquentInLastYear;浏览整个代码库,我看到有很多使用 Site 对象的客户端在处理“顾客未知”的情况,大多数都用了同样的应对方式:用”occupant”(居民)作为顾客名,使用基本的计价套餐,并认为这家顾客没有欠费。到处都在检查这种特例,再加上对特例的处理方式高度一致,这些现象告诉我:是时候使用特例对象(Special Case Object)模式了。
我首先给 Customer 添加一个函数,用于指示“这个顾客是否未知”。
class Customer…
get isUnknown() {return false;}然后我给“未知的顾客”专门创建一个类。
class UnknownCustomer {
get isUnknown() {
return true;
}
}注意,我没有把 UnknownCustomer 类声明为 Customer 的子类。在其他编程语言(尤其是静态类型的编程语言)中,我会需要继承关系。但 JavaScript 是一种动态类型语言,按照它的子类化规则,这里不声明继承关系反而更好。
下面就是麻烦之处了。我必须在所有期望得到”unknown”值的地方返回这个新的特例对象,并修改所有检查”unknown”值的地方,令其使用新的 isUnknown 函数。一般而言,我总是希望细心安排修改过程,使我可以每次做一点小修改,然后马上测试。但如果我修改了 Customer 类,使其返回 UnknownCustomer 对象(而非”unknown”字符串),那么就必须同时修改所有客户端,让它们不要检查”unknown”字符串,而是调用 isUnknown 函数——这两个修改必须一次完成。我感觉这一大步修改就像一大块难吃的食物一样难以下咽。
还好,遇到这种困境时,有一个常用的技巧可以帮忙。如果有一段代码需要在很多地方做修改(例如我们这里的“与特例做比对”的代码),我会先对其使用提炼函数(106)。
function isUnknown(arg) {
if (!(arg instanceof Customer || arg === "unknown"))
throw new Error(`investigate bad value: <${arg}>`);
return arg === "unknown";
}我会放一个陷阱,捕捉意料之外的值。如果在重构过程中我犯了错误,引入了奇怪的行为,这个陷阱会帮我发现。
现在,凡是检查未知顾客的地方,都可以改用这个函数了。我可以逐一修改这些地方,每次修改之后都可以执行测试。
客户端 1…
let customerName;
if (isUnknown(aCustomer)) customerName = "occupant";
else customerName = aCustomer.name;没用多久,就全部修改完了。
客户端 2…
const plan = isUnknown(aCustomer)
? registry.billingPlans.basic
: aCustomer.billingPlan;客户端 3…
if (!isUnknown(aCustomer)) aCustomer.billingPlan = newPlan;客户端 4…
const weeksDelinquent = isUnknown(aCustomer)
? 0
: aCustomer.paymentHistory.weeksDelinquentInLastYear;将所有调用处都改为使用 isUnknown 函数之后,就可以修改 Site 类,令其在顾客未知时返回 UnknownCustomer 对象。
class Site…
get customer() {
return (this._customer === "unknown") ? new UnknownCustomer() : this._customer;
}然后修改 isUnknown 函数的判断逻辑。做完这步修改之后我可以做一次全文搜索,应该没有任何地方使用”unknown”字符串了。
客户端 1…
function isUnknown(arg) {
if (!(arg instanceof Customer || arg instanceof UnknownCustomer))
throw new Error(`investigate bad value: <${arg}>`);
return arg.isUnknown;
}测试,以确保一切运转如常。
现在,有趣的部分开始了。我可以逐一查看客户端检查特例的代码,看它们处理特例的逻辑,并考虑是否能用函数组合成类(144)将其替换为一个共同的、符合预期的值。此刻,有多处客户端代码用字符串”occupant”来作为未知顾客的名字,就像下面这样。
客户端 1…
let customerName;
if (isUnknown(aCustomer)) customerName = "occupant";
else customerName = aCustomer.name;我可以在 UnknownCustomer 类中添加一个合适的函数。
class UnknownCustomer…
get name() {return "occupant";}然后我就可以去掉所有条件代码。
客户端 1…
const customerName = aCustomer.name;测试通过之后,我可能会用内联变量(123)把 customerName 变量也消除掉。
接下来处理代表“计价套餐”的 billingPlan 属性。
客户端 2…
const plan = isUnknown(aCustomer)
? registry.billingPlans.basic
: aCustomer.billingPlan;客户端 3…
if (!isUnknown(aCustomer)) aCustomer.billingPlan = newPlan;对于读取该属性的行为,我的处理方法跟前面处理 name 属性一样——找到通用的应对方式,并在 UnknownCustomer 中使用之。至于对该属性的写操作,当前的代码没有对未知顾客调用过设值函数,所以在特例对象中,我会保留设值函数,但其中什么都不做。
class UnknownCustomer…
get billingPlan() {return registry.billingPlans.basic;}
set billingPlan(arg) { /* ignore */ }读取的例子…
const plan = aCustomer.billingPlan;更新的例子…
aCustomer.billingPlan = newPlan;特例对象是值对象,因此应该始终是不可变的,即便它们替代的原对象本身是可变的。
最后一个例子则更麻烦一些,因为特例对象需要返回另一个对象,后者又有其自己的属性。
客户端…
const weeksDelinquent = isUnknown(aCustomer)
? 0
: aCustomer.paymentHistory.weeksDelinquentInLastYear;一般的原则是:如果特例对象需要返回关联对象,被返回的通常也是特例对象。所以,我需要创建一个代表“空支付记录”的特例类 NullPaymentHistory。
class UnknownCustomer…
get paymentHistory() {return new NullPaymentHistory();}class NullPaymentHistory…
get weeksDelinquentInLastYear() {return 0;}客户端…
const weeksDelinquent = aCustomer.paymentHistory.weeksDelinquentInLastYear;我继续查看客户端代码,寻找是否有能用多态行为取代的地方。但也会有例外情况——客户端不想使用特例对象提供的逻辑,而是想做一些别的处理。我可能有 23 处客户端代码用”occupant”作为未知顾客的名字,但还有一处用了别的值。
客户端…
const name = !isUnknown(aCustomer) ? aCustomer.name : "unknown occupant";这种情况下,我只能在客户端保留特例检查的逻辑。我会对其做些修改,让它使用 aCustomer 对象身上的 isUnknown 函数,也就是对全局的 isUnknown 函数使用内联函数(115)。
客户端…
const name = aCustomer.isUnknown ? "unknown occupant" : aCustomer.name;处理完所有客户端代码后,全局的 isUnknown 函数应该没人再调用了,可以用移除死代码(237)将其移除。
范例:使用对象字面量
我们在上面处理的其实是一些很简单的值,却要创建一个这样的类,未免有点儿大动干戈。但在上面这个例子中,我必须创建这样一个类,因为 Customer 类是允许使用者更新其内容的。但如果面对一个只读的数据结构,我就可以改用字面量对象(literal object)。
还是前面这个例子——几乎完全一样,除了一件事:这次没有客户端对 Customer 对象做更新操作:
class Site…
get customer() {return this._customer;}class Customer…
get name() {...}
get billingPlan() {...}
set billingPlan(arg) {...}
get paymentHistory() {...}客户端 1…
const aCustomer = site.customer;
// ... lots of intervening code ...
let customerName;
if (aCustomer === "unknown") customerName = "occupant";
else customerName = aCustomer.name;客户端 2…
const plan =
aCustomer === "unknown" ? registry.billingPlans.basic : aCustomer.billingPlan;客户端 3…
const weeksDelinquent =
aCustomer === "unknown"
? 0
: aCustomer.paymentHistory.weeksDelinquentInLastYear;和前面的例子一样,我首先在 Customer 中添加 isUnknown 属性,并创建一个包含同名字段的特例对象。这次的区别在于,特例对象是一个字面量。
class Customer…
get isUnknown() {return false;}顶层作用域…
function createUnknownCustomer() {
return {
isUnknown: true,
};
}然后我对检查特例的条件逻辑运用提炼函数(106)。
function isUnknown(arg) {
return arg === "unknown";
}客户端 1…
let customerName;
if (isUnknown(aCustomer)) customerName = "occupant";
else customerName = aCustomer.name;客户端 2…
const plan = isUnknown(aCustomer)
? registry.billingPlans.basic
: aCustomer.billingPlan;客户端 3…
const weeksDelinquent = isUnknown(aCustomer)
? 0
: aCustomer.paymentHistory.weeksDelinquentInLastYear;修改 Site 类和做条件判断的 isUnknown 函数,开始使用特例对象。
class Site…
get customer() {
return (this._customer === "unknown") ? createUnknownCustomer() : this._customer;
}顶层作用域…
function isUnknown(arg) {
return arg.isUnknown;
}然后把“以标准方式应对特例”的地方都替换成使用特例字面量的值。首先从“名字”开始:
function createUnknownCustomer() {
return {
isUnknown: true,
name: "occupant",
};
}客户端 1…
const customerName = aCustomer.name;接着是“计价套餐”:
function createUnknownCustomer() {
return {
isUnknown: true,
name: "occupant",
billingPlan: registry.billingPlans.basic,
};
}客户端 2…
const plan = aCustomer.billingPlan;同样,我可以在字面量对象中创建一个嵌套的空支付记录对象:
function createUnknownCustomer() {
return {
isUnknown: true,
name: "occupant",
billingPlan: registry.billingPlans.basic,
paymentHistory: {
weeksDelinquentInLastYear: 0,
},
};
}客户端 3…
const weeksDelinquent = aCustomer.paymentHistory.weeksDelinquentInLastYear;如果使用了这样的字面量,应该使用诸如 Object.freeze 的方法将其冻结,使其不可变。通常,我还是喜欢用类多一点。
范例:使用变换
前面两个例子都涉及了一个类,其实本重构手法也同样适用于记录,只要增加一个变换步骤即可。
假设我们的输入是一个简单的记录结构,大概像这样:
{
name: "Acme Boston",
location: "Malden MA",
// more site details
customer: {
name: "Acme Industries",
billingPlan: "plan-451",
paymentHistory: {
weeksDelinquentInLastYear: 7
//more
},
// more
}
}有时顾客的名字未知,此时标记的方式与前面一样:将 customer 字段标记为字符串”unknown”。
{
name: "Warehouse Unit 15",
location: "Malden MA",
// more site details
customer: "unknown",
}客户端代码也类似,会检查“未知顾客”的情况:
客户端 1…
const site = acquireSiteData();
const aCustomer = site.customer;
// ... lots of intervening code ...
let customerName;
if (aCustomer === "unknown") customerName = "occupant";
else customerName = aCustomer.name;客户端 2…
const plan =
aCustomer === "unknown" ? registry.billingPlans.basic : aCustomer.billingPlan;客户端 3…
const weeksDelinquent =
aCustomer === "unknown"
? 0
: aCustomer.paymentHistory.weeksDelinquentInLastYear;我首先要让 Site 数据结构经过一次变换,目前变换中只做了深复制,没有对数据做任何处理。
客户端 1…
const rawSite = acquireSiteData();
const site = enrichSite(rawSite);
const aCustomer = site.customer;
// ... lots of intervening code ...
let customerName;
if (aCustomer === "unknown") customerName = "occupant";
else customerName = aCustomer.name;
function enrichSite(inputSite) {
return _.cloneDeep(inputSite);
}然后对“检查未知顾客”的代码运用提炼函数(106)。
function isUnknown(aCustomer) {
return aCustomer === "unknown";
}客户端 1…
const rawSite = acquireSiteData();
const site = enrichSite(rawSite);
const aCustomer = site.customer;
// ... lots of intervening code ...
let customerName;
if (isUnknown(aCustomer)) customerName = "occupant";
else customerName = aCustomer.name;客户端 2…
const plan = isUnknown(aCustomer)
? registry.billingPlans.basic
: aCustomer.billingPlan;客户端 3…
const weeksDelinquent = isUnknown(aCustomer)
? 0
: aCustomer.paymentHistory.weeksDelinquentInLastYear;然后开始对 Site 数据做增强,首先是给 customer 字段加上 isUnknown 属性。
function enrichSite(aSite) {
const result = _.cloneDeep(aSite);
const unknownCustomer = {
isUnknown: true,
};
if (isUnknown(result.customer)) result.customer = unknownCustomer;
else result.customer.isUnknown = false;
return result;
}随后修改检查特例的条件逻辑,开始使用新的属性。原来的检查逻辑也保留不动,所以现在的检查逻辑应该既能应对原来的 Site 数据,也能应对增强后的 Site 数据。
function isUnknown(aCustomer) {
if (aCustomer === "unknown") return true;
else return aCustomer.isUnknown;
}测试,确保一切正常,然后针对特例使用函数组合成变换(149)。首先把“未知顾客的名字”的处理逻辑搬进增强函数。
function enrichSite(aSite) {
const result = _.cloneDeep(aSite);
const unknownCustomer = {
isUnknown: true,
name: "occupant",
};
if (isUnknown(result.customer)) result.customer = unknownCustomer;
else result.customer.isUnknown = false;
return result;
}客户端 1…
const rawSite = acquireSiteData();
const site = enrichSite(rawSite);
const aCustomer = site.customer;
// ... lots of intervening code ...
const customerName = aCustomer.name;测试,然后是“未知顾客的计价套餐”的处理逻辑。
function enrichSite(aSite) {
const result = _.cloneDeep(aSite);
const unknownCustomer = {
isUnknown: true,
name: "occupant",
billingPlan: registry.billingPlans.basic,
};
if (isUnknown(result.customer)) result.customer = unknownCustomer;
else result.customer.isUnknown = false;
return result;
}客户端 2…
const plan = aCustomer.billingPlan;再次测试,然后处理最后一处客户端代码。
function enrichSite(aSite) {
const result = _.cloneDeep(aSite);
const unknownCustomer = {
isUnknown: true,
name: "occupant",
billingPlan: registry.billingPlans.basic,
paymentHistory: {
weeksDelinquentInLastYear: 0,
},
};
if (isUnknown(result.customer)) result.customer = unknownCustomer;
else result.customer.isUnknown = false;
return result;
}客户端 3…
const weeksDelinquent = aCustomer.paymentHistory.weeksDelinquentInLastYear;