如果你要给程序添加一个特性,但发现代码因缺乏良好的结构而不易于进行更改,那就先重构那个程序,使其比较容易添加特性,然后再添加该特性。
重构的第一个例子
//plays.json...
{
"hamlet": {"name": "Hamlet", "type": "tragedy"},
"as-like": {"name": "As You Like It", "type": "comedy"},
"othello": {"name": "Othello", "type": "tragedy"},
}
//invoices.json...
[
{
"customer": "BigCo",
"performances": [
{"playID": "Hamlet", "audience": 55},
{"playID": "as-like", "audience": 35},
{"playID": "othello", "audience": 40},
]
}
]
// 下面函数用于打印账单详情
function statement (invoice, plays) {
let totalAmount = 0;
let volumeCredits = 0;
let result = `Statement for ${invoice.customer}\n`;
const format = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", minimumFractionDigits: 2 }).format;
for (let perf of invoice.performances) {
const play = plays[perf.playID];
let thisAmount = 0;
switch (play.type) {
case "tragedy":
thisAmount = 40000;
if (perf.audience > 30) {
thisAmount += 1000 * (perf.audience - 30);
}
break;
case "comedy":
thisAmount = 30000;
if (perf.audience > 20) {
thisAmount += 10000 + 500 * (perf.audience - 20);
}
thisAmount += 300 * perf.audience;
break;
default:
throw new Error(`unknow type: ${play.type}`);
}
// add volume credits
volumeCredits += Math.max(perf.audience - 30, 0);
// add extra credit for every ten comedy attendees
if ("comedy" === play.type) {
volumeCredits += Math.floor(perf.audience / 5);
}
// print line for this order
result += `${play.name}: ${format(thisAmount/100)} (${perf.audience} seats)\n`;
totalAmount += thisAmount;
}
result += `Amount owed is ${format(totalAmount/100)}\n`;
result += `You earned ${volumeCredits} credits\n`;
return result;
}
// 使用上面的json数据文件测试输出
这段程序设计如何?代码组织不清晰,但还在可忍受的限度内。如果在一个更大规模的程序中,把所有代码放到一个函数里就很难理解了。
在这个例子中,如果用户希望对系统做几个修改:
1、以HTML格式输出详单
2、演员们尝试在表演类型上做更多突破,无论是历史剧、田园剧、田园戏剧、历史悲剧等,这对戏剧场次的计费方式、积分的计算方式都有影响
我们需要强调,是需求的变化使重构变得必要。如果一段代码能正常工作且不会被修改,那么完全可以不去重构它。能改进当然更好,但是确实有人需要理解它的工作原理,并且觉得理解起来很费劲,那么就需要改进一下代码了。
第一步
进行重构的第一个步骤永远相同:我得确保即将修改的代码拥有一组可靠的测试。重构前,先检查自己是否有一套可靠的测试集。这些测试必须有自我检查能力。
进行重构时,我们需要依赖测试。测试是bug检测器,它能保护我们不被自己犯的错误所困扰。构筑测试体系对重构来说实在太重要了。测试我们在后面章节会详细讨论。
分解statement函数
这块代码是在计算一场戏剧演出的费用,我们先对它进行分解,这种重构手法叫做提炼函数,比如命名为amountFor(performance)。
每次想将一块代码抽取成一个函数时,我们都需要遵循一个标准流程,最大程度减少犯错的可能。首先,检查哪些变量会离开原本的作用域。此示例中的perf、play、thisAmount,前两个变量会被提炼后的函数使用,但不会被修改,可以以参数方式传递进来,thisAmount会被修改,可以直接将它从函数中直接返回。
function amountFor (aPerformance, play) {
let result= 0;
switch (play.type) {
case "tragedy":
result= 40000;
if (aPerformance.audience > 30) {
result+= 1000 * (aPerformance.audience - 30);
}
break;
case "comedy":
result= 30000;
if (aPerformance.audience > 20) {
result+= 10000 + 500 * (aPerformance.audience - 20);
}
result+= 300 * aPerformance.audience;
break;
default:
throw new Error(`unknow type: ${play.type}`);
}
return result;
}
重构技术就是以微小的步伐修改程序,如果你犯下错误,很容易便可发现它,每次修改后就运行测试。如果改动太多了,犯错时就可能陷入麻烦的调试,耗费大把实践。
移除play变量
观察amountFor函数参数,aPerformance是从循环变量中来,每次循环都会改变,但play是从计算得到的,因此没必要将它作为参数传入,我们可以在amountFor函数中计算得到。一般太多的具有局部作用域的临时变量会使提炼函数变得更加复杂,这种重构手法是以查询取代临时变量。
删除play变量定义,由函数playFor返回,去掉amountFor函数参数play...
function playFor(aPerformance) {
return plays[aPerformance.playID]
}
提炼计算观众量积分的逻辑
移除play变量的好处,即移除一个局部作用域变量,提炼这个逻辑更简单一些。还有两个变量:perf可以轻易作为参数传入,但volumeCredits是一个累加变量,循环迭代会更新,最简单方式是将整块逻辑提炼到新函数:
function volumeCreditsFor(perf) {
let volumeCredits = 0;
volumeCredits += Math.max(perf.audience - 30, 0);
if ("comedy" === perf.play.type) {
volumeCredits += Math.floor(perf.audience / 5);
}
return volumeCredits;
}
移除format变量
这里有个典型的“将函数赋值给临时变量”的场景,更好的方式是将其替换为一个明确声明的函数。
function usd(aNumber) {
return new Intl.NumberFormat("en-US",
{ style: "currency", currency: "USD",
minimumFractionDigits: 2 }).format(aNumber/100);
}
移除观众量积分总和
在移除volumeCredits的过程中,主要分为下面4步:
1、使用拆分循环分离出累加过程;
2、使用移动语句将累加变量的声明与累加过程集中到一起;
3、使用提炼函数提炼出计算总数的函数;
4、使用内联变量完全移除中间变量。
function totalAmount() {
let result = 0;
for (let perf of invoice.performances) {
result += amountFor(perf);
}
return result;
}
function totalVolumeCredits() {
let result = 0;
for (let perf of invoice.performances) {
result += volumeCreditsFor(perf);
}
return result;
}
拆分计算阶段与格式化阶段
到目前为止,我们重构主要是为原函数添加足够的结构,看清它的逻辑结构。要实现复用有许多方法,拆分阶段就是其中一种。现在将逻辑分成两部分:一部分计算详单所需的数据,另一部分将数据渲染成文本或HTML。然后分离到两个文件中:
// statement.js...
import createStatementData from "./createStatementData";
function statement (invoice, plays) {
return renderPlainText(createStatementData(invoice, plays));
}
function renderPlainText (data) {
let result = `Statement for ${data.customer}\n`;
for (let perf of data.performances) {
result += `${perf.play.name}: ${usd(perf.amount)} (${perf.audience} seats)\n`;
}
result += `Amount owed is ${usd(data.totalAmount)}\n`;
result += `You earned ${data.totalVolumeCredites} credits\n`;
return result;
}
function htmlStatement(invoice, plays) {
return renderHtml(createStatementData(invoice, plays));
}
function renderHtml(data) {
let result = `<h1>Statement for ${data.customer}</h1>\n`;
result += '<table>\n';
result += '<tr><th>play</th><th>seates</th><th>costs</th></tr>';
for (let perf of data.performances) {
result += ` <tr><td>${perf.play.name}</td><td>${perf.audiance}</td>`;
result += `<td>${usd(perf.amount)}</td></tr>`
}
result += '</table>\n';
result += `<p>Amount owed is <em>${usd(data.totalAmount)}</em></p>\n`;
result += `<p>You earned <em>${data.totalVolumeCredites}</em> credits</p>\n`;
return result;
}
function usd(aNumber) {
return new Intl.NumberFormat("en-US",
{ style: "currency", currency: "USD",
minimumFractionDigits: 2 }).format(aNumber/100);
}
// createStatementData.js...
export default function createStatementData(invoice, plays) {
const result = {};
result.customer = invoice.customer;
result.performances = invoice.performances.map(enrichPerformance);
result.totalAmount = totalAmount(result);
result.totalVolumeCredits = totalVolumeCredits(result);
return result;
}
function enrichPerformance(aPerformance) {
const result = Object.assign({}, aPerformance);
result.play = playFor(result);
result.amount = amountFor(result);
result.volumeCredits = volumeCreditsFor(result);
return result;
}
function playFor(aPerformance) {
return plays[aPerformance.playID]
}
function amountFor (aPerformance) {
let result = 0;
switch (aPerformance.play.type) {
case "tragedy":
result = 40000;
if (aPerformance.audience > 30) {
result += 1000 * (aPerformance.audience - 30);
}
break;
case "comedy":
result = 30000;
if (aPerformance.audience > 20) {
result += 10000 + 500 * (aPerformance.audience - 20);
}
result += 300 * aPerformance.audience;
break;
default:
throw new Error(`unknow type: ${aPerformance.play.type}`);
}
return result;
}
function volumeCreditsFor(aPerformance) {
let result = 0;
result += Math.max(aPerformance.audience - 30, 0);
if ("comedy" === aPerformance.play.type) {
result += Math.floor(aPerformance.audience / 5);
}
return result;
}
function totalAmount() {
// 以管道取代循环
// let result = 0;
// for (let perf of invoice.performances) {
// result += amountFor(perf);
// }
// return result;
return data.performances.reduce((total, p) => total + p.amount, 0)
}
function totalVolumeCredits(data) {
// let result = 0;
// for (let perf of invoice.performances) {
// result += volumeCreditsFor(perf);
// }
// return result;
return data.performances.reduce((total, p) => total + p.volumeCredits, 0)
}
虽然代码行数由开始重构时的40多行增加到了70多行,主要是将代码抽取到函数里带来的额外包装成本。代码行数虽然增加,但重构也带来了代码可读性的提高,后面的扩展也容易了许多。
使用多态计算器来提供数据
再次调整代码结构,将不同的戏剧种类的计算各自集中到了一处地方。如果大多数修改都涉及特定类型的计算,像这样按类型进行分离就很有意义。当添加新剧种时,只需添加一个子类,并在创建函数中返回它。
export default function createStatementData(invoice, plays) {
const result = {};
result.customer = invoice.customer;
result.performances = invoice.performances.map(enrichPerformance);
result.totalAmount = totalAmount(result);
result.totalVolumeCredits = totalVolumeCredits(result);
return result;
}
function enrichPerformance(aPerformance) {
const calculator = createPerformanceCalculator(aPerformance, playFor(aPerformance));
const result = Object.assign({}, aPerformance);
result.play = calculator.play;
result.amount = calculator.amount;
result.volumeCredits = calculator.volumeCredits;
return result;
}
function playFor(aPerformance) {
return plays[aPerformance.playID]
}
// function amountFor (aPerformance) {
// ...
// }
// function volumeCreditsFor(aPerformance) {
// ...
// }
function totalAmount() {
return data.performances.reduce((total, p) => total + p.amount, 0)
}
function totalVolumeCredits(data) {
return data.performances.reduce((total, p) => total + p.volumeCredits, 0)
}
function createPerformanceCalculator(aPerformance, aPlay) {
switch (aPlay.type) {
case 'tragedy': return new TragedyCalculator(aPerformance, aPlay);
case 'comedy': return new ComedyCalculator(aPerformance, aPlay);
default:
throw new Error(`unknow type: ${aPlay.type}`);
}
}
class PerformanceCalculator {
constructor(aPerformance, aPlay) {
this.performance = aPerformance;
this.paly = aPlay;
}
get amount() {
throw new Error('subclass responsibility');
}
get volumeCredits() {
return Math.max(this.performance.audience - 30, 0);
}
}
class TragedyCalculator extends PerformanceCalculator {
get amount() {
let result = 40000;
if (this.performance.audience > 30) {
result += 1000 * (this.performanceaudience.audience - 30);
}
return result;
}
}
class ComedyCalculator extends PerformanceCalculator {
get amount() {
let result = 30000;
if (this.performance.audience.audience > 20) {
thisAmount += 10000 + 500 * (this.performance.audience - 20);
}
result += 300 * this.performance.audience;
return result;
}
get volumeCredits() {
return super.volumeCredits + Math.floor(this.performance.audience / 5);
}
}
结语
通过这个简单的例子,或许能让我们对“重构怎么做”有一点感觉。示例中我只是选取了部分关键步骤的代码,如果需要了解更详细的过程可以阅读本书。这段重构有3个较为重要的节点:将原函数分解为一套嵌套的函数、应用拆分阶段分离计算逻辑和输出格式化逻辑、计算器引入多态性来处理计算逻辑。每一步都添加了更多的结构,以便能更好的表达代码的意图。
好代码的检验标准就是人们是否能轻而易举地修改它。。。
下一章:代码的坏味道