重构,改善代码的既有设计——简化条件逻辑
简化条件逻辑
程序的大部分威力来自条件逻辑,但是程序的复杂度也大多来自条件逻辑。
分解条件表达式
动机
程序之中,复炸的条件逻辑是最常导致复杂度上升的地点之一,我必须编写代码来检查不同的条件分支,更具不同条件做不同的事,然后我就会得到一个相当长的函数。大型函数本身就会使代码的可读性下降,而条件逻辑会使代码更难阅读。
和任何大块头代码一样,我可以将它分解成多个独立的函数,将每个分支条件分解成新函数还可以带来更多好处:可以突出条件逻辑,更清楚的表明每个分支的作用,并且突出每个分支的原因。
做法
- 对条件判断和每个条件分支分别运用提炼函数手法。
范例
假设我要计算购买某样商品的总价(总价 = 数量 * 单价),而这个商品在冬季和夏季的单价是不同的。
if (!aDate.isBefore(plan.summerStart) && !aDate.isAfter(plan.summerEnd))
charge = quantity * plan.summerRage;
else charge = quantity * plan.regularRage + plan.regularServiceCharge;
我把条件判断提炼到一个独立的函数中,然后提炼条件判断真和假的分支
if (summer()) charge = summerCharge();
else charge = regularCharge();
function summer() {
return !aDate.isBefore(plan.summerStart) && !aDate.isAfter(plan.summerEnd);
}
function summerCharge() {
return quantity * plan.summerRage;
}
function regularCharge() {
return quantity * plan.regularRage + plan.regularServiceCharge;
}
提炼完成后,可以用三元运算符重新安排条件语句
charge = summer() ? summerCharge() : regularCharge();
合并条件表达式
动机
有时我会发想这样一串条件检查:检查条件各不相同,最终行为却一致。如果发现这种情况,就应该使用“逻辑或”和“逻辑与”将它们合并为一个条件表达式。这样做有两个好处:第一是使这一次条件检查的用意更清晰,第二是这项重构往往可以为使用提炼函数做准备。
做法
- 确定这些条件表达式都没有副作用。
- 使用适当的逻辑运算符,将两个相关条件表达式合并成一个。
- 测试。
- 重复前面的合并过程,直到所有相关的条件表达式都合并到一起。
- 可以考虑对合并后的条件表达式实施提炼函数。
范例
有下面一段代码
function disabilityAmount(anEmployee) {
if (anEmployee.seniority < 2) return 0;
if (anEmployee.monthsDisabled > 12) return 0;
if (anEmployee.isPartTime) return 0;
}
上面有一连串的条件检查,都指向同样的结果,既然结果是相同的,就应该把这些条件检查合并成一条表达式。对于这样顺序执行的条件检查,可以用逻辑或运算符来合并。
function disabilityAmount(anEmployee) {
if (isNotEligableForDiasblility()) return 0;
}
function isNotEligableForDisability() {
return (
anEmployee.seniority < 2 ||
anEmployee.monthsDisabled > 12 ||
anEmployee.isPartTime
);
}
范例:使用逻辑与
上面的例子展示了用逻辑或合并条件表达式的做法,不过我有可能遇到需要逻辑与的情况。
if (anEmployee.onVacation) if (anEmployee.seniority > 10) return 1;
return 0.5;
使用逻辑与运算符将其合并
if (anEmployee.onVacation && anEmployee.seniority > 10) return 1;
return 0.5;
以卫语句取代嵌套条件表达式
动机
根据我的经验,条件表达式通常有两种风格。第一种风格是:两个条件分支都属于正常行为。第二种风格是:只有一个条件分支是正常行为,另一个分支则是异常的情况。
这两类条件表达式有不同的用途,这一点应该通过代码表现出来。如果两个分支都是正常行为,就应该使用形如 if...else...
的条件表达式,如果某个条件极其罕见,就应该单独检查该语句,并在该条件为真时立刻从函数中返回。这样的单独检查常常被成为卫语句。
以卫语句取代嵌套条件表达式的精髓就是:给某一条分支以特别的重视。如果使用 if-then-else
结构,你对 if
和 else
的重视是同等的,卫语句就不同了,它告诉阅读者:这种情况不是本函数的核心逻辑所关心的,如果它发生了,请做一些必要的整理工作,然后退出。
做法
- 选中最外层需要被替换的条件逻辑,将其替换为卫语句。
- 测试。
- 有需要的话,重复上述步骤。
- 如果所有卫语句都引发相同的结果,可以使用合并条件表达式合并。
范例
下面的代码用于计算要支付给员工的工资,只有还在公司上班的员工才需要支付工资,所以这个函数需要检查两种“员工已经不在公司上班”的情况。
function payAmount(employee) {
let result;
if (employee.isSeparated) {
result = {
amount: 0,
reasonCode: "SEP",
};
} else {
if (employee.isRetired) {
result = {
amount: 0,
reasonCode: "RET",
};
} else {
// 计算账单的逻辑
lorem.ipsum(dolor.ditAmet);
consectetur(adipiscing).elit();
sed.do.eiusmod = tempor.incididunt.ut(labore) && dolore(magna.aliqua);
ut.enim.ad(minim.veniam);
result = someFinalComputation();
}
}
return result;
}
嵌套的条件逻辑让我们看不清代码真实的含义。只有当前两个条件表达式都不为真的时候,这段代码才真正开始它的主要工作。所以,卫语句能让代码更清晰地阐述自己的意图。
function payAmount(employee) {
if (employee.isSeparated)
return {
amount: 0,
reasonCode: "SEP",
};
if (employee.isRetired)
return {
amount: 0,
reasonCode: "RET",
};
// 计算账单的逻辑
lorem.ipsum(dolor.ditAmet);
consectetur(adipiscing).elit();
sed.do.eiusmod = tempor.incididunt.ut(labore) && dolore(magna.aliqua);
ut.enim.ad(minim.veniam);
return someFinalComputation();
}
范例:讲条件反转
我们常常可以将条件表达式反转,从而实现以卫语句取代嵌套条件表达式。
function adjustedCapital(anInstrument) {
let result = 0;
if (anInstrument.capital > 0) {
if (anInstrument.capital > 0 && anInstrument.duration > 0) {
result =
(anInstrument.income / anInstrument.duration) *
anInstrument.adjustmentFactor;
}
}
return result;
}
同样的,我逐一进行替换,不过这次在插入卫语句时,我需要将相应的条件反转过来:
function adjustedCapital(anInstrument) {
let result = 0;
if (anInstrument.capital <= 0) return result;
if (anInstrument.capital <= 0 || anInstrument.duration <= 0) return result;
result =
(anInstrument.income / anInstrument.duration) *
anInstrument.adjustmentFactor;
return result;
}
两条语句引发的结果一样,所以我可以使用合并条件表达式将其合并
if (
anInstrument.capital <= 0 ||
anInstrument.capital <= 0 ||
anInstrument.duration <= 0
)
return result;
此时 result
为一个可变变量,我可以彻底移除它避免承担两份责任。
function adjustedCapital(anInstrument) {
if (
anInstrument.capital <= 0 ||
anInstrument.capital <= 0 ||
anInstrument.duration <= 0
)
return 0;
return (
(anInstrument.income / anInstrument.duration) *
anInstrument.adjustmentFactor
);
}
以多态取代条件表达式
动机
很多时候,我发现可以将条件逻辑拆分到不同的场景(或者叫高阶用例),从而拆解复杂的条件逻辑额。这种拆分有时用条件逻辑本身的结构就足以表达,但是用类和多态能把逻辑的拆分表述地更清晰。
一个常见的场景是:我可以构造一组类型,每个类型处理各自的一种条件逻辑。例如,图书、音乐、食品的处理方式不同,这是因为它们分属不同类型的商品。
另一种情况是:有一个基础逻辑,在其上又有一些变体。基础逻辑可能是最常用的,也可能是最简单的。我可以把基础逻辑放进超类,这样我就可以首先理解这部分逻辑,展示不管各种变体,然后我可以把每种变体逻辑单独范进一个子类,其中的代码着重强调与基础逻辑的差异。
多态是面向对象编程的关键特性之一,跟其他一切有用的特性一样,它也很容易被滥用,但如果发现如前所述的复杂条件逻辑,多态是改善这种情况的有力工具。
做法
- 如果现有的类尚不具备多态行为,就用工厂函数创建,令工厂函数返回恰当的对象实例。
- 在调用方代码中使用工厂函数获得对象实例。
- 将带有条件逻辑的函数移到超类中。
- 任选一个子类,在其中建立一个函数,使之覆写超类中容纳条件表达式的那个函数。将与该子类相关的条件表达式分支复制到新函数中,并对它进行适当调整。
- 重复上述过程,处理其他条件分支。
- 在超类函数中保留默认情况的逻辑。或者,如果超类是抽象的,就把该函数声明为
abstract
,或在其中直接抛出异常,表明计算责任都在子类中。
范例
我们写了一小段程序来判断一群鸟飞得有多快,以及它们的羽毛是什么样的。
function plumages(birds) {
return new Map(birds.map(b => [b.name, plumage(b)]))
}
function speeds(birds) {
return new Map(birds.map(b => [b.name, airSpeedVelocity(b)]))
}
function plumage(bird) {
switch (bird.type) {
case 'EuropeanSwallow':
return 'average'
case 'AfricanSwallow':
return (bird.numberOfCoconuts > 2) ? 'tired' : 'average'
case 'NorwegianVlueJParror':
return (bird.voltage > 100) ? 'scorched' : 'beautiful'
default:
return 'unknown'
}
}
function airSpeedVelocity(bird) {
case 'EuropeanSwallow':
return 35
case 'AfricanSwallow':
return 40 - 2 * bird.numberOfCoconuts
case 'NorwegianVlueJParror':
return (bird.isNailed) ? 0 : 10 + bird.voltage / 10
default:
return null
}
}
有两个不同的操作,其行为都随着鸟的类型来变化,因此可以创建出对应的类,用多态来处理各类型特有的行为。
function plumage(bird) {
return new Bird(bired).plumage
}
function airSpeedVelocity(bird) {
return new Bird(bird).airSpeedVelocity
}
class Bird {
constructor(birdObject) {
Object.assign(this, birdObject)
}
get plumage() {
switch (bird.type) {
case 'EuropeanSwallow':
return 'average'
case 'AfricanSwallow':
return (bird.numberOfCoconuts > 2) ? 'tired' : 'average'
case 'NorwegianVlueJParror':
return (bird.voltage > 100) ? 'scorched' : 'beautiful'
default:
return 'unknown'
}
}
get airSpeedVelocity() {
case 'EuropeanSwallow':
return 35
case 'AfricanSwallow':
return 40 - 2 * bird.numberOfCoconuts
case 'NorwegianVlueJParror':
return (bird.isNailed) ? 0 : 10 + bird.voltage / 10
default:
return null
}
}
}
然后针对每种鸟创建一个子类,用一个工厂函数来实例化合适的子类对象
function plumages(birds) {
return new Map(birds.map(b => createBird(b)).map(bird => [bird.name, bird.plumage])))
}
function speeds(birds) {
return new Map(birds.map(b => createBird(b)).map(bird => [bird.name, bird.airSpeedVelocity])))
}
function createBird(bird) {
switch (bird.type) {
case 'EuropeanSwallow':
return new EuropeanSwallow(bird)
case 'AfricanSwallow':
return new AfricanSwallow(bird)
case 'NorwegianVlueJParror':
return new NorwegianBlueParrot(bird)
default:
return new Bird(bird)
}
}
class Bird...
// 超类函数用来处理默认情况
get plumage() {
return 'unknow'
}
get airSpeedVelocity() {
return null
}
class EuropeanSwallow extends Bird {
get plumage() {
return 'average'
}
get airSpeedVelocity() {
return 35
}
}
class AfricanSwallow extends Bird {
get plumage() {
return (this.numberOfCoconuts > 2) ? 'tired' : 'average'
}
get airSpeedVelocity() {
return 40 - 2 * bird.numberOfCoconuts
}
}
class NorwegianVlueJParror extends Bird {
get plumage() {
return (this.voltage > 100) ? 'scorched' : 'beautiful'
}
get airSpeedVelocity() {
return (bird.isNailed) ? 0 : 10 + bird.voltage / 10
}
}
范例:用多态处理变体逻辑
前面例子中,‘鸟’的类型体系是一个清晰的泛化体系:超类是抽象的“鸟”,子类是各种具体的鸟,但这并不是事件中使用继承的唯一方式。实际上这种方式很可能不是最常用或最好的方式。另一种使用继承的情况是:我想表达某个对象与另一个对象大体相似,但又有一些不同之处。
下面有这样一个例子,有一家评级机构,要对远洋航船的航行进行投资凭借,这家评级机构会给出“A”或“B”两种评级,取决于多种风险和盈利潜力的因素。
// 此函数将三个分数组合到一起,给出一次航行的综合评级
function rating(voyage, history) {
const vpf = voyageProfitFactor(voyage, history);
const vr = voyageRisk(voyage);
const chr = captainHistoryRisk(voyage, history);
if (vpf * 3 > vr + chr * 2) return "A";
else return "B";
}
// 打出风险分数
function voyageRisk(voyage) {
let result = 1;
if (voyage.length > 4) result += 2;
if (voyage.length > 8) result += voyage.length - 8;
if (["china", "east-indies"].includes(voyage.zone)) result += 4;
return Math.max(result, 0);
}
// 打出风险因素
function captainHistoryRisk(voyage, history) {
let result = 1;
if (history.length < 5) result += 4;
result += history.filter((v) => v.profit < 0).length;
if (voyage.zone === "china" && hasChina(history)) result -= 2;
return Math.max(result, 0);
}
function hasChina(history) {
return history.some((v) => "china" === v.zone);
}
// 打出盈利潜力分数
function voyageProfitFactor(voyage, history) {
let result = 2;
if (voyage.zone === "china") result += 1;
if (voyage.zone === "east-indies") result += 1;
if (voyage.zone === "china" && hasChina(history)) {
result += 3;
if (history.length > 10) result += 4;
if (voyage.length > 12) result += 1;
if (voyage.length > 18) result -= 1;
} else {
if (history.length > 8) result += 1;
if (voyage.length > 14) result -= 1;
}
return result;
}
// 调用方代码
const voyage = {
zone: "west-indies",
length: 10,
};
const history = [
{
zone: "east-indies",
profit: 5,
},
{
zone: "west-indies",
profit: 15,
},
{
zone: "china",
profit: -2,
},
{
zone: "west-africa",
profit: 7,
},
];
const myRating = rating(voyage, history);
我会用继承和多态将处理“中国因素”的逻辑从基础逻辑中分离出来。如果还要引入更多的特殊逻辑,这个重构就很有用——这些重复的“中国因素”会混淆视听,让基础逻辑难以理解。
function rating(voyage, history) {
return new Rating(voyage, history).value;
}
class Rating {
constructor(voyage, history) {
this.voyage = voyage;
this.history = history;
}
get value() {
const vpf = thsi.voyageProfitFactor;
const vr = this.voyageRisk;
const chr = this.captainHistoryRisk;
if (vpf * 3 > vr + chr * 2) return "A";
else return "B";
}
get voyageRisk() {
let result = 1;
if (this.voyage.length > 4) result += 2;
if (this.voyage.length > 8) result += this.voyage.length - 8;
if (["china", "east-indies"].includes(thisvoyage.zone)) result += 4;
return Math.max(result, 0);
}
get captainHistoryRisk() {
let result = 1;
if (this.history.length < 5) result += 4;
result += this.history.filter((v) => v.profit < 0).length;
if (this.voyage.zone === "china" && this.hasChina(history)) result -= 2;
return Math.max(result, 0);
}
get voyageProfitFactor() {
let result = 2;
if (this.voyage.zone === "china") result += 1;
if (this.voyage.zone === "east-indies") result += 1;
if (this.voyage.zone === "china" && this.hasChina(history)) {
result += 3;
if (this.history.length > 10) result += 4;
if (this.voyage.length > 12) result += 1;
if (this.voyage.length > 18) result -= 1;
} else {
if (this.history.length > 8) result += 1;
if (this.voyage.length > 14) result -= 1;
}
return result;
}
get hasChinaHistory() {
return thsi.history.some((v) => "china" === v.zone);
}
}
于是我就有了一个类,用来安放基础逻辑。现在我需要另建一个空的子类,用来安放与超类不同的行为。
// 新建一个工厂函数用于在需要时返回变体类
function createRating(voyage, history) {
if (voyage.zone === "china" && history.some((v) => "china" === v.zone))
return new ExperiencedChinaRating(voyage, history);
else return new Rating(voyage, history);
}
// 现在我要修改所有调用方代码,让它们使用该工厂函数,而不要直接调用构造函数。
function rating(voyage, history) {
return createRating(voyage, history).value;
}
class ExperiencedChinaRating extends Rating {
// 在子类中覆写 captainHistoryRisk 同时在超类中去除相同逻辑
get captainHistoryRisk() {
const result = super.captainHistoryRisk - 2;
return Math.max(result, 0);
}
}
分离 voyageProfitFactor
函数中的变体行为要更麻烦一些,我不能直接从超类中删掉变体行为,因为在超类中还有另一条执行路径。我又不想把整个超类中的函数复制到子类中。
class Rating...
get voyageAndHistoryLengthFactor() {
let result = 0
if (this.history.length > 8) result += 1
if (this.voyage.length > 14) result -= 1
return result
}
class ExperiencedChinaRating...
get voyageAndHistoryLengthFactor() {
let result = 0
result += 3
if (this.history.length > 10) result += 4
if (this.voyage.length > 12) result += 1
if (this.voyage.length > 18) result -= 1
return result
}
严格来说,重构到这里已经结束了,但是我还想处理这个丑陋的新函数。这样一个难看的函数只会妨碍,而不是帮助别人理解其中的逻辑。
函数名中的 and
表示其中包含了两件事,所以我觉得应该将他们分开,我会用提炼函数把历史航行数的相关逻辑提炼出来。
class Rating...
get voyageAndHistoryLengthFactor() {
let result = 0
result += this.historyLengthFactor
result += this.voyageLengthFactor
return result
}
get historyLengthFactor() {
return (this.history.length > 8) ? 1 : 0
}
get voyageLengthFactor() {
return (this.voyage.length > 14) ? -1 : 0
}
class ExperiencedChinaRating...
get voyageAndHistoryLengthFactor() {
let result = 0
result += 3
result += this.historyLengthFactor
result += this.voyageLengthFactor
return result
}
get historyLengthFactor() {
return (this.history.length > 10) ? 1 : 0
}
get voyageLengthFactor() {
let result = 0
result += 3
if (this.voyage.length > 12) result += 1
if (this.voyage.length > 18) result -= 1
return result
}
此时应该把超类中使用搬移语句到调用者
class Rating...
get voyageProfitFactor() {
let result = 2
if (this.voyage.zone === 'china') result += 1
if (this.voyage.zone === 'east-indies') result += 1
result += this.historyLengthFactor
result += this.voyageLengthFactor
return result
}
class ExperiencedChinaRating...
// 将加三分加在最终结果上
get voyageProfitFactor() {
return super.voyageProfitFactor + 3
}
get voyageLengthFactor() {
let result = 0
// result += 3
if (this.voyage.length > 12) result += 1
if (this.voyage.length > 18) result -= 1
return result
}
引入特例
动机
一种常见的重复代码是这种情况:一个数据结构的使用者都在检查某个特殊值,并且当这个特殊值出现时所做的处理都相同,如果我发现代码库中有多处以同样方式应对一个特殊值,我就会想把这个处理逻辑收容到一处
处理这种情况的一个好办法是使用“特例”模式:创建一个特例元素,用以表达对这种特例的公用行为的处理。
做法
我们从一个作为隆起的数据结构(或者类)开始,其中包含一个属性,该属性就是我们要重构的目标。容器的客户端每次使用这个属性时,都需要将其与某个特例值做比对。
- 给重构目标添加检查特例的属性。令其返回
false
。 - 创建一个特里对象,其中只有检查特例的属性,返回
ture
。 - 对与特例值做比对的代码运用提炼函数,确保所有客户端都是用这个新函数,而不再直接做特例值的比对。
- 将新的特里对象引入代码中,可以从函数调用中返回,也可以在变换函数中生成。
- 测试。
- 使用函数组合类或函数组合成变换,把通用的特例处理逻辑都搬移到新建的特里对象中。
- 对特例比对函数使用内联函数,将其内联到仍然需要的地方。
范例
一家提供公共事业服务的公司将自己的服务安装在各个场所。
class Site...
get customer() {
return this._customer
}
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 (aCustoemr === "unknown") custoemrName = "occupant";
else customerName = aCustomer.name;
// 客户端2
const plan =
aCustoemr === "unknown" ? resistry.billingPlans.basic : aCustomer.billingPlan;
// 客户端3
if (aCustoemr !== "unknown") aCustomer.billingPlan = newPlan;
// 客户端4
const weeksDelinquent =
aCustomer === "unknown"
? 0
: aCustomer.paymentHistory.weeksDelinquentInLastYear;
浏览整个代码库,我看到有很多使用 site
对象的客户端在处理客户未知的情况下,大多数使用了同样的应对方式。到处都在检查这种特例,这些现象告诉我:是时候使用特例对象模式了
我首先给 Customer
添加一个函数,用于指示这个顾客是否未知。
class Customer...
get isUnknown() {
return false
}
// 然后我给”未知的顾客“专门创建一个类
// 注意这里没有声明为 Customer 的子类,在静态类型的编程语言中,需要声明为子类
class UnknownCustomer {
get isUnknown() {
return true
}
}
但是改成这样我就需要将客户端所有期望得到 unknown
值的地方返回这个新的特例对象,并修改所有检查 unknown
值的地方,令其使用 isUnknown
函数。还好,遇到这种困境时,我会先对其使用提炼函数。
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)) custoemrName = "occupant";
else customerName = aCustomer.name;
// 客户端2
const plan = isUnknown(aCustomer)
? resistry.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
}
此时我可以做一次全文搜索,确保没有任何地方使用 unknown
字符串了。
function isUnknown(arg) {
if (!(arg instanceof Customer) || arg instanceof UnknownCustomer))
throw new Error(`investigate bad value: <${arg}>`)
return arg.isUnknown
}
此时,我可以逐一查看客户端检查特例的代码,看它们处理特例的逻辑,并考虑是否能用函数组合成类将其替换成一个共同的、符合预期的值。此时有多处客户端代码用字符串 occupant
来作为未知顾客的名字。
class UnknownCustomer...
get name() {
return 'occupant'
}
// 处理代表‘计价套餐的 billingPlan 属性
// 特例对象是值对象,因此应该始终是不可变的
get billingPlan() {
return registry.billingPlans.basic
}
set billingPlan(arg) {
...
}
// 客户端1
const customerName = aCustomer.name
// 客户端2
aCustomer.billingPlan = newPlan
// 客户端3
const plan = aCustomer.billingPlan
最后一个例子更麻烦一些,因为特例对象需要返回另一个对象,后者又有其自有的属性。一般的原则是:如果特里对象需要返回关联对象,被返回的通常也是特例对象。所以,我需要创建一个代表‘空支付记录’的特例类 NullPaymentHistory
。
class UnknownCustoemr...
get paymentHistory() {
return new NullPaymentHistory()
}
class NullPaymentHistory...
get weeksDelinquentInLastYear() {
return 0
}
// 客户端...
const weeksDelinquent = aCustomer.paymentHistory.weeksDelinquentInLastYear
假如此时有一处客户端代码未用 occupant
作为未知顾客的名字,那么我只能在客户端保留特里检查的逻辑。
const name = aCustomer.isUnknown ? "unknown occupant" : aCustomer.name;
范例:使用对象字面量
我们在上面处理的其实是一些很简单的值,却要创建这样一个类,未免有点大动干戈。但在上面的例子中,我必须创建这样一个类,因为 Customer
类是允许使用者更新其内容的。但面对一个只读的数据结构,我就可以改用字面量对象。
还是前面这个例子,但是这次没有客户端对 Customer
对象做更新操作:
class Site...
get customer() {
return this._customer
}
class Customer...
get name() {
...
}
get billingPlan() {
...
}
get billingPlan(arg) {
...
}
get paymentHistory() {
...
}
// 客户端1
const aCustomer = site.customer
// ... lots of intervening code ...
let customerName
if (aCustoemr === 'unknown') custoemrName = 'occupant'
else customerName = aCustomer.name
// 客户端2
const plan = (aCustoemr === 'unknown') ?
resistry.billingPlans.basic :
aCustomer.billingPlan
// 客户端3
if (aCustoemr !== 'unknown') aCustomer.billingPlan = newPlan
// 客户端4
const weeksDelinquent = (aCustomer === 'unknown') ?
0 :
aCustomer.paymentHistory.weeksDelinquentInLastYear
和前面的例子一样,我现在 Customer
中添加 isUnknown
属性,并创建一个包含同名字段的特例对象那个。这次的区别在于,特例对象是一个字面量。
class Customer...
get isUnknown() {
return false
}
// 顶层作用域
function createUnknownCustomer() {
return {
isUnknown: true
}
}
// 之后对检查特例的条件逻辑运用提炼函数
function isUnknown(arg) {
return (arg === 'unknown')
}
// 客户端1
let customerName
if (isUnknown(aCustomer)) custoemrName = 'occupant'
else customerName = aCustomer.name
// 客户端2
const plan = (isUnknown(aCustomer)) ?
resistry.billingPlans.basic :
aCustomer.billingPlan
// 客户端3
if (!isUnknown(aCustomer)) aCustomer.billingPlan = newPlan
// 客户端4
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',
bilingPlan: registry.billingPlans.basic,
paymentHistoty: {
weeksDelinquentInLastYear: 0
}
}
}
// 客户端1
const customerName = aCustomer.name
// 客户端2
const plan = aCustomer.billingPlan
// 客户端3
const weeksDelinquent = aCustomer.paymentHistory.weeksDelinquentInLastYear
如果使用了这样的字面量,应该使用诸如 Object.freeze
的方法将其冻结,使其不可变。通常我还是喜欢用类多一点。
范例:使用变换
前面两个例子都涉及了一个类,其实本重构手法也同样适用于记录,只要增加一个变换步骤即可。
{
name: 'Acme Boston',
location: 'Malden MA',
// more
customer: {
name: 'Acme Industries',
billingPlan: 'plan-451',
paymentHistory: {
weeksDelinquentInLastYear: 7
// more
},
// more
}
}
// 有时顾客名字未知,此时标记的方式与前面一样:将 customer 字段标记为字符串 unknown。
{
name: 'Warehouse Unit 15',
location: 'Malden MA',
// more
customer: "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 weeksDelingquent = (isUnknown(aCustomer)) ?
0 :
aCustomer.paymentHistory.weeksDelinquentInLastYear
开始对 Site
数据做增强,首先是给 customer
字段加上 isUnknown
属性。
function enrichSite(aSite) {
const result = _.cloneDeep(aSite);
const unknownCustomer = {
isUnknown: true,
};
if (isUnknow(result.customer)) result.customer = unknownCustomer;
else result.customer.isUnknown = false;
return result;
}
// 随后修改检查特例的条件逻辑,开始使用新的属性。
function isUnknown(aCustomer) {
if (aCustomer === "unknown") return true;
else return Customer.isUnknown;
}
测试,确保一切正常,然后针对特例使用函数组合成变换
function enrichSite(aSite) {
const result = _.cloneDeep(aSite);
const unknownCustomer = {
isUnknown: true,
name: "occupant",
bilingPlan: registry.billingPlans.basic,
paymentHistoty: {
weeksDelinquentInLastYear: 0,
},
};
if (isUnknow(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;
// 客户端2
const plan = aCustomer.billingPlan;
// 客户端3
const weeksDelinquent = aCustomer.paymentHistory.weeksDelinquentInLastYear;
引入断言
动机
常常会有一段代码:只有某个条件为真时,该段代码才能正常运行。例如,平方根的计算只对正值才能进行,又比如,某个对象可能假设一组字段中至少有一个不等于 null
.
这样的假设通常你必须阅读整个算法才能看出。有时程序员会以注释写出这样的假设,而我要介绍的是一种更好的技术——使用断言明确标明这些假设。
断言是一个条件表达式,应该总是为真。如果它失败,表示程序员犯了错误。断言的失败不应该被系统任何地方捕捉。整个程序的行为在有没有断言出现的时候都应该完全一样。实际上,有些编程语言中的断言可以在编译期用一个开关完全禁用掉。
断言是一种很有价值的交流形式——它们告诉阅读者,程序在执行到这一点时,对当前状态做了何种假设。另外断言对调试也很有帮助。
做法
- 如果你发现代码假设某个断言始终为真,就加入一个断言明确说明这种情况。应为断言不会对系统运行造成任何影响,所以“加入断言”永远应该是行为保持的。
范例
下面是一个简单例子:顾客(customer)会获得一个折扣率(discount rate),可以用于所有其购买的商品。
class Customer...
applyDiscount(aNumber) {
return (this.discountRate) ?
aNumber - (this.discountRate * aNumber) :
aNumber
}
这里有一个假设:折扣率永远是正数。我可以用断言明确标识出这个假设。但在一个三元表达式中没办法很简单地插入断言,所以我首先要把这个表达式转换成 if-else
的形式,然后插入断言。
class Customer...
applyDiscount(aNumber) {
if (!this.discountRate) return aNumber
else {
assert(this.discountRate >= 0)
return aNumber - (this.discountRate * aNumber)
}
}
// 当然可以直接在设值函数中使用断言,这样能更快发现错误源头
set discountRate(aNumber) {
assert(null === aNumber || aNumber >= 0)
this._discountRate = aNumber
}
或许听来讽刺,只有当我认为断言绝对不会失败的时候,我才会使用断言。