跳转到内容

第 57 章 一次完整的重构

前面五十多章,每章只讲一个手法,案例都是为它量身定做的。真实的重构不是这样:你面对的是一段现成的代码和一个具体的需求,要自己判断先做什么、后做什么,一个手法做完往往引出下一个。这一章用一个完整的小模块,从头到尾演示一次:先读懂、补测试,再一步一步改,每一步说明为什么选这个手法,改完跑测试。

Fowler 在原书里把这样的综合案例放在第 1 章。本书放在最后,因为你现在已经认识所有的手法了,读的时候可以把注意力放在“为什么是这一步”上,而不是“这一步是什么”。

老秦在一家培训机构负责收费系统。机构开三类课:编程课、外语课、兴趣课。每个月给每位学员出一张账单,列出上了哪些课、每门多少钱、合计多少,再算出本月获得的学习积分。

计费规则是教务定的:

  • 编程课基础价 400 元,超过 8 课时的部分每课时加 50 元。
  • 外语课基础价 300 元,超过 10 课时的部分每课时加 30 元,另外每课时固定加 5 元资料费。
  • 兴趣课每课时 40 元。
  • 积分:每门课超过 4 课时的部分每课时 1 分;兴趣课另外每 5 课时奖励 1 分。

这是老秦接手时的代码:

"""月度账单:重构前。"""
COURSES = {
"py": {"name": "Python 编程", "type": "coding"},
"en": {"name": "商务英语", "type": "language"},
"draw": {"name": "素描", "type": "hobby"},
}
def statement(invoice: dict, courses: dict) -> str:
total_amount = 0
credits = 0
result = f"{invoice['student']} 的账单\n"
for enrol in invoice["enrolments"]:
course = courses[enrol["course_id"]]
this_amount = 0
if course["type"] == "coding":
this_amount = 400
if enrol["hours"] > 8:
this_amount += 50 * (enrol["hours"] - 8)
elif course["type"] == "language":
this_amount = 300
if enrol["hours"] > 10:
this_amount += 30 * (enrol["hours"] - 10)
this_amount += 5 * enrol["hours"]
elif course["type"] == "hobby":
this_amount = 40 * enrol["hours"]
else:
raise ValueError(f"未知课程类型:{course['type']}")
credits += max(enrol["hours"] - 4, 0)
if course["type"] == "hobby":
credits += enrol["hours"] // 5
result += f" {course['name']}:{this_amount} 元({enrol['hours']} 课时)\n"
total_amount += this_amount
result += f"合计:{total_amount} 元\n"
result += f"本月积分:{credits}\n"
return result
INVOICE = {
"student": "小周",
"enrolments": [
{"course_id": "py", "hours": 12},
{"course_id": "en", "hours": 8},
{"course_id": "draw", "hours": 10},
],
}
if __name__ == "__main__":
print(statement(INVOICE, COURSES), end="")
# 输出:
# 小周 的账单
# Python 编程:600 元(12 课时)
# 商务英语:340 元(8 课时)
# 素描:400 元(10 课时)
# 合计:1340 元
# 本月积分:20
// 月度账单:重构前。
export interface Course {
name: string;
type: string;
}
export interface Enrolment {
courseId: string;
hours: number;
}
export interface Invoice {
student: string;
enrolments: Enrolment[];
}
export const COURSES: Record<string, Course> = {
py: { name: "Python 编程", type: "coding" },
en: { name: "商务英语", type: "language" },
draw: { name: "素描", type: "hobby" },
};
export function statement(invoice: Invoice, courses: Record<string, Course>): string {
let totalAmount = 0;
let credits = 0;
let result = `${invoice.student} 的账单\n`;
for (const enrol of invoice.enrolments) {
const course = courses[enrol.courseId];
let thisAmount = 0;
if (course.type === "coding") {
thisAmount = 400;
if (enrol.hours > 8) thisAmount += 50 * (enrol.hours - 8);
} else if (course.type === "language") {
thisAmount = 300;
if (enrol.hours > 10) thisAmount += 30 * (enrol.hours - 10);
thisAmount += 5 * enrol.hours;
} else if (course.type === "hobby") {
thisAmount = 40 * enrol.hours;
} else {
throw new Error(`未知课程类型:${course.type}`);
}
credits += Math.max(enrol.hours - 4, 0);
if (course.type === "hobby") credits += Math.floor(enrol.hours / 5);
result += ` ${course.name}:${thisAmount} 元(${enrol.hours} 课时)\n`;
totalAmount += thisAmount;
}
result += `合计:${totalAmount} 元\n`;
result += `本月积分:${credits}\n`;
return result;
}
export const INVOICE: Invoice = {
student: "小周",
enrolments: [
{ courseId: "py", hours: 12 },
{ courseId: "en", hours: 8 },
{ courseId: "draw", hours: 10 },
],
};
process.stdout.write(statement(INVOICE, COURSES));
// 输出:
// 小周 的账单
// Python 编程:600 元(12 课时)
// 商务英语:340 元(8 课时)
// 素描:400 元(10 课时)
// 合计:1340 元
// 本月积分:20

这段代码不长,也没有 bug。如果它永远不用改,就让它这样待着。重构的动机永远来自一个具体的需求,这里有两个:

  1. 家长希望在微信里看到网页版账单,要输出一份 HTML,内容和文本版一样。
  2. 教务下个月要加一类“竞赛辅导课”,计费和积分规则都不一样,以后可能还会加。

先想一下,不重构的话怎么做。HTML 版只能把 statement 整个复制一份,把拼字符串的地方改成 HTML 标签。可计费规则也跟着复制了一份,下个月加竞赛课,要在两个函数里各加一个分支,规则一改要改两处,迟早会对不上。这正是重构该出场的时候:先把代码整理成“加 HTML 和加课程类型都很容易”的样子,再去加。

重构的第一个前提是测试(第 1 章)。这段代码没有测试,所以先补。账单是一段文本,最直接的测试就是把整段输出钉住,再加几个覆盖各种分支的用例:

import unittest
from target import COURSES, INVOICE, statement
EXPECTED = """小周 的账单
Python 编程:600 元(12 课时)
商务英语:340 元(8 课时)
素描:400 元(10 课时)
合计:1340 元
本月积分:20
"""
def one(course_id: str, hours: int) -> dict:
return {"student": "测试", "enrolments": [{"course_id": course_id, "hours": hours}]}
class StatementTest(unittest.TestCase):
def test_full_statement(self):
self.assertEqual(statement(INVOICE, COURSES), EXPECTED)
def test_coding_within_base_hours(self):
self.assertIn("Python 编程:400 元", statement(one("py", 8), COURSES))
def test_language_over_ten_hours(self):
self.assertIn("商务英语:420 元", statement(one("en", 12), COURSES))
def test_hobby_bonus_credits(self):
self.assertIn("本月积分:2\n", statement(one("draw", 5), COURSES))
def test_unknown_type_rejected(self):
with self.assertRaises(ValueError):
statement(one("x", 1), {"x": {"name": "某课", "type": "secret"}})
if __name__ == "__main__":
unittest.main()
import test from "node:test";
import assert from "node:assert/strict";
import { COURSES, INVOICE, statement } from "./target";
const EXPECTED = `小周 的账单
Python 编程:600 元(12 课时)
商务英语:340 元(8 课时)
素描:400 元(10 课时)
合计:1340 元
本月积分:20
`;
const one = (courseId: string, hours: number) => ({ student: "测试", enrolments: [{ courseId, hours }] });
test("完整账单", () => {
assert.equal(statement(INVOICE, COURSES), EXPECTED);
});
test("编程课基础课时内", () => {
assert.match(statement(one("py", 8), COURSES), /Python 编程:400 元/);
});
test("外语课超过 10 课时", () => {
assert.match(statement(one("en", 12), COURSES), /商务英语:420 元/);
});
test("兴趣课奖励积分", () => {
assert.match(statement(one("draw", 5), COURSES), /本月积分:2\n/);
});
test("未知课程类型报错", () => {
assert.throws(() => statement(one("x", 1), { x: { name: "某课", type: "secret" } }));
});

这份测试从头到尾不会再改一个字。接下来每一步改完都跑它,这一章里我就不再每次重复“跑测试,通过”了,默认每步都做。实际操作时还有一个习惯:每一步通过后都提交一次,提交说明写上用的是哪个手法,比如“提炼函数:amount_for”。走错了就回到上一个提交,最多损失几分钟。

第 1 步:把计费的 if 链提成函数

Section titled “第 1 步:把计费的 if 链提成函数”

statement 太长,第一眼最显眼的是中间那段按课程类型算价钱的 if 链。它有自己的输入(课程、报名记录)和输出(这门课多少钱),这是提炼函数(第 4 章)的标准对象。提出来起个名字叫 amount_for:

def amount_for(enrol: dict, course: dict) -> int:
result = 0
if course["type"] == "coding":
result = 400
if enrol["hours"] > 8:
result += 50 * (enrol["hours"] - 8)
...
return result

提的时候顺手把局部变量 this_amount 改名成 result,这是变量改名(第 7 章)。我的习惯是函数的返回值统一叫 result,读的人一眼知道它是要交出去的东西。

原来的循环里这一段变成 this_amount = amount_for(enrol, course)。

amount_for 的两个参数里,course 其实可以从 enrol 算出来:courses[enrol["course_id"]]。一个能由别的参数推出来的参数,是以查询取代参数(第 46 章)的对象;循环里的临时变量 course 也一样,是以查询取代临时变量(第 16 章)的对象。先写一个查询函数:

def course_for(enrol: dict) -> dict:
return courses[enrol["course_id"]]

因为 courses 是 statement 的参数,course_for 要写成 statement 里的嵌套函数才能看见它。Python 和 TS 的嵌套函数都能读外层的变量,正好用上。然后把循环里所有的 course 换成 course_for(enrol),再用改变函数声明(第 6 章)去掉 amount_for 的 course 参数,函数里同样改成调用 course_for(enrol)。最后删掉临时变量 course,这是内联变量(第 5 章)。

这一步会让 course_for 被调用好几次。这是一个有意识的取舍:多查几次字典的代价几乎为零,换来的是少一个临时变量,后面提炼函数时要传的参数也少一个。第 58 章会细说重构和性能的关系。

积分计算的两行也是一件独立的事,提成 credits_for(enrol):

def credits_for(enrol: dict) -> int:
result = max(enrol["hours"] - 4, 0)
if course_for(enrol)["type"] == "hobby":
result += enrol["hours"] // 5
return result

循环里变成 credits += credits_for(enrol)。

第 4 步:拆分循环,再换成管道

Section titled “第 4 步:拆分循环,再换成管道”

现在循环里做着三件事:累加积分、拼每行文本、累加总价。这是拆分循环(第 26 章)的典型信号。拆成三个循环,再把积分和总价的循环提炼成函数:

def total_credits() -> int:
return sum(credits_for(e) for e in invoice["enrolments"])
def total_amount() -> int:
return sum(amount_for(e) for e in invoice["enrolments"])

拆开之后,每个循环都变成了 sum,这一步同时完成了以管道取代循环(第 27 章)。statement 主体只剩一个拼字符串的循环,前后各加一行合计和积分。

到这里先停下来看一眼:statement 从将近三十行缩短了一半,计费、积分、总价各有一个名字。行为没有任何变化,测试一直是绿的。可是 HTML 那个需求还是不好做:要做 HTML 版,仍然得把这些嵌套函数整个复制过去,因为它们都长在 statement 里面。

问题的根子在于 statement 做了两件事:算出账单的数据(每门课多少钱、合计多少、多少积分),和把数据排版成文本。HTML 版需要的是第一件事加上另一种排版。这是拆分阶段(第 11 章):第一阶段产出一个中间数据结构,第二阶段只负责排版。

先用提炼函数把排版部分提成 render_plain_text(data),再建中间结构,把排版需要的东西一项一项搬进去:学员名、每门课的名称、课时、价钱、合计、积分。每搬一项跑一次测试。搬完以后,所有计算都集中在 statement_data(invoice, courses) 里,排版函数只读 data,不知道课程表和计费规则的存在。这一步也收回了前面的几个嵌套函数:statement_data 用一个循环为每门课生成一行数据,合计和积分直接对这些行求和,total_credits、total_amount 不再需要;course_for 也被一个循环内的局部变量 course 取代,因为现在只有这一个循环要用它,第 2 步担心的“临时变量挡住提炼”已经不存在了。重构途中先引入、后来又消失的结构很常见,它们是脚手架,帮你走到下一步:

def statement(invoice: dict, courses: dict) -> str:
return render_plain_text(statement_data(invoice, courses))

第 6 步:以多态取代条件表达式

Section titled “第 6 步:以多态取代条件表达式”

现在轮到第二个需求:以后会加新的课程类型。amount_for 里按类型分支的 if 链,credits_for 里又按类型判断了一次,这就是第 2 章的重复的 switch。每加一种课程,两个地方都得加分支。以多态取代条件表达式(第 38 章)的做法是:为每种课程建一个计算器类,各自实现 amount 和 credits,共同的部分(积分的基本规则)放在父类里。

建计算器的地方需要按类型选子类,这里用以工厂函数取代构造函数(第 48 章),写一个 create_calculator(enrol, course),未知类型在这里报错。statement_data 不再关心类型,只管向计算器要数字。

最终的代码:

"""月度账单:重构后。"""
COURSES = {
"py": {"name": "Python 编程", "type": "coding"},
"en": {"name": "商务英语", "type": "language"},
"draw": {"name": "素描", "type": "hobby"},
}
class CourseCalculator:
def __init__(self, enrol: dict, course: dict) -> None:
self.enrol = enrol
self.course = course
@property
def hours(self) -> int:
return self.enrol["hours"]
def amount(self) -> int:
raise NotImplementedError
def credits(self) -> int:
return max(self.hours - 4, 0)
class CodingCalculator(CourseCalculator):
def amount(self) -> int:
return 400 + 50 * max(self.hours - 8, 0)
class LanguageCalculator(CourseCalculator):
def amount(self) -> int:
return 300 + 30 * max(self.hours - 10, 0) + 5 * self.hours
class HobbyCalculator(CourseCalculator):
def amount(self) -> int:
return 40 * self.hours
def credits(self) -> int:
return super().credits() + self.hours // 5
CALCULATORS = {"coding": CodingCalculator, "language": LanguageCalculator, "hobby": HobbyCalculator}
def create_calculator(enrol: dict, course: dict) -> CourseCalculator:
if course["type"] not in CALCULATORS:
raise ValueError(f"未知课程类型:{course['type']}")
return CALCULATORS[course["type"]](enrol, course)
def statement_data(invoice: dict, courses: dict) -> dict:
lines = []
for enrol in invoice["enrolments"]:
course = courses[enrol["course_id"]]
calc = create_calculator(enrol, course)
lines.append({"name": course["name"], "hours": enrol["hours"], "amount": calc.amount(), "credits": calc.credits()})
return {
"student": invoice["student"],
"lines": lines,
"total_amount": sum(line["amount"] for line in lines),
"total_credits": sum(line["credits"] for line in lines),
}
def render_plain_text(data: dict) -> str:
result = f"{data['student']} 的账单\n"
for line in data["lines"]:
result += f" {line['name']}:{line['amount']} 元({line['hours']} 课时)\n"
result += f"合计:{data['total_amount']} 元\n"
result += f"本月积分:{data['total_credits']}\n"
return result
def statement(invoice: dict, courses: dict) -> str:
return render_plain_text(statement_data(invoice, courses))
INVOICE = {
"student": "小周",
"enrolments": [
{"course_id": "py", "hours": 12},
{"course_id": "en", "hours": 8},
{"course_id": "draw", "hours": 10},
],
}
if __name__ == "__main__":
print(statement(INVOICE, COURSES), end="")
# 输出:
# 小周 的账单
# Python 编程:600 元(12 课时)
# 商务英语:340 元(8 课时)
# 素描:400 元(10 课时)
# 合计:1340 元
# 本月积分:20
// 月度账单:重构后。
export interface Course {
name: string;
type: string;
}
export interface Enrolment {
courseId: string;
hours: number;
}
export interface Invoice {
student: string;
enrolments: Enrolment[];
}
export const COURSES: Record<string, Course> = {
py: { name: "Python 编程", type: "coding" },
en: { name: "商务英语", type: "language" },
draw: { name: "素描", type: "hobby" },
};
abstract class CourseCalculator {
constructor(
protected readonly enrol: Enrolment,
protected readonly course: Course,
) {}
get hours(): number {
return this.enrol.hours;
}
abstract amount(): number;
credits(): number {
return Math.max(this.hours - 4, 0);
}
}
class CodingCalculator extends CourseCalculator {
amount(): number {
return 400 + 50 * Math.max(this.hours - 8, 0);
}
}
class LanguageCalculator extends CourseCalculator {
amount(): number {
return 300 + 30 * Math.max(this.hours - 10, 0) + 5 * this.hours;
}
}
class HobbyCalculator extends CourseCalculator {
amount(): number {
return 40 * this.hours;
}
credits(): number {
return super.credits() + Math.floor(this.hours / 5);
}
}
const CALCULATORS: Record<string, new (enrol: Enrolment, course: Course) => CourseCalculator> = {
coding: CodingCalculator,
language: LanguageCalculator,
hobby: HobbyCalculator,
};
function createCalculator(enrol: Enrolment, course: Course): CourseCalculator {
const Calculator = CALCULATORS[course.type];
if (!Calculator) throw new Error(`未知课程类型:${course.type}`);
return new Calculator(enrol, course);
}
interface StatementLine {
name: string;
hours: number;
amount: number;
credits: number;
}
interface StatementData {
student: string;
lines: StatementLine[];
totalAmount: number;
totalCredits: number;
}
export function statementData(invoice: Invoice, courses: Record<string, Course>): StatementData {
const lines = invoice.enrolments.map((enrol) => {
const course = courses[enrol.courseId];
const calc = createCalculator(enrol, course);
return { name: course.name, hours: enrol.hours, amount: calc.amount(), credits: calc.credits() };
});
return {
student: invoice.student,
lines,
totalAmount: lines.reduce((sum, line) => sum + line.amount, 0),
totalCredits: lines.reduce((sum, line) => sum + line.credits, 0),
};
}
function renderPlainText(data: StatementData): string {
let result = `${data.student} 的账单\n`;
for (const line of data.lines) result += ` ${line.name}:${line.amount} 元(${line.hours} 课时)\n`;
result += `合计:${data.totalAmount} 元\n`;
result += `本月积分:${data.totalCredits}\n`;
return result;
}
export function statement(invoice: Invoice, courses: Record<string, Course>): string {
return renderPlainText(statementData(invoice, courses));
}
export const INVOICE: Invoice = {
student: "小周",
enrolments: [
{ courseId: "py", hours: 12 },
{ courseId: "en", hours: 8 },
{ courseId: "draw", hours: 10 },
],
};
process.stdout.write(statement(INVOICE, COURSES));
// 输出:
// 小周 的账单
// Python 编程:600 元(12 课时)
// 商务英语:340 元(8 课时)
// 素描:400 元(10 课时)
// 合计:1340 元
// 本月积分:20

重构后的结构:statement 拆成计算阶段和排版阶段,中间是账单数据;计算阶段通过工厂函数为每门课选一个计算器子类

两个需求现在都很容易。HTML 版只是另一个排版函数,读同一份数据:

def html_statement(invoice: dict, courses: dict) -> str:
return render_html(statement_data(invoice, courses))

render_html 里只有 HTML 标签,没有一行计费逻辑。竞赛辅导课只是一个新的计算器子类,加进 CALCULATORS 表里,statement_data、两个排版函数都不用动。注意这两件事是在重构完成之后、换上“加功能的帽子”才做的(第 1 章的两顶帽子),它们会改变程序的行为,所以有自己的新测试,和上面那份重构测试分开。

步 手法 章 为什么在这时做
0 补测试 1、58 没有测试不重构
1 提炼函数、变量改名 4、7 最大的一块逻辑先有个名字
2 以查询取代临时变量、以查询取代参数、改变函数声明、内联变量 16、46、6、5 减少参数和临时变量,为后面的提炼扫清障碍
3 提炼函数 4 积分是另一件独立的事
4 拆分循环、以管道取代循环 26、27 一个循环做三件事
5 拆分阶段 11 计算和排版分开,HTML 需求的直接准备
6 以多态取代条件表达式、以工厂函数取代构造函数 38、48 重复的类型分支,新课程类型需求的直接准备

几点值得记住的观察。

顺序不是随意的。 前四步都在“整理”:给代码起名字、减少变量、让每块只做一件事。它们不针对任何需求,但让第 5、6 步变得可行。如果一上来就做拆分阶段,statement 里满是交织在一起的临时变量,中间结构该有哪些字段都很难看清。小手法铺路,大手法收尾,这是重构的常见节奏。

每一步都很小。 最大的一步(拆分阶段)也是分成十来个小动作完成的,每个动作之后测试都通过。真实项目里这样一次重构可能分散在一两天里,中间随时可以停下来去处理别的事,代码始终是能用的。

代码变长了。 重构前 40 行左右,重构后 80 行左右。行数不是衡量代码好坏的标准,衡量标准是改起来容不容易。现在加一种排版、加一种课程,各只需要写一个独立的新单元,不用碰已有的任何一行。

测试一个字没改。 五个用例从第 0 步一直绿到最后,这是“行为没变”唯一可靠的证据。

重构走过的路:从补测试开始,四步整理之后,拆分阶段和多态两步分别为两个需求做好了准备

把本章的 before.py 或 before.ts 复制到自己的目录,配上测试文件,按上面的顺序自己做一遍,每步提交一次。做完之后和本章的 after 对比。你的结果很可能和本书不一样,比如你也许把计算器做成了函数表而不是类,那也完全可以:重构没有唯一正确的终点,只要测试一直通过、代码比原来更容易应对你面前的需求,就是好的重构。

  • 真实的重构从一个具体的需求开始,先想清楚“不重构的话这个需求有多难做”。
  • 第一步永远是测试;测试从头到尾不改。
  • 先用小手法整理(提炼、改名、去临时变量、拆循环),再用大手法改结构(拆分阶段、多态)。
  • 每一步都小到可以单独提交、随时回退。
  • 重构完成后再换上加功能的帽子,新功能有自己的测试。