第 57 章 一次完整的重构
前面五十多章,每章只讲一个手法,案例都是为它量身定做的。真实的重构不是这样:你面对的是一段现成的代码和一个具体的需求,要自己判断先做什么、后做什么,一个手法做完往往引出下一个。这一章用一个完整的小模块,从头到尾演示一次:先读懂、补测试,再一步一步改,每一步说明为什么选这个手法,改完跑测试。
Fowler 在原书里把这样的综合案例放在第 1 章。本书放在最后,因为你现在已经认识所有的手法了,读的时候可以把注意力放在“为什么是这一步”上,而不是“这一步是什么”。
起点:一份月度账单
Section titled “起点:一份月度账单”老秦在一家培训机构负责收费系统。机构开三类课:编程课、外语课、兴趣课。每个月给每位学员出一张账单,列出上了哪些课、每门多少钱、合计多少,再算出本月获得的学习积分。
计费规则是教务定的:
- 编程课基础价 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。如果它永远不用改,就让它这样待着。重构的动机永远来自一个具体的需求,这里有两个:
- 家长希望在微信里看到网页版账单,要输出一份 HTML,内容和文本版一样。
- 教务下个月要加一类“竞赛辅导课”,计费和积分规则都不一样,以后可能还会加。
先想一下,不重构的话怎么做。HTML 版只能把 statement 整个复制一份,把拼字符串的地方改成 HTML 标签。可计费规则也跟着复制了一份,下个月加竞赛课,要在两个函数里各加一个分支,规则一改要改两处,迟早会对不上。这正是重构该出场的时候:先把代码整理成“加 HTML 和加课程类型都很容易”的样子,再去加。
第 0 步:先有测试
Section titled “第 0 步:先有测试”重构的第一个前提是测试(第 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)。
第 2 步:去掉临时变量 course
Section titled “第 2 步:去掉临时变量 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 章会细说重构和性能的关系。
第 3 步:提炼积分计算
Section titled “第 3 步:提炼积分计算”积分计算的两行也是一件独立的事,提成 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 里面。
第 5 步:拆分阶段
Section titled “第 5 步:拆分阶段”问题的根子在于 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现在再来做需求
Section titled “现在再来做需求”两个需求现在都很容易。HTML 版只是另一个排版函数,读同一份数据:
def html_statement(invoice: dict, courses: dict) -> str: return render_html(statement_data(invoice, courses))render_html 里只有 HTML 标签,没有一行计费逻辑。竞赛辅导课只是一个新的计算器子类,加进 CALCULATORS 表里,statement_data、两个排版函数都不用动。注意这两件事是在重构完成之后、换上“加功能的帽子”才做的(第 1 章的两顶帽子),它们会改变程序的行为,所以有自己的新测试,和上面那份重构测试分开。
回头看走过的路
Section titled “回头看走过的路”| 步 | 手法 | 章 | 为什么在这时做 |
|---|---|---|---|
| 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 步一直绿到最后,这是“行为没变”唯一可靠的证据。
你也可以试试
Section titled “你也可以试试”把本章的 before.py 或 before.ts 复制到自己的目录,配上测试文件,按上面的顺序自己做一遍,每步提交一次。做完之后和本章的 after 对比。你的结果很可能和本书不一样,比如你也许把计算器做成了函数表而不是类,那也完全可以:重构没有唯一正确的终点,只要测试一直通过、代码比原来更容易应对你面前的需求,就是好的重构。
- 真实的重构从一个具体的需求开始,先想清楚“不重构的话这个需求有多难做”。
- 第一步永远是测试;测试从头到尾不改。
- 先用小手法整理(提炼、改名、去临时变量、拆循环),再用大手法改结构(拆分阶段、多态)。
- 每一步都小到可以单独提交、随时回退。
- 重构完成后再换上加功能的帽子,新功能有自己的测试。