第 58 章 什么时候重构、什么时候不
前面讲的都是怎么重构。这一章讲什么时候重构:在一天的工作里,重构该占在哪里;面对没有测试的老代码怎么开始;怎么和性能、代码评审、分支策略相处;怎么向同事和老板解释;以及什么时候该放弃重构,推倒重写。
Fowler 把日常的重构分成几种时机,它们的共同点是:重构几乎总是和某个具体的开发任务绑在一起,而不是单独排期的“重构周”。
预备性重构:为了加功能先把地整平。 这是最常见、回报最高的一种。你要加一个需求,打开代码发现现在的结构让这个需求很难加:要复制一大段、要在三个地方各改一点、要先读懂一个两百行的函数。这时先花半小时重构,让新需求变成“加一个函数”或“加一个子类”,再去加。第 57 章就是一个完整的预备性重构:为了 HTML 账单和新课程类型,先拆分阶段、再引入多态。Fowler 在书里引用过 Jessica Kerr 的一个比方:要往东走一百公里,与其直接钻进树林,不如先往北开二十公里上高速。
帮助理解的重构:读懂了就把理解写进代码。 你在读一段代码,花了十分钟才弄明白 calc(d, r) 在算什么。弄明白之后,顺手把它改名成 shipping_fee(distance_km, rate)。下一个读的人,包括三个月后的你,就不用再花这十分钟。理解是你已经付出的成本,把它留在代码里,而不是只留在你的脑子里。
捡垃圾式重构:看见了就顺手清一点。 你在改一个函数,旁边有一段明显的重复代码或者一个糟糕的变量名,跟你手头的任务没关系。如果五分钟能清掉,就清掉;如果要半天,记下来,回头再说。不要因为看见了垃圾就放下手头的活去大扫除,但也别每次都从它旁边绕过去。营地法则说的就是这个:离开时让营地比来时干净一点。
有计划的重构:少数情况下单独排期。 前三种都是穿插在日常开发里的。只有当某块代码已经烂到每个需求都绕不过去、积累的问题大到穿插着做不完时,才值得单独排一段时间去重构。这应该是例外。如果一个团队经常需要“重构冲刺”,说明日常的重构做得太少了。
很多人担心重构会让程序变慢:拆出来一堆小函数、多遍历一次列表、临时变量换成查询方法,每一样都多花一点时间。这些担心大多不成立,即使成立,正确的应对也不是“不重构”。
第一,大部分程序的大部分时间花在一小部分代码上。真正的性能瓶颈通常在数据库查询、网络请求、算法复杂度这些地方,不在多调用一次函数上。第 26 章拆分循环时说过:多遍历一次几千条数据的列表,差别是微秒级的。
第二,猜测哪里慢几乎总是猜错。正确的做法是先把代码写清楚,然后用性能分析工具(Python 的 cProfile、浏览器和 Node 的 profiler)测量,找到真正的热点,只优化那几处。
第三,清楚的代码更容易优化。一个拆分得当、职责清楚的程序,找到热点后往往只需要改一个函数;一团纠缠在一起的代码,优化时连从哪里下手都不知道。所以顺序是:先重构到清楚,再测量,再针对性地优化。优化时可能会让局部代码变得不那么好读,那是值得的,因为它只发生在测量证明了需要的地方。
重构与遗留代码
Section titled “重构与遗留代码”最常见的困境是:要改的代码没有测试,而没有测试就不能安全地重构。Michael Feathers 在《修改代码的艺术》里给“遗留代码”下的定义就是“没有测试的代码”。
办法是先补测试,但不是补“正确行为”的测试,而是补“现有行为”的测试,Feathers 叫它特征测试(characterization test)。你不需要知道代码应该做什么,只需要把它现在做什么记录下来:调用它,看输出,把输出写成断言。哪怕现有行为里有 bug,也先照原样钉住,重构的目标是不改变行为,修 bug 是另一件事。
"""一段没有测试的老代码:会员积分。"""
def points(amount, level, birthday_month, month): p = int(amount / 10) if level == "gold": p = p * 2 elif level == "silver": p = int(p * 1.5) if birthday_month == month: p += 100 if amount > 1000 and level != "gold": p += 50 return p
if __name__ == "__main__": print(points(250, "gold", 3, 3), points(1500, "silver", 1, 2), points(99, "basic", 5, 6))
# 输出:# 150 275 9"""特征测试:不问应该是多少,只记录现在是多少。"""
import unittest
from target import points
class CharacterizationTest(unittest.TestCase): def test_gold_on_birthday(self): self.assertEqual(points(250, "gold", 3, 3), 150)
def test_silver_big_order(self): self.assertEqual(points(1500, "silver", 1, 2), 275)
def test_gold_big_order_gets_no_bonus(self): self.assertEqual(points(1500, "gold", 1, 2), 300)
def test_basic_rounds_down(self): self.assertEqual(points(99, "basic", 5, 6), 9)
if __name__ == "__main__": unittest.main()写特征测试的过程本身就是在读代码。比如第三个用例,写之前你可能以为金卡会员大额消费也有 50 分奖励,跑一下才发现没有。这到底是 bug 还是业务规则,去问产品;问清楚之前,测试按现状写。
补测试时从你要改的地方周围开始,不要试图一次给整个系统补全测试。你要改积分规则,就给 points 补测试;不相干的模块等哪天要改它们时再说。这样测试会沿着你实际修改的路径慢慢长出来,最常改的代码最先有保护。
有些老代码难到连测试都没法写:函数里直接连数据库、读系统时间、调外部接口。这时要先做一些很小、很机械的改动,让它变得可测,比如把读系统时间的那一行提成一个参数(第 46 章以参数取代查询),把连数据库的那一段提成一个可以替换的函数。这些改动没有测试保护,所以要特别小心:只用 IDE 的自动重构,一次只改一处,改完立刻补上测试。
重构与代码评审、分支
Section titled “重构与代码评审、分支”重构最好是小步、频繁、和功能开发交织在一起的,这对团队的工作方式有两个要求。
提交要小,说明要清楚。 把重构和功能改动分成不同的提交:先一个提交“提炼函数:amount_for”,再一个提交“加 HTML 账单”。评审的人看重构提交时只需要确认行为没变,看功能提交时只需要关注新逻辑。混在一起的提交,评审的人很难分辨哪些行是在挪位置、哪些行是在改逻辑,要么看得很累,要么干脆放过。
不要开长寿命的重构分支。 在一个分支上重构两周,同时主干上别人在继续开发,合并时会遇到大量冲突,而重构恰恰会动到很多文件。更好的做法是持续集成:每完成一小步重构就合并回主干,最长不超过一两天。一时没完成的大重构,用迁移式做法(第 6 章)让新旧两种结构并存,逐步把调用方迁过去,主干始终是能用的。
怎么向团队和老板说
Section titled “怎么向团队和老板说”“我们需要时间重构”这句话很难说服一个关心交付日期的经理。经理听到的是“工程师想花时间做一件对客户没有直接价值的事”。
更好的说法是不单独谈重构。重构是加功能的一部分,就像厨师在做菜之前把案板收拾干净,没有人会把“收拾案板”单独列进账单。估算一个需求的工作量时,把预备性重构的时间包含进去;汇报时说“这个需求要两天,因为现在的结构不支持它,要先调整”,而不是“一天做需求、一天做重构”。
如果真的需要一段单独的时间处理一块积重难返的代码,用后果去说服:最近三个需求在这块代码上各多花了几天、出过几次事故、新人要多久才能上手改它。用具体的数字,而不是“代码质量差”这种评语,这和本书一直强调的写法是一样的。
什么时候该推倒重写
Section titled “什么时候该推倒重写”重构不是万能的。有些情况下,重写比重构更划算:
- 代码已经无法运行,或者运行结果根本没人信任,连特征测试都写不出一份可靠的。
- 技术栈本身要换:从一个已经停止维护的框架迁到另一个,从一种语言迁到另一种。
- 代码规模不大,重写只需要几天,而重构它要先补的测试比重写还多。
即使决定重写,也很少需要“一次性全部推倒”。更稳妥的做法是绞杀者模式(strangler fig):新系统先接管一小块功能,旧系统继续运行其他部分,新系统一块一块地蚕食旧系统,直到旧系统可以被安全地关掉。这其实是在更大的尺度上做迁移式重构。
真正的一次性重写风险极高:重写期间旧系统还在不断加需求,新系统要追赶一个移动的目标;旧系统里大量没有文档的边界规则,只有在新系统上线出事后才会被重新发现。如果你的直觉是“推倒重写更干净”,先问自己:能不能用重构加绞杀的方式,一块一块地达到同样的目标。
- 重构大多穿插在日常开发里:为了加功能先整理(预备性)、读懂了就写进代码(帮助理解)、看见了顺手清(捡垃圾);单独排期是例外。
- 先写清楚,再测量,再优化;清楚的代码更容易优化。
- 遗留代码先补特征测试,记录现有行为,从要改的地方周围开始。
- 重构和功能分开提交;不开长寿命的重构分支,用迁移式做法让新旧结构并存。
- 向别人解释时不单独谈重构,把它算进需求的工作量,用具体的后果说话。
- 真要重写,优先一块一块地替换,而不是一次推倒。