接上文,这里记录剩下的 3 条技巧(实际对应最后三章内容):简化条件逻辑、API 重构、处理继承关系。
16 简化条件逻辑
程序的大部分威力来自条件逻辑,但很不幸,程序的复杂度也大多来自条件逻辑。
16.1 重构表达式
我们先看一个复杂的表达式:
if (!aDate.isBefore(plan.summerStart) && !aDate.isAfter(plan.summerEnd)) {
charge = quantity * plan.summerRate;
} else {
charge = quantity * plan.regularRate + plan.regularServiceCharge;
}
表达式逻辑其实并不复杂(实际业务中可能更复杂),但可读性却很差。这里可以用我们之前学过的“提炼函数”技巧优化:
// 原来的 if/else 表达式简化成了三元表达式,易读性大大增加
charge = summer() ? summerCharge() : regularCharge();
// 将表达式提炼成函数,通过函数命名就很清楚知道表达式在做什么
function summer() {
return !aDate.isBefore(plan.summerStart) && !aDate.isAfter(plan.summerEnd);
}
function summerCharge() {
return quantity * plan.summerRate;
}
function regularCharge() {
return quantity * plan.regularRate + plan.regularServiceCharge;
}
这就是“分解条件表达式”的重构技巧。
当然,按照我们之前的套路,还得介绍一下它的反向重构技巧:“合并条件表达式”,看下面这段代码:
if (anEmployee.seniority < 2) return 0;
if (anEmployee.monthsDisabled > 12) return 0;
if (anEmployee.isPartTime) return 0;
有时你会发现这样一串条件检查:虽然检查条件各不相同,最终行为却一致。如果发现这种情况,就应该使用“逻辑或”和“逻辑与”将它们合并为一个条件表达式。如上面这段代码可优化为:
// 逻辑合并
if (isNotEligableForDisability()) return 0;
// 提炼函数
function isNotEligableForDisability() {
return ((anEmployee.seniority < 2)
|| (anEmployee.monthsDisabled > 12)
|| (anEmployee.isPartTime));
}
16.2 用卫语句优化嵌套
条件表达式通常有两种风格。第一种风格是:两个条件分支都属于正常行为。第二种风格则是:只有一个条件分支是正常行为,另一个分支则是异常的情况。
如果某个条件极其罕见,就应该单独检查该条件,并在该条件为真时立刻从函数中返回。这样的单独检查常常被称为“卫语句”(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();
}
优化后是不是简单多了!同样,日常研发中我们也经常会先处理异常分支,有异常先 return,这样后面的正常业务代码就不用再嵌套在 if/else 里面了。
16.3 用多态取代条件语句
很多时候,我们可以将条件逻辑拆分到不同的场景,从而拆解复杂的条件逻辑。这种情况,使用类和多态能把逻辑的拆分表述得更清晰。
举个例子,李大爷有一群鸟,他想知道这些鸟飞得有多快,羽毛长啥样,于是写了这样一段代码:
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 'NorwegianBlueParrot':
return (bird.voltage > 100) ? "scorched" : "beautiful";
default:
return "unknown";
}
}
function airSpeedVelocity(bird) {
switch (bird.type) {
case 'EuropeanSwallow':
return 35;
case 'AfricanSwallow':
return 40 - 2 * bird.numberOfCoconuts;
case 'NorwegianBlueParrot':
return (bird.isNailed) ? 0 : 10 + bird.voltage / 10;
default:
return null;
}
}
想想,如果用多态要怎么优化?
我们可以针对switch语句中的每种分支逻辑创建一个类,用多态来承载各个类型特有的行为,从而去除重复的分支逻辑。参考答案:
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 'NorwegianBlueParrot':
return new NorwegianBlueParrot(bird);
default:
return new Bird(bird);
}
}
class Bird {
constructor(birdObject) {
Object.assign(this, birdObject);
}
get plumage() {
return "unknown";
}
get airSpeedVelocity() {
return null;
}
}
class AfricanSwallow extends Bird { ... }
class EuropeanSwallow extends Bird { ... }
class NorwegianBlueParrot extends Bird { ... }
16.4 引入特例
一种常见的重复代码是这种情况:一个数据结构的使用者都在检查某个特殊的值,并且当这个特殊值出现时所做的处理也都相同。如果代码库中有多处以同样方式应对同一个特殊值,则可以把这个处理逻辑收拢到一处。
这就是“特例”(Special Case)模式:创建一个特例元素,用以表达对这种特例的共用行为的处理。这样就可以用一个函数调用取代大部分特例检查逻辑。
17 对 API 重构
模块和函数是软件的骨肉,而API则是将骨肉连接起来的关节。本章将介绍常用的一些 API 重构技巧。
17.1 查询修改分离
这个很好理解,查询操作不应该有副作用,那么再衍生一下:任何有返回值的函数,都不应该有看得到的副作用。虽然这不是一条绝对的规则,但你可以尽量遵循它,如果实在遇到“既有返回值又有副作用”的场景,试着将查询动作从修改动作中分离出来。
17.2 函数参数化
如果两个函数逻辑非常相似,只有一些字面量值不同,可以将其合并成一个函数,以参数的形式传入不同的
值,从而消除重复。这个重构可以使函数更有用,因为重构后的函数还可以用于处理其他的值。
// 重构前
function tenPercentRaise(aPerson) {
aPerson.salary = aPerson.salary.multiply(1.1);
}
function fivePercentRaise(aPerson) {
aPerson.salary = aPerson.salary.multiply(1.05);
}
// 重构后
function raise(aPerson, factor) {
aPerson.salary = aPerson.salary.multiply(factor);
}
17.3 移除标记参数
“标记参数”是这样的一种参数:调用者用它来指示被调函数应该执行哪一部分逻辑。
例如,我们经常看到类似代码:
deliveryDate(anOrder, true);
deliveryDate(anOrder, false);
你可能很好奇参数二的 true/false 到底是干嘛的:
function deliveryDate(anOrder, isRush) {
if (isRush) {
// do something rush...
}
else {
// do something not rush...
}
}
原来调用者用这个布尔型字面量来判断应该运行哪个分支的代码——这就是典型的标记参数。实际上,你可以移除第二个标记参数,直接提炼成两个函数:
function rushDeliveryDate(anOrder) {
// do something rush...
}
function normalDeliveryDate(anOrder) {
// do something not rush...
}
这样,调用的时候语义会更清晰:
rushDeliveryDate(anOrder); // 等价于 deliveryDate(anOrder, true);
normalDeliveryDate(anOrder); // 等价于 deliveryDate(anOrder, false);
17.4 保持对象完整
如果代码从一个记录结构中导出几个值,然后又把这几个值一起传递给一个函数,那么最好整个记录传给这个函数,在函数体内部导出所需的值。
// 优化前
const low = aRoom.daysTempRange.low;
const high = aRoom.daysTempRange.high;
if (aPlan.withinRange(low, high)) {
// ...
}
// 优化后
if (aPlan.withinRange(aRoom.daysTempRange)) {
// ...
}
“传递整个记录”的方式能更好地应对变化:如果将来被调的函数需要从记录中导出更多的数据,就不用为此修改参数列表。并且传递整个记录也能缩短参数列表,让函数调用更容易看懂。如果有很多函数都在使用记录中的同一组数据,处理这部分数据的逻辑常会重复,此时可以把这些处理逻辑搬移到完整对象中去。
17.5 以查询取代参数
函数的参数列表应该总结该函数的可变性,标示出函数可能体现出行为差异的主要方式。和任何代码中的语句一样,参数列表应该尽量避免重复,并且参数列表越短就越容易理解。
如果调用函数时传入了一个值,而这个值由函数自己来获得也是同样容易,这就是重复。这个本不必要的参数会增加调用者的难度,因为它不得不找出正确的参数值,其实原本调用者是不需要费这个力气的。
考虑下面这段代码,discountedPrice 的 discountLevel 真的有必要吗?
get finalPrice() {
const basePrice = this.quantity * this.itemPrice;
let discountLevel;
if (this.quantity > 100) {
discountLevel = 2;
} else {
discountLevel = 1;
}
return this.discountedPrice(basePrice, discountLevel);
}
discountedPrice(basePrice, discountLevel) {
switch (discountLevel) {
case 1: return basePrice * 0.95;
case 2: return basePrice * 0.9;
}
}
实际上完全没必要,看如何把 discountLevel 参数给优化掉:
get finalPrice() {
const basePrice = this.quantity * this.itemPrice;
return this.discountedPrice(basePrice);
}
discountedPrice(basePrice) {
if (this.quantity > 100) {
return basePrice * 0.9;
}
return basePrice * 0.95;
}
当然,还存在另外一种情况,即函数内部引用了一个奇怪的,或者毫无关联的对象,这个时候倾向于“以参数取代查询”来重构。再来看这个例子:
discountedPrice(basePrice) {
switch (otherObj.discountLevel) {
case 1: return basePrice * 0.95;
case 2: return basePrice * 0.9;
default: return basePrice;
}
}
这个 otherObj 不知道从哪儿来的,增加了函数对外界的不明依赖,此时将其抽象成参数就比较合理:
discountedPrice(basePrice, discountLevel) {
switch (discountLevel) {
case 1: return basePrice * 0.95;
case 2: return basePrice * 0.9;
default: return basePrice;
}
}
17.6 以工厂函数代替构造函数
很多面向对象语言都有特别的构造函数,专门用于对象的初始化。但与一般的函数相比,构造函数又常有一些丑陋的局限性。例如,Java的构造函数只能返回当前所调用类的实例,也就是说,我无法根据环境或参数信息返回子类实例或代理对象;构造函数的名字是固定的,因此无法使用比默认名字更清晰的函数名;构造函数需要通过new操作符来调用,所以在要求普通函数的场合就难以使用。工厂函数就不受这些限制。工厂函数的实现内部可以调用构造函数,但也可以换成别的方式实现。
// 优化前
leadEngineer = new Employee(document.leadEngineer, 'E');
// 优化后
leadEngineer = createEngineer(document.leadEngineer);
18 处理继承关系
最后说一下继承。与任何强有力的特性一样,继承机制十分实用,却也经常被误用。
18.1 上移&下移
如果各个子类中的函数、字段有共同行为,那么通过上移到超类可以减少重复逻辑。
同理,如果超类中的某个函数只与一个(或少数几个)子类有关,那么最好将其从超类中挪走,放到真正关心它的子类中去。这项重构手法只有在超类明确知道哪些子类需要这个函数时适用。如果超类不知晓这个信息,那可以用以多态取代条件语句(16.3 有说到),只留些共用的行为在超类。
18.2 以子类取代类型码
软件系统经常需要表现“相似但又不同的东西”,比如员工可以按职位分类(工程师、经理、销售),订单可以按优先级分类(加急、常规)。这时我们可以用枚举、符号、字符串来实现类型码。
大多数时候,有这样的类型码就够了。但也有些时候引入子类会更合适。继承有两个诱人之处,首先,你可以用多态来处理条件逻辑。如果有几个函数都在根据类型码的取值采取不同的行为,多态就显得特别有用。引入子类之后,还可以用以多态取代条件语句来处理这些函数。
另外,有些字段或函数只对特定的类型码取值才有意义,例如“销售目标”只对“销售”这类员工才有意义。此时我可以创建子类,然后用字段下移把这样的字段放到合适的子类中去。
// 优化前
function createEmployee(name, type) {
return new Employee(name, type);
}
// 优化后
function createEmployee(name, type) {
switch (type) {
case "engineer": return new Engineer(name);
case "salesman": return new Salesman(name);
case "manager": return new Manager (name);
}
}
18.3 组合优于继承
继承很好,但是给类之间引入了非常紧密的关系。在超类上做任何修改,都很可能破坏子类。有一条设计原则就叫“对象组合优于类继承”。当你不确定继承能给你带来的便捷性时,说明此时继承并非最佳方案。
总结
《重构》展示了很多实战策略,有些技巧其实是相似的,所以本文没有记录出来。重构也是编码的一种技巧, 可以结合设计模式和代码大全一起来看。当你从这些分散的技巧能抽象出共性原则,从设计原则能想到实际的应用场景时,说明你就是一名成熟的开发者了。