一场手术看全程:重构是什么
1. 这一章讲什么
原书不用定义开书,而是直接做一场手术:一个戏剧演出团的账单程序,功能正常,但写成了一个大函数。作者当着读者的面,用一串小改动把它变干净——每一步都小到几乎无聊,每一步做完都跑一遍测试。读完这一章你会知道:重构的动作长什么样、节奏是什么、为什么每步都要那么小。定义和原则留给后面的章节;这一章只负责让你「见过」。
2. 顶层全景:要动的那个程序
一家戏剧演出团给客户发账单:客户订了几出戏,剧团按剧目类型和观众人数收费,再按人数发「观众量积分」当折扣。数据存在两个文件里,程序读进来,打印一张文本账单1。
plays.json(剧目表) invoices.json(账单)
hamlet → 悲剧 BigCo: hamlet 55 座
as-like → 喜剧 as-like 35 座
othello → 悲剧 othello 40 座
└────────── statement 函数 ──────────┘
│ 44 行,一个大循环,里面塞着
│ 计费 switch + 积分累计 + 拼字符串
▼
"Statement for BigCo
Hamlet: $650.00 (55 seats) ... Amount owed is $1,730.00
You earned 47 credits"
图说:计费、积分、排版三件事挤在同一个函数里——这正是后面所有改动要拆的东西。
计费规则本身不长。金额以美分计:悲剧基础 40000 美分,超过 30 座每座加 1000;喜剧基础 30000 美分,超过 20 座先加 10000 再每座加 500,另外每座再加 300。积分:每场 max(人数−30, 0),喜剧再多加人数除以 5 取整。拿真实数据核一遍:hamlet 55 座 → 40000 + 1000×(55−30) = 65000 美分 = $650.00;as-like 35 座 → 30000 + 10000 + 500×15 + 300×35 = 58000 = $580.00;othello 40 座 → 50000 = $500.00。合 计 173000 美分,输出 $1,730.00;积分 25 + 12 + 10 = 47。这三行输出和 47 个积分都是原书给出的真实运行结果,后面每一步改动都要守住它们不变1。
3. 为什么要动它:两个马上要来的需求
程序能跑,评论只有一句「组织不甚清晰」。放在几百行的真实系统里,这样的函数意味着:想改的时候找不到改的地方,一改就错。作者给出的行动法则写成了箴言:要加特性而代码不好加时,「那就先重构那个程序,使其比较容易添加该特性,然后再添加该特性」2。
接下来真的来了两个需求:一,账单要出 HTML 版;二,剧团要尝试更多剧种,计费和积分规则必改——作者的判断是「他们一定会在6个月之内再次修改它」3。如果为了 HTML 直接复制一份函数,将来每次改计费就得改两处并保证一致。所以先动结构。注意作者的限定:「是需求的变化使重构变得必要。如果一段代码能正常工作,并且不会再被修改,那么完全可以不去重构它」4。
4. 主走查:这场手术的全程
动第一刀之前先做一件事:给 statement 配上自我检验的测试——拿几张账单跑函数,把输出和人工核对过的字符串(一段字符文本)比对,红了就是改坏了。这套网怎么搭是第 04 章的主题,这里只交代它的存在;没有它,后面每一步都走不稳。
第一刀:提炼计费函数。 循环中间那段 switch 一眼能看出「在算一场演出的费用」,但作者提醒:这种理解只是脑子里「转瞬即逝的灵光」,得把它搬回代码里5。搬法是把这段代码抽成独立函数 amountFor,按它「做什么」命名。抽之前要清点变量:被用的(perf、play)变成参数(传给函数的输入值)传进去,被改的(thisAmount)变成返回值带出来。改完立刻跑测试——绿了就提交。这一步用的手法叫提炼函数(把一段代码抽成一个有名字的新函数,原处改为调用它)。
箴言:重构技术就是以微小的步伐修改程序。如果你犯下错误,很容易便可发现它。6
第二刀:改名。 thisAmount 改成 result(作者的习惯:函数返回值一律叫 result),参数改成 aPerformance——在 JavaScript 这种没有静态类型检查的语言里,把类型写进参数名,读的人不用猜7。
第三刀:去掉临时变量 play。 play 是从 perf 算出来的,没必要作为参数传来传去。先提炼一个查询函数 playFor(perf),再把 play 变量内联掉,最后用「改变函数声明」把参数从 amountFor 的签名上摘掉。这时警钟响起:原来每次循环只查一次剧目,现在要查三次。作者的处理是:先不管。「大多数情况下可以忽略它。如果重构引入了性能损耗,先完成重构,再做性能优化」8。理由在 12 章还会展开:性能只集中在少数热点上,而结构好的代码回头调优容易得多。
第四刀:同样手法清理积分和格式化。 积分累计提炼成 volumeCreditsFor;格式化函数从临时变量 format 变成真函数,改名 usd,顺手把「除以 100」(美分转美元)也搬进去——用整数美分存钱是常见做法,避免浮点数存货币出错。
第五刀:移除累加变量 volumeCredits。 这个变量在循环里累加,不好直接抽。套路是四小步,每步都编译、测试、提交:①拆分循环,把积分累加单独成一个循环;②移动语句,把声明挪到循环旁边;③把循环提炼成函数 totalVolumeCredits;④内联变量,彻底删掉中间变量。totalAmount 照方抓药,插曲是:函数最好就叫 totalAmount,可名字被变量占着,于是先给函数随便起名 appleSauce,等变量删了再改回来——名字冲突就这样绕过去。
中盘小结(原书第一个节点): 顶层 statement 只剩 7 行,打印是打印、计算是计算。这是原书说的三个节点中的第一个:把大函数分解成一组小函数9。
第六刀:拆分阶段。 新需求(HTML 版)要复用计算逻辑,但现在所有小函数都嵌套在文本版函数里。办法是把整段逻辑劈成前后两截:前一段 createStatementData 只算账,产出一个中转数据结构(两个阶段之间传递数据的容器);后一段只管把数据渲染成文本或 HTML。填充中转结构时每场演出被「加料」成 {play, amount, volumeCredits},用的是浅拷贝——「我总是尽量保持数据不可变」,可变状态是烫手山芋10。拆完之后,HTML 版只需 17 行渲染代码,一行计算逻辑都不用复制。这是第二个节点:计算与排版分离。
第七刀:多态计算器。 第二个需求(新剧种)落在计费分支上。现在的 switch 每加一个剧种就多一条 case,散落两处。做法:先把计费和积分函数搬进一个类 PerformanceCalculator(第三个节点:按类型组织计算),再建悲剧/喜剧两个子类,把 switch 的每个分支下沉到对应子类里;用一个工厂函数按 play.type 决定造哪个子类——JavaScript 的构造函数没法返回子类,所以必须经过这层普通函数。超类里的 amount 只剩一行 throw new Error('subclass responsibility')(子类的责任):谁来继承谁负责实现。积分计算的通用部分 max(座−30, 0) 留在超类, 喜剧子类加一点再调超类方法。
终盘。 代码从 44 行变成 70 行,「这主要是将代码抽取到函数里带来的额外包装成本」——行数涨了,但可读性和可改性换回来了11。
5. 作者的判断与证据
| 判断 | 证据/性质 |
|---|---|
| 好代码有客观标准:「好代码的检验标准就是人们是否能轻而易举地修改它」12 | 作者立场,全书反复论证;不是美学偏好 |
| 小步更快:步子小+每步可工作,出错时只需看一小段 | 作者 20 年实践;Kent Beck 现场演示给他同样的震撼13 |
| 性能异议先搁置 | 基于软件性能只集中于少数代码的经验(原书 2.8 节,本拆解第 12 章讲) |
| 营地法则:离开时比来时更健康 | 作者对「没时间重构」的标准回答14 |
判断(我们的,不是书里的): 这个示例最值钱的不是手法清单,而是它演示了「理解 」与「代码」的互相喂养——先提炼才能看清结构,看清结构才发现下一处该改哪里。原书把它叫「积极正向的反馈环」。如果错,会错在: 如果读者拿到的代码本身写得极好,这个反馈环的收益会趋近于零——重构的收益与代码现状的糟糕程度成正比,这一点原书没有明说。
6. 边界与局限
- 示例太小,不配重构。 作者自己承认:这个小程序「根本不值得我们这么做」,请读者把它想象成大系统的一部分15。真实系统里变量更多、步骤更碎。
- JavaScript 是展示载体,不是推荐语言。 「这不是一本“用JavaScript进行重构”的书,而是一本关于重构的通用书籍」16;JS 特有的重构(回调改 promise 等)不在书里。与 Java 版的可见差异:JS 无编译期检查(「编译」一词在这里泛指运行前的所有准备步骤)、支持嵌套函数从而省掉传参、构造函数不能返回子类所以多态要经过工厂函数。这些差异影响的是手法的步骤细节,不是手法本身。
- 本章所有数字($650.00、47 credits、40000 美分)除 44→70 行的行数对比外,全部直接来自原书示例数据,不是编造。
7. 可带走的
- 重构的触发器是「需求要来了,而代码不好改」,不是「代码丑」。
- 每一步:改动小到能一眼检查 → 跑测试 → 提交;步子大了出错,回滚重走更小的步。
- 抽函数前先清点变量:用的变参数、改的变返回值;被赋值太多次就先别抽。
- 临时变量是提炼的天敌;能变成查询函数的就变成查询函数。
- 复用先拆阶段:算账的一段、排版的一段,中间用数据结构接头。
- 多个函数按同一套类型分支时,把分支收敛(聚拢到一处)到一个工厂函数 + 子类。
- 性能异议先记下,重构完再调优。
8. 原文地图
| 主题 | 原书章 | 原文位置 |
|---|---|---|
| 示例程序与输出 | 第1章 §1.1 | text/09-ch01.txt:35(搜「plays.json」) |
| 先重构再加特性 | 第1章 §1.2 | text/09-ch01.txt:140(搜「但发现代码因缺乏良好的结构」) |
| 需求变化使重构必要 | 第1章 §1.2 | text/09-ch01.txt:160(搜「是需求的变化使重构变得必要」) |
| 测试先行箴言 | 第1章 §1.3 | text/09-ch01.txt:180(搜「可靠的测试集」) |
| 提炼 amountFor | 第1章 §1.4 | text/09-ch01.txt:243(搜「amountFor」) |
| 小步箴言 | 第1章 §1.4 | text/09-ch01.txt:325(搜「以微小的步伐修改程序」) |
| 参数带类型命名 | 第1章 §1.4 | text/09-ch01.txt:389(搜「动态类型语言」) |
| 性能警钟与处理 | 第1章 §1.4 | text/09-ch01.txt:559(搜「敲响警钟」) · text/09-ch01.txt:887(搜「大多数情况下可以忽略它」) |
| 移除 volumeCredits 的 4 小步 | 第1章 §1.4 | text/09-ch01.txt:890(搜「一共有4」) |
| 保持数据不可变 | 第1章 §1.6 | text/09-ch01.txt:1184(搜「尽量保持数据不可变」) |
| 44 行到 70 行 | 第1章 §1.7 | text/09-ch01.txt:1511(搜「44行增加到了70行」) |
| 工厂函数的由来 | 第1章 §1.8 | text/09-ch01.txt:1746(搜「JavaScript的构造函数里无法返回子类」) |
| 三个节点 | 第1章 §1.10 | text/09-ch01.txt:1961(搜「3个较为重要的节点」) |
| 好代码的标准 | 第1章 §1.10 | text/09-ch01.txt:1979(搜「轻而易举地修改它」) · text/09-ch01.txt:1984(搜「小的步子可以更快前进」) |