跳转到内容

第 1 章 什么是重构

重构是在不改变程序行为的前提下,改善它的内部结构。这一章先不讲道理,先带你做一次,再回头说它是什么、为什么值得、做之前要准备什么,最后给出全书 61 个手法的总表。

小李在做一个电商网站的结算页。运费规则是运营定的:国内订单满 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,加一个新地区只用在三张表里各加一行。测试文件一个字没动,四个用例在改之前和改之后都通过。这就是重构:程序对外的行为丝毫没变,但现在小李敢接那个新需求了。

重构前后的 shipping_fee:左边四层嵌套,右边三个小函数各管一件事,国内外的差别收进三张表

注意小李做了什么、没做什么。他做了两步,每步之后都跑测试;他没有一上来就改首重。先把地整平,再盖房子,这个顺序贯穿全书。

Fowler 给重构的定义是:在不改变软件可观察行为的前提下,调整其内部结构,使它更容易理解、更容易修改。两个关键词:不改变行为,更容易修改。上面的例子里,三组输入的输出完全一样,这是不改变行为;首重从改两处变成改一处,这是更容易修改。

这个定义排除了几件常被混为一谈的事。

重构不是重写。 重写是把旧代码扔掉从头来,中间有一段程序不能用的时间。重构是一系列小步骤,每一步做完程序都能跑、测试都能过,随时可以停下来去做别的。小李的两步各花了几分钟,中间任何时候运营来问“现在能上线吗”,答案都是能。

重构不是性能优化。 两者都不改变行为,但目的相反:优化为了让程序更快,常常让代码更难懂;重构为了让代码更好懂,有时会让程序慢一点点。第 58 章会专门讲两者怎么配合。

重构不是顺手改需求。 Fowler 用“两顶帽子”来说这件事。戴上加功能的帽子,你只加功能,不动已有结构;戴上重构的帽子,你只调整结构,不加也不改任何功能。两顶帽子可以频繁换,但不能同时戴。小李先戴重构的帽子理顺代码,再换上加功能的帽子改首重。如果他一边改结构一边改首重,测试失败时就分不清是结构改坏了还是新规则写错了。

重构不直接产出功能,花在它上面的时间要有回报。回报有四种。

让设计跟上现实。 代码刚写出来时结构往往是合理的,需求一变就不合理了。运费函数在只有两个地区时写成两段 if 没什么问题,等要加第三个地区,它就该变成表了。不重构,每次改需求都是在一个越来越不合身的结构上打补丁。

让代码好懂。 你写的代码别人要读,三个月后的你也要读。重构后的 shipping_fee 三行,一眼看出“包邮就免费,否则按重量算”。重构前的版本要读半分钟,而且读完还得在心里总结出同样这句话。把这句话写进代码里,就省掉了每个读者的半分钟。

帮你找到 bug。 理顺代码的过程就是逐行检查它在做什么的过程。嵌套展开、重复合并之后,不一致的地方会自己跳出来。比如重构前如果国内写的是 <= 1、海外写的是 < 1,两段并排时很难注意到;合并成一个 weight_fee 之后,这个差异要么被统一,要么会被明确地问一句“这是故意的吗”。

让后面的开发更快。 这是前三条的总和,也是最反直觉的一条。花半小时重构看起来拖慢了今天的进度,但接下来每一个改动都因为代码更清楚而更快,而且更不容易出错。代码库越大、活得越久,这笔账越划算。

重构有风险:改结构的过程中可能改坏行为。三件事把风险压到可以接受的程度,缺一个就不要开始。

有测试。 测试是你判断“行为没变”的唯一客观依据。没有测试,每一步之后你只能靠眼睛检查,而眼睛恰恰最容易放过那些细微的差别。要重构的代码没有测试,第一步就是给它补测试,覆盖你要动的那部分行为。第 58 章讲怎么给遗留代码补测试。

小步前进。 每一步只做一件事,做完就跑测试。步子小的好处是出错时你立刻知道是哪一步错的,回退代价只有几分钟。步子大了,测试失败时你要在二十处改动里找原因,往往比重做还慢。本书每个手法的“怎么一步步做”一节就是在教你怎么把步子切小。

随时能回退。 用版本控制,每完成一个手法就提交一次。这样任何一步走错,git checkout 就能回到上一个安全点,不用手工撤销。

下面是本书覆盖的全部手法,按七个分类列出。流行度三颗星表示几乎每天都用,两颗星表示常用,一颗星表示偶尔用到。章号对应本书的章节;一正一反的手法放在同一章里。

中文名 英文名 一句话 典型场合 流行度 章
提炼函数 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
中文名 英文名 一句话 典型场合 流行度 章
拆分变量 Split Variable 一个变量只做一件事 变量被赋值两次,含义不同 ★★ 30
字段改名 Rename Field 给字段一个准确的名字 字段名和它存的东西对不上 ★ 31
以查询取代派生变量 Replace Derived Variable with Query 能算出来的值不要存 字段存着别的字段算出来的结果 ★★ 32
将引用对象改为值对象 Change Reference to Value 让对象不可变,比较按内容 对象被共享后改动引起意外 ★★ 33
将值对象改为引用对象 Change Value to Reference 同一个实体只有一个对象 同一个客户的数据在多个副本里不一致 ★ 33
中文名 英文名 一句话 典型场合 流行度 章
分解条件表达式 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
中文名 英文名 一句话 典型场合 流行度 章
将查询函数和修改函数分离 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
中文名 英文名 一句话 典型场合 流行度 章
函数上移 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

七个分类各自回答一类问题。基本手法管代码该切成多大一块、叫什么名字,它们是其他一切手法的工具。封装管数据该藏在哪里、谁能改它。搬移特性管函数和字段该住在哪个类、哪个模块。重新组织数据管变量和字段本身的形态。简化条件逻辑管 if 和 switch。重构 API 管函数对外的样子:参数、返回值、名字。处理继承关系管父类和子类之间该放什么、该不该有继承。

手法章节分三档。完整档值得从头读到尾,它们是最常用、最值得练熟的;精简档读到“重构之后”就够了,需要时再看后面;速览档一屏读完。遇到具体问题时,先看第 2 章找到坏味道的名字,顺着它给的章号去读,或者直接查上面的总表。

每章的代码都能跑。前言说了怎么跑示例和测试,建议至少把完整档的案例在自己机器上跑一遍,把 before 改成 after,看着测试从头到尾保持通过。重构是手艺,读十遍不如做一遍。