第九章:重新组织数据。
一、拆分变量
动机:
变量有各种不同的用途,其中某些用途会很自然地导致临时变量被多次赋值。”循环变量”和”结果收集变量”就是两个例子:循环变量会随循环的每次运行而改变;结果收集变量负责将”通过整个函数的运算”而构成的某个值收集起来。
除了以上两种情况,还有很多变量用于保存一段冗长代码的运算结果,以便稍后使用。这种变量应该只被赋值一次。如果被赋值超过一次,就意味着它们在函数中承担了一个以上的责任。如果变量承担多个责任,就应该被替换(分解)为多个变量,每个变量只承担一个责任。同一个变量承担两件不同的事情,会令代码阅读者糊涂。
具体展现:
// 重构前
let temp = 2 * (height + width);
console.log(temp);
temp = height * width;
console.log(temp);
// 重构后
const perimeter = 2 * (height + width);
console.log(perimeter);
const area = height * width;
console.log(area);
二、字段改名
动机:
命名很重要,这个不需要再过多的重复了。
具体展现:
// 重构前
class Organization {
get name() { ... }
}
// 重构后
class Organization {
get title() { ... }
}
三、以查询取代派生变量
动机:
可变数据是软件中最大的错误源头之一。
有些变量其实可以很容易地随时计算出来。如果能去掉这些变量,也能消除可变性。计算常能更清晰地表达数据的含义,而且也避免了”源数据修改时忘了更新派生变量”的错误。
具体展现:
// 重构前
get discountedTotal() { return this._discountedTotal; }
set discount(aNamber) {
const old = this._discount;
this._discount = aNamber;
this._discountedTotal += old - aNamber;
}
// 重构后
get discountedTotal() { return this._baseTotal - this._discount; }
set discount(aNamber) { this._discount = aNamber; }
四、将引用对象改为值对象
动机:
在把一个对象(或数据结构)嵌入另一个对象时,位于内部的对象可以被视为引用对象,也可以被视为值对象。其最明显的差别在于:视为引用对象的话,在更新其属性时,会保留原对象不动,更新内部对象的属性。如果视为值对象,就可以替换整个内部对象,把内部对象的类也变成值对象。
值对象是不可变的。因此可以放心地把不可变的数据值传给程序的其他部分,而不必担心对象中包装的数据被偷偷修改。可以在程序各处复制值对象,而不必操心维护内存链接。
如果想在几个对象之间共享一个对象,以便几个对象都能看见对共享对象的修改,那么这个共享的对象就应该是引用。
具体展现:
// 重构前
class Product {
applyDiscount(arg) { this._price.amount -= arg; }
}
// 重构后
class Product {
applyDiscount(arg) {
this._price = new Money(this._price.amount - arg, this._price.currency);
}
}
五、将值对象改为引用对象
动机:
正如上面的重构方法所知,把数据作为值对象和引用对象都可以。但需要根据具体需求而定。
如果共享的数据需要更新,将其复制多份的做法就会遇到巨大的问题。对于这种情况,可以考虑将多分数据副本变成单一的引用,这样对一处数据的修改就会立即反映到所有引用的数据中。
如果要将值对象转换成引用对象,可以创建一个仓库对象,仓库对象存储所有唯一的值,在需要使用到值的构造函数中将仓库引用进来就可以了。
具体展现:
// 重构前
let customer = new Customer(customerData);
// 重构后
let customer = customerRepository.get(customerData.id);
第十章:简化条件逻辑
程序的大部分威力来自条件逻辑,但很不幸,程序的复杂程度也大多来自条件逻辑。
一、分解条件表达式
动机:
程序之中,复杂的条件逻辑是最常导致复杂度上升的地点之一。
和任何大块头代码一样,对于复杂逻辑的函数,我们可以将它分解为多个独立的函数,根据每个小块代码的用途,为分解而得的新函数命名,并将原函数中对应的代码改为调用新函数,从而更清楚地表达自己的意图。对于条件逻辑,将每个分支条件分解成新函数还可以带来更多好处:可以突出条件逻辑,更清楚地表明每个分支的作用,并且突出每个分支的原因。
具体展现:
// 重构前
if (!aDate.isBefore(plan.summerStart) && !aDate.isAfter(plan.summerEnd)) {
charge = quantity * plan.summerRate;
} else {
charge = quantity * plan.regularRate + plan.regularServiceCharge;
}
// 重构后
if (summer()) {
charge = summerCharge();
} else {
charge = regularCharge();
}
二、合并条件表达式
动机:
如果一串检查条件各不相同,但最终行为一致,就应该使用”逻辑或”和”逻辑与”将它们合并为一个条件表达式。
具体展现:
// 重构前
if (anExployee.seniority < 2) return 0;
if (anExployee.monthsDisabled > 12) return 0;
if (anExployee.isPartTime) return 0;
// 重构后
if (isNotEligibleForDisability()) return 0;
function isNotEligibleForDisability() {
return ((anExployee.seniority < 2)
|| (anExployee.monthsDisabled > 12)
|| (anExployee.isPartTime));
}
三、以卫语句取代嵌套条件表达式
动机:
条件表达式通常有两种风格。第一种风格是:两个条件分支都属于正常行为。第二种风格是:只有一个条件分支是正常行为,另一个分支则是异常的情况。
如果两条分支都是正常行为,就应该使用形如if … else …的条件表达式;如果某个条件极其罕见,就应该单独检查该条件,并在该条件为真时从函数中返回。这样的单独检查常常被称为”卫语句”(guard clauses)。
卫语句的精髓就是:给某一条分支以特别的重视。卫语句告诉读者:“这种情况不是本函数的核心逻辑所关心的,如果真的发生了,请做一些必要的整理工作,然后退出”。
具体展现:
// 重构前
function getPayAmount() {
let result;
if (isDead) {
result = deadAmount();
} else {
if (isSeparated) {
result = separatedAmount();
} else {
if (isRetired) {
result = retiredAmount();
} else {
result = normalPayAmount();
}
}
}
return result;
}
// 重构后
function getPayAmount() {
if (isDead) return deadAmount();
if (isSeparated) return separatedAmount();
if (isRetired) return retiredAmount();
return normalPayAmount();
}
四、 以多态取代条件表达式
动机:
一种常见场景:如果有好几个函数都有基于类型代码的switch语句,我们就可以针对每种分支逻辑创建一个类,用多态来承载各个类型特有的行为,从而去除重复的分支逻辑。
另一种情况:基础逻辑可能是最常用的,也可能是最简单的。可以把基础逻辑放进超类,这样就首先可以理解在和部分逻辑,暂时不管各种变体,然后可以把每种变体逻辑单独放进一个子类,其中的代码着重强调与基础逻辑的差异。
具体展现:
// 重构前
switch (brid.type) {
case 'EuropeanSwallow':
return 'average';
case 'AfricanSwallow':
return (bird.numberOfCoconuts > 2) ? 'tired' : 'average';
case 'NorwegianBlueParrot':
return (bird.voltage > 100) ? 'scorched' : 'beautiful';
default:
return 'unknown';
}
// 重构后
class EuropeanSwallow {
get plumage() {
return 'average';
}
}
class AfricanSwallow {
get plumage() {
return (this.numberOfCoconuts > 2) ? 'tired' : 'average';
}
}
class NorwegianBlueParrot {
get plumage() {
return (this.voltage > 100) ? 'scorched' : 'beautiful';
}
}
五、引入特例
动机:
有一种常见的重复代码情况:一个数据结构的使用者都在检查某个特殊的值,并且当这个特殊值出现时所做的处理也都相同。如果发现代码库中有多处以同样方式应对同一个特殊值,应该把这个处理逻辑收拢在一起。处理这种情况的一个好办法就是使用“特例”模式:创建一个特例元素,用以表达对这种特例的共用行为的处理。
一个通常需要特殊处理的值就是null,这也是这个模式常被叫做”Null对象”(Null Object)模式的原因,Null对象是特例的一种特例。
具体展现:
// 重构前
if (aCustomer === "unknown") customerName = "occupant";
// 重构后
class UnknownCustomer {
get name() { return "occupant"; }
}
六、引入断言
动机:
常常会有这样一段代码:只有当某个条件为真时,该段代码才能正常运行。这样的假设通常并没有在代码中明确的表现出了,我们可以使用断言来明确标明这些假设。
断言是一个条件表达式,应该总是为真。如果它失败,表示程序员犯了错误,断言的失败不应被系统任何地方捕获,整个程序的行为在有没有断言出现的时候都应该完全一样。
断言是一种很有价值的交流形式——它告诉阅读者,程序执行到这一点时,对当前状态做出了何种假设。另外断言对调试也很有帮助。
另外,不要滥用断言。不要使用断言来检查“我认为应该为真”的条件,应该来检查“必须为真”的条件。
具体展现:
// 重构前
if (this.discountRate) {
base = base - (this.discountRate * base);
}
// 重构后
assert(this.discountRate >= 0);
if (this.discountRate) {
base = base - (this.discountRate * base);
}