重构:改善代码的实战技巧(下)

117 阅读10分钟

重构:改善代码的实战技巧(上)

接上文,这里记录剩下的 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 组合优于继承

继承很好,但是给类之间引入了非常紧密的关系。在超类上做任何修改,都很可能破坏子类。有一条设计原则就叫“对象组合优于类继承”。当你不确定继承能给你带来的便捷性时,说明此时继承并非最佳方案。

总结

《重构》展示了很多实战策略,有些技巧其实是相似的,所以本文没有记录出来。重构也是编码的一种技巧, 可以结合设计模式和代码大全一起来看。当你从这些分散的技巧能抽象出共性原则,从设计原则能想到实际的应用场景时,说明你就是一名成熟的开发者了。