第 1 章 什么是重构
重构是在不改变程序行为的前提下,改善它的内部结构。这一章先不讲道理,先带你做一次,再回头说它是什么、为什么值得、做之前要准备什么,最后给出全书 61 个手法的总表。
先动手:一次 30 行的重构
Section titled “先动手:一次 30 行的重构”小李在做一个电商网站的结算页。运费规则是运营定的:国内订单满 99 元包邮,否则首重 1 公斤 8 元、每多 1 公斤加 3 元;海外订单满 300 元包邮,否则首重 40 元、每多 1 公斤加 15 元。小李照着规则写了下面这个函数。
"""运费计算:重构前。"""
def shipping_fee(order: dict) -> float: if order["country"] == "CN": if order["total"] >= 99: fee = 0.0 else: if order["weight_kg"] <= 1: fee = 8.0 else: fee = 8.0 + (order["weight_kg"] - 1) * 3.0 else: if order["total"] >= 300: fee = 0.0 else: if order["weight_kg"] <= 1: fee = 40.0 else: fee = 40.0 + (order["weight_kg"] - 1) * 15.0 return fee
if __name__ == "__main__": print(shipping_fee({"country": "CN", "total": 120, "weight_kg": 2})) print(shipping_fee({"country": "CN", "total": 50, "weight_kg": 2.5})) print(shipping_fee({"country": "US", "total": 50, "weight_kg": 3}))
# 输出:# 0.0# 12.5# 70.0// 运费计算:重构前。export interface Order { country: string; total: number; weightKg: number;}
export function shippingFee(order: Order): number { let fee: number; if (order.country === "CN") { if (order.total >= 99) { fee = 0; } else { if (order.weightKg <= 1) { fee = 8; } else { fee = 8 + (order.weightKg - 1) * 3; } } } else { if (order.total >= 300) { fee = 0; } else { if (order.weightKg <= 1) { fee = 40; } else { fee = 40 + (order.weightKg - 1) * 15; } } } return fee;}
console.log(shippingFee({ country: "CN", total: 120, weightKg: 2 }));console.log(shippingFee({ country: "CN", total: 50, weightKg: 2.5 }));console.log(shippingFee({ country: "US", total: 50, weightKg: 3 }));
// 输出:// 0// 12.5// 70这段代码是对的,测试也能过。测试长这样,覆盖了四种情况:国内包邮、国内轻件、国内重件、海外重件。
import unittest
from target import shipping_fee
class ShippingFeeTest(unittest.TestCase): def test_domestic_free_over_threshold(self): self.assertEqual(shipping_fee({"country": "CN", "total": 99, "weight_kg": 5}), 0.0)
def test_domestic_light_parcel(self): self.assertEqual(shipping_fee({"country": "CN", "total": 10, "weight_kg": 1}), 8.0)
def test_domestic_heavy_parcel(self): self.assertEqual(shipping_fee({"country": "CN", "total": 10, "weight_kg": 2.5}), 12.5)
def test_overseas_heavy_parcel(self): self.assertEqual(shipping_fee({"country": "US", "total": 50, "weight_kg": 3}), 70.0)
if __name__ == "__main__": unittest.main()import test from "node:test";import assert from "node:assert/strict";import { shippingFee } from "./target";
test("国内满额免运费", () => { assert.equal(shippingFee({ country: "CN", total: 99, weightKg: 5 }), 0);});
test("国内轻件", () => { assert.equal(shippingFee({ country: "CN", total: 10, weightKg: 1 }), 8);});
test("国内重件", () => { assert.equal(shippingFee({ country: "CN", total: 10, weightKg: 2.5 }), 12.5);});
test("海外重件", () => { assert.equal(shippingFee({ country: "US", total: 50, weightKg: 3 }), 70);});两周后运营来了新需求:首重改成 2 公斤。小李打开这个函数,碰到三处难受的地方。
第一,四层嵌套。要确认“海外、不包邮、超重”走的是哪个分支,得用手指顺着缩进数。第二,国内和海外两段几乎一模一样,只是数字不同。改首重要改两处,而且一处写 <= 1,另一处也写 <= 1,改漏一个就是线上事故。第三,“1 公斤以内按首重算”这条规则根本没有名字,它藏在两个 <= 1 和两个 - 1 里。
小李决定先把代码理顺,再改需求。第一步,把“是否包邮”和“按重量算费”两件事各提成一个函数,并把国内、海外的数字收进三张表:
FREE_SHIPPING_THRESHOLD = {"CN": 99, "OTHER": 300}BASE_FEE = {"CN": 8.0, "OTHER": 40.0}EXTRA_PER_KG = {"CN": 3.0, "OTHER": 15.0}
def is_free_shipping(order: dict) -> bool: return order["total"] >= FREE_SHIPPING_THRESHOLD[region(order)]改完跑测试,四个用例全过。第二步,把嵌套的 if 改成先处理特殊情况就返回的写法,也就是第 37 章要讲的卫语句:
def shipping_fee(order: dict) -> float: if is_free_shipping(order): return 0.0 return weight_fee(order)再跑测试,还是全过。完整的结果如下。
"""运费计算:重构后。"""
FREE_SHIPPING_THRESHOLD = {"CN": 99, "OTHER": 300}BASE_FEE = {"CN": 8.0, "OTHER": 40.0}EXTRA_PER_KG = {"CN": 3.0, "OTHER": 15.0}
def region(order: dict) -> str: return "CN" if order["country"] == "CN" else "OTHER"
def is_free_shipping(order: dict) -> bool: return order["total"] >= FREE_SHIPPING_THRESHOLD[region(order)]
def weight_fee(order: dict) -> float: key = region(order) extra_kg = max(0.0, order["weight_kg"] - 1) return BASE_FEE[key] + extra_kg * EXTRA_PER_KG[key]
def shipping_fee(order: dict) -> float: if is_free_shipping(order): return 0.0 return weight_fee(order)
if __name__ == "__main__": print(shipping_fee({"country": "CN", "total": 120, "weight_kg": 2})) print(shipping_fee({"country": "CN", "total": 50, "weight_kg": 2.5})) print(shipping_fee({"country": "US", "total": 50, "weight_kg": 3}))
# 输出:# 0.0# 12.5# 70.0// 运费计算:重构后。export interface Order { country: string; total: number; weightKg: number;}
type Region = "CN" | "OTHER";const FREE_SHIPPING_THRESHOLD: Record<Region, number> = { CN: 99, OTHER: 300 };const BASE_FEE: Record<Region, number> = { CN: 8, OTHER: 40 };const EXTRA_PER_KG: Record<Region, number> = { CN: 3, OTHER: 15 };
function region(order: Order): Region { return order.country === "CN" ? "CN" : "OTHER";}
function isFreeShipping(order: Order): boolean { return order.total >= FREE_SHIPPING_THRESHOLD[region(order)];}
function weightFee(order: Order): number { const key = region(order); const extraKg = Math.max(0, order.weightKg - 1); return BASE_FEE[key] + extraKg * EXTRA_PER_KG[key];}
export function shippingFee(order: Order): number { if (isFreeShipping(order)) return 0; return weightFee(order);}
console.log(shippingFee({ country: "CN", total: 120, weightKg: 2 }));console.log(shippingFee({ country: "CN", total: 50, weightKg: 2.5 }));console.log(shippingFee({ country: "US", total: 50, weightKg: 3 }));
// 输出:// 0// 12.5// 70对比一下。嵌套没有了,shipping_fee 三行就能读完。国内和海外的差别只剩三张表里的数字,改首重只用改 weight_fee 里的一处 - 1,加一个新地区只用在三张表里各加一行。测试文件一个字没动,四个用例在改之前和改之后都通过。这就是重构:程序对外的行为丝毫没变,但现在小李敢接那个新需求了。
注意小李做了什么、没做什么。他做了两步,每步之后都跑测试;他没有一上来就改首重。先把地整平,再盖房子,这个顺序贯穿全书。
重构是什么、不是什么
Section titled “重构是什么、不是什么”Fowler 给重构的定义是:在不改变软件可观察行为的前提下,调整其内部结构,使它更容易理解、更容易修改。两个关键词:不改变行为,更容易修改。上面的例子里,三组输入的输出完全一样,这是不改变行为;首重从改两处变成改一处,这是更容易修改。
这个定义排除了几件常被混为一谈的事。
重构不是重写。 重写是把旧代码扔掉从头来,中间有一段程序不能用的时间。重构是一系列小步骤,每一步做完程序都能跑、测试都能过,随时可以停下来去做别的。小李的两步各花了几分钟,中间任何时候运营来问“现在能上线吗”,答案都是能。
重构不是性能优化。 两者都不改变行为,但目的相反:优化为了让程序更快,常常让代码更难懂;重构为了让代码更好懂,有时会让程序慢一点点。第 58 章会专门讲两者怎么配合。
重构不是顺手改需求。 Fowler 用“两顶帽子”来说这件事。戴上加功能的帽子,你只加功能,不动已有结构;戴上重构的帽子,你只调整结构,不加也不改任何功能。两顶帽子可以频繁换,但不能同时戴。小李先戴重构的帽子理顺代码,再换上加功能的帽子改首重。如果他一边改结构一边改首重,测试失败时就分不清是结构改坏了还是新规则写错了。
为什么值得花这个时间
Section titled “为什么值得花这个时间”重构不直接产出功能,花在它上面的时间要有回报。回报有四种。
让设计跟上现实。 代码刚写出来时结构往往是合理的,需求一变就不合理了。运费函数在只有两个地区时写成两段 if 没什么问题,等要加第三个地区,它就该变成表了。不重构,每次改需求都是在一个越来越不合身的结构上打补丁。
让代码好懂。 你写的代码别人要读,三个月后的你也要读。重构后的 shipping_fee 三行,一眼看出“包邮就免费,否则按重量算”。重构前的版本要读半分钟,而且读完还得在心里总结出同样这句话。把这句话写进代码里,就省掉了每个读者的半分钟。
帮你找到 bug。 理顺代码的过程就是逐行检查它在做什么的过程。嵌套展开、重复合并之后,不一致的地方会自己跳出来。比如重构前如果国内写的是 <= 1、海外写的是 < 1,两段并排时很难注意到;合并成一个 weight_fee 之后,这个差异要么被统一,要么会被明确地问一句“这是故意的吗”。
让后面的开发更快。 这是前三条的总和,也是最反直觉的一条。花半小时重构看起来拖慢了今天的进度,但接下来每一个改动都因为代码更清楚而更快,而且更不容易出错。代码库越大、活得越久,这笔账越划算。
重构有风险:改结构的过程中可能改坏行为。三件事把风险压到可以接受的程度,缺一个就不要开始。
有测试。 测试是你判断“行为没变”的唯一客观依据。没有测试,每一步之后你只能靠眼睛检查,而眼睛恰恰最容易放过那些细微的差别。要重构的代码没有测试,第一步就是给它补测试,覆盖你要动的那部分行为。第 58 章讲怎么给遗留代码补测试。
小步前进。 每一步只做一件事,做完就跑测试。步子小的好处是出错时你立刻知道是哪一步错的,回退代价只有几分钟。步子大了,测试失败时你要在二十处改动里找原因,往往比重做还慢。本书每个手法的“怎么一步步做”一节就是在教你怎么把步子切小。
随时能回退。 用版本控制,每完成一个手法就提交一次。这样任何一步走错,git checkout 就能回到上一个安全点,不用手工撤销。
61 个手法一览
Section titled “61 个手法一览”下面是本书覆盖的全部手法,按七个分类列出。流行度三颗星表示几乎每天都用,两颗星表示常用,一颗星表示偶尔用到。章号对应本书的章节;一正一反的手法放在同一章里。
最常用的基本手法
Section titled “最常用的基本手法”| 中文名 | 英文名 | 一句话 | 典型场合 | 流行度 | 章 |
|---|---|---|---|---|---|
| 提炼函数 | Extract Function | 把一段代码提成有名字的函数 | 函数太长,或一段代码要先看注释才懂 | ★★★ | 4 |
| 内联函数 | Inline Function | 把函数体放回调用处 | 函数名没有比函数体说得更清楚 | ★★ | 4 |
| 提炼变量 | Extract Variable | 给表达式的一部分起名字 | 一行算式读不懂 | ★★★ | 5 |
| 内联变量 | Inline Variable | 用表达式替掉变量 | 变量名没有比表达式说得更清楚 | ★★ | 5 |
| 改变函数声明 | Change Function Declaration | 改函数名或参数 | 名字不准,参数不合适 | ★★★ | 6 |
| 变量改名 | Rename Variable | 给变量一个说明用途的名字 | 名字是 tmp、data、a |
★★★ | 7 |
| 封装变量 | Encapsulate Variable | 用函数包住对变量的读写 | 全局或共享的数据被到处直接改 | ★★ | 8 |
| 引入参数对象 | Introduce Parameter Object | 把总是一起出现的参数合成一个对象 | 几个函数都传同一组参数 | ★★★ | 9 |
| 函数组合成类 | Combine Functions into Class | 把操作同一数据的函数收进一个类 | 几个函数都接收同一个数据 | ★★ | 10 |
| 函数组合成变换 | Combine Functions into Transform | 用一个变换函数把派生数据一次算好 | 同样的派生值在多处重复计算 | ★★ | 10 |
| 拆分阶段 | Split Phase | 把一段做两件事的代码拆成两个阶段 | 一个函数既解析输入又做计算 | ★★★ | 11 |
| 中文名 | 英文名 | 一句话 | 典型场合 | 流行度 | 章 |
|---|---|---|---|---|---|
| 封装记录 | Encapsulate Record | 把字典或对象字面量换成类 | 一个字典被很多地方读写 | ★★ | 13 |
| 封装集合 | Encapsulate Collection | 不直接暴露集合,提供增删方法 | 外部代码直接往列表里加东西 | ★★ | 14 |
| 以对象取代基本类型 | Replace Primitive with Object | 把字符串、数字换成有行为的类 | 电话号、金额、优先级这类值带着规则 | ★★ | 15 |
| 以查询取代临时变量 | Replace Temp with Query | 把临时变量换成函数 | 临时变量只是某个计算的结果 | ★★ | 16 |
| 提炼类 | Extract Class | 把一个类里的一组数据和方法拆出去 | 类太大,有一组字段总是一起变 | ★★ | 17 |
| 内联类 | Inline Class | 把一个类并回使用它的类 | 类几乎没有职责 | ★ | 17 |
| 隐藏委托关系 | Hide Delegate | 在对象上加方法,不让调用方穿透到内部对象 | 调用方写了 a.b.c() |
★★ | 18 |
| 移除中间人 | Remove Middle Man | 删掉只转发的方法,让调用方直接访问 | 一个类里全是转发方法 | ★ | 18 |
| 替换算法 | Substitute Algorithm | 整个换成更清楚的算法 | 发现了更简单的写法 | ★ | 19 |
| 中文名 | 英文名 | 一句话 | 典型场合 | 流行度 | 章 |
|---|---|---|---|---|---|
| 搬移函数 | Move Function | 把函数搬到更合适的类或模块 | 函数用别的类的数据比用自己的多 | ★★★ | 21 |
| 搬移字段 | Move Field | 把字段搬到更合适的类 | 字段总和另一个类的数据一起用 | ★★ | 22 |
| 搬移语句到函数 | Move Statements into Function | 把每次调用前后都做的事搬进函数 | 每个调用点都有同样的几行 | ★★ | 23 |
| 搬移语句到调用者 | Move Statements to Callers | 把函数里只有部分调用方需要的事搬出去 | 函数开始为不同调用方做不同的事 | ★ | 23 |
| 以函数调用取代内联代码 | Replace Inline Code with Function Call | 用已有函数替掉手写的同样逻辑 | 手写了一遍标准库已有的功能 | ★ | 24 |
| 移动语句 | Slide Statements | 把相关的语句挪到一起 | 变量声明离使用处太远 | ★ | 25 |
| 拆分循环 | Split Loop | 一个循环只做一件事 | 一个循环里算两个结果 | ★★ | 26 |
| 以管道取代循环 | Replace Loop with Pipeline | 用 map、filter 这类操作替掉循环 | 循环在做筛选和转换 | ★★★ | 27 |
| 移除死代码 | Remove Dead Code | 删掉不再用的代码 | 有函数没人调用 | ★ | 28 |
重新组织数据
Section titled “重新组织数据”| 中文名 | 英文名 | 一句话 | 典型场合 | 流行度 | 章 |
|---|---|---|---|---|---|
| 拆分变量 | Split Variable | 一个变量只做一件事 | 变量被赋值两次,含义不同 | ★★ | 30 |
| 字段改名 | Rename Field | 给字段一个准确的名字 | 字段名和它存的东西对不上 | ★ | 31 |
| 以查询取代派生变量 | Replace Derived Variable with Query | 能算出来的值不要存 | 字段存着别的字段算出来的结果 | ★★ | 32 |
| 将引用对象改为值对象 | Change Reference to Value | 让对象不可变,比较按内容 | 对象被共享后改动引起意外 | ★★ | 33 |
| 将值对象改为引用对象 | Change Value to Reference | 同一个实体只有一个对象 | 同一个客户的数据在多个副本里不一致 | ★ | 33 |
简化条件逻辑
Section titled “简化条件逻辑”| 中文名 | 英文名 | 一句话 | 典型场合 | 流行度 | 章 |
|---|---|---|---|---|---|
| 分解条件表达式 | Decompose Conditional | 把条件和分支各提成函数 | if 的条件要读三遍 | ★★★ | 35 |
| 合并条件表达式 | Consolidate Conditional Expression | 把结果相同的几个条件合成一个 | 几个 if 做同一件事 | ★★ | 36 |
| 以卫语句取代嵌套条件表达式 | Replace Nested Conditional with Guard Clauses | 特殊情况先返回 | if 套 if 套 if | ★★★ | 37 |
| 以多态取代条件表达式 | Replace Conditional with Polymorphism | 按类型分支改成子类各自实现 | 同一个 switch 出现在多个函数里 | ★★★ | 38 |
| 引入特例 | Introduce Special Case | 用一个特例对象替掉到处检查 null | 到处写 if x is None |
★★ | 39 |
| 引入断言 | Introduce Assertion | 把假设写成断言 | 代码依赖某个条件但没写出来 | ★ | 40 |
重构 API
Section titled “重构 API”| 中文名 | 英文名 | 一句话 | 典型场合 | 流行度 | 章 |
|---|---|---|---|---|---|
| 将查询函数和修改函数分离 | Separate Query from Modifier | 返回值的函数不改状态 | 一个函数既返回结果又改了数据 | ★★★ | 42 |
| 函数参数化 | Parameterize Function | 几个只差一个值的函数合成一个 | raise_5_percent、raise_10_percent |
★★ | 43 |
| 移除标记参数 | Remove Flag Argument | 用两个函数替掉布尔参数 | 调用处写着 f(x, true) |
★★★ | 44 |
| 保持对象完整 | Preserve Whole Object | 传整个对象而不是它的几个字段 | 调用处先从对象取出几个值再传 | ★★ | 45 |
| 以查询取代参数 | Replace Parameter with Query | 函数自己能算的值不要传 | 参数能由别的参数推出来 | ★★ | 46 |
| 以参数取代查询 | Replace Query with Parameter | 让函数不依赖外部状态 | 函数内部读了全局变量 | ★★ | 46 |
| 移除设值函数 | Remove Setting Method | 不该改的字段不提供修改方法 | 字段只在构造时设一次 | ★ | 47 |
| 以工厂函数取代构造函数 | Replace Constructor with Factory Function | 用有名字的函数创建对象 | 构造函数不能表达意图或返回子类 | ★★ | 48 |
| 以命令取代函数 | Replace Function with Command | 把函数变成一个对象 | 函数需要撤销、排队或很多参数 | ★★ | 49 |
| 以函数取代命令 | Replace Command with Function | 把只有一个方法的对象变回函数 | 命令对象没有用到它的灵活性 | ★ | 49 |
处理继承关系
Section titled “处理继承关系”| 中文名 | 英文名 | 一句话 | 典型场合 | 流行度 | 章 |
|---|---|---|---|---|---|
| 函数上移 | Pull Up Method | 把子类里相同的方法搬到父类 | 几个子类有一样的方法 | ★★ | 51 |
| 字段上移 | Pull Up Field | 把子类里相同的字段搬到父类 | 几个子类有一样的字段 | ★ | 51 |
| 构造函数本体上移 | Pull Up Constructor Body | 把子类构造函数里相同的部分搬到父类 | 子类构造函数开头都一样 | ★ | 51 |
| 函数下移 | Push Down Method | 把只有一个子类用的方法搬下去 | 父类的方法只和一个子类有关 | ★ | 51 |
| 字段下移 | Push Down Field | 把只有一个子类用的字段搬下去 | 父类的字段只有一个子类用 | ★ | 51 |
| 以子类取代类型码 | Replace Type Code with Subclasses | 用子类替掉类型字段 | 代码到处按 kind 字段分支 |
★★ | 52 |
| 移除子类 | Remove Subclass | 把没什么用的子类换回类型字段 | 子类只剩一个返回常量的方法 | ★ | 53 |
| 提炼超类 | Extract Superclass | 把两个类的共同部分提成父类 | 两个类做类似的事 | ★★ | 54 |
| 折叠继承体系 | Collapse Hierarchy | 把父类和子类合并 | 父子类差别太小 | ★ | 55 |
| 以委托取代超类 | Replace Superclass with Delegate | 继承改成持有 | 子类只用了父类的一小部分 | ★★ | 56 |
| 以委托取代子类 | Replace Subclass with Delegate | 子类改成委托对象 | 需要按多个维度变化,或运行时换类型 | ★★ | 56 |
怎么用这本书
Section titled “怎么用这本书”七个分类各自回答一类问题。基本手法管代码该切成多大一块、叫什么名字,它们是其他一切手法的工具。封装管数据该藏在哪里、谁能改它。搬移特性管函数和字段该住在哪个类、哪个模块。重新组织数据管变量和字段本身的形态。简化条件逻辑管 if 和 switch。重构 API 管函数对外的样子:参数、返回值、名字。处理继承关系管父类和子类之间该放什么、该不该有继承。
手法章节分三档。完整档值得从头读到尾,它们是最常用、最值得练熟的;精简档读到“重构之后”就够了,需要时再看后面;速览档一屏读完。遇到具体问题时,先看第 2 章找到坏味道的名字,顺着它给的章号去读,或者直接查上面的总表。
每章的代码都能跑。前言说了怎么跑示例和测试,建议至少把完整档的案例在自己机器上跑一遍,把 before 改成 after,看着测试从头到尾保持通过。重构是手艺,读十遍不如做一遍。