一个 200 行的类被"重构"成 12 个方法,每个 8–15 行,看代码清爽。半年后改一个规则要"跨六个方法来回跳"——因为那 12 个方法是按"行数"切的,而不是按"一件事"切的。
"提取方法(Extract Method)"是最常用的重构,而它有两个完全不同的动机:"表达一件事"(让名字说明意图)与**"缩短方法"(让每段变短)。后者会把结构切碎,这个"半年后"的延迟是它难纠正的原因:"分成小方法"在评审里几乎总是加分**("可读"、"单一职责"听起来都对),于是"切碎"很难被拦。
而它的代价要半年后才显现。
1. 两种提取动机的差别
| 动机 | 判据 |
|---|---|
| 表达意图 | 能给这一段起一个"业务上说得通"的名字 |
| 缩短方法 | 只能给它起"代码形态"的名字 |
第一项产出"calculateFreight()",第二项产出"loopOverItems()"——名字里出现"loop/check/step/get"这类形态词,通常说明"它不是一个概念"。这个"命名测试"很便宜:如果名字必须用"循环、遍历、处理、检查"这类词才能描述,那它描述的是"代码怎么跑",而不是"业务要什么"——而后者才是提取的目的。
"命名测试"可以写成三步,30 秒就能判断:
// 三步命名测试(apply 到"待提取的一段")
1 用"业务语言"给它起个名字(不许出现 loop/check/process)
2 若起不出来 → 试着用"这做了什么"描述一句(不含代码细节)
3 仍不行 → 它不是概念,别提取;可能是"该合并"或"该删"
// 例:算出"满减后的金额" → calcAfterDiscount()(可提取)
// 例:遍历订单明细累加 → 起不出业务名(不提取)
2. 三种"该提取"的情形
| 情形 | 理由 |
|---|---|
| 一段逻辑有独立概念(运费、折扣) | 名字能表达意图 |
| 一段逻辑被复用(两处以上) | 消除重复 |
| 一段逻辑需要单独测试 | 可测性 |
第三项的"操作方式"很具体:试着为"这一段"写三行测试——如果"要造一堆无关的前置"才能测,说明"它依赖了不该依赖的东西",此时该先"解耦"再"提取"。
第三项是"被低估"的:"能单独测"意味着"输入输出清晰",而"清晰"正是"概念独立"的证据。
3. 三种"不该提取"的情形
| 情形 | 后果 |
|---|---|
| 只是"代码很长" | 结构被切碎 |
| 提取后参数要传 5 个 | 数据被拆散 |
| 提取后只被调用一次且无名字可起 | 无收益,多一层跳转 |
第二项是最强的"反信号":**"要传 5 个参数才能算出结果"说明"这些数据本是一体"**第二项的"修法"是"先聚数据,再提取":把 5 个参数收成 1 个对象(或值对象),于是"方法签名"自然变短——顺序反了就会"先把参数列长,再想办法缩短"。
——此时该做的是"把数据聚成一个对象",而不是"提取方法"。
// 反例:提取后参数爆炸(说明"数据本该在一起")
private BigDecimal calc(a, b, c, d, e) { ... }
// 正例:先把数据聚成对象,再提取(参数变 1 个)
private BigDecimal freight(Order order) { ... }
"段落"比"行数"更可靠:一段代码里的"空行与分组"往往已经标出了概念边界。
// 三段(每段一个概念)→ 三次提取,而不是"按 10 行切两刀"
void settle(Order o) {
// 1 校验
if (o.items().isEmpty()) throw new IllegalStateException("空订单");
// 2 计算
var total = o.items().stream().map(Item::amount).reduce(ZERO, BigDecimal::add);
// 3 落账
ledger.record(o.id(), total);
}
// 提取后:validate(o) / total(o) / record(o,total) —— 三个"业务名字"
4. "方法长度"该怎么看
| 判据 | 说明 |
|---|---|
| 能否用一句话命名 | 不能 → 说明它做了多件事 |
| 是否有"段落"(逻辑块) | 有 → 每段是一个概念 |
| 是否需要注释解释 | 需要 → 该提取或改名 |
第三项很实用:"这段代码需要注释说明它做什么" 就是"该提取"的信号——因为"提取出来的名字"可以替代注释。
而"行数"只是"症状":30 行但只有一件事,优于 10 行但混了两件事。
"参数爆炸"有一个"计数判据",比"感觉"好:
// 什么时候该"聚数据"而不是"提取方法"
参数 ≤ 2:提取没问题
参数 3–4:先看"这些参数是否总一起出现"——是 → 聚成对象
参数 ≥ 5:一定先聚成对象/值对象,再提取
// 判据:参数"总是一起出现"就是"一个概念"的证据
5. "搬家式重构"的三种代价
| 代价 | 表现 |
|---|---|
| 跳转成本 | 改一处要看六个地方 |
| 参数传递 | 数据在方法间来回答 |
| 调试变难 | 断点跨多层 |
第二项是"最烦"的:为了传数据,加了一堆参数——于是"提取"把"局部变量"变成了"参数列表"。
第三项在"排障"时最明显:栈变深了,而"值在哪被改"要一层层看。第三项还有一个"断点"后果:"一次逻辑断裂"会让"断点命中七次"——而"七个栈帧里六个是重构产生的",于是"排障时间"被"结构"吃掉了。
第三项还有一个"日志"后果:每个小方法都想打一行日志,于是"一次请求打出 12 行"——而其中 10 行"没有信息量"。
6. 排错:五种"提取带来的问题"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 改一处要跳六层 | 按行数切 | 按概念合并 |
| 参数列表很长 | 数据该聚成对象 | 先聚数据 |
| 方法名都是"process/check" | 没有概念 | 改名或合并 |
| 单测很难写 | 依赖隐式上下文 | 显式参数 |
| 代码更长了 | 提取引入样板 | 评估收益 |
第三项的"量化"是"命名熵":如果 12 个方法里有 8 个以"process/check/handle"开头,说明这次重构是"按行数切"的——而它应当被"合并回"2–3 个概念。
第三项是最直接的"信号":一堆 process/check/handle 命名,说明这些方法"只是代码块",不是"概念"。
"合并回去"也是一种重构,而它常被忘掉:
// 三种"反向"重构(当"提取"过度时)
1 inline:把只调用一次、名字又难起的方法合回调用处
2 merge:把"按行数切"的三段合并成"一个概念"的方法
3 rename:把 processXxx 改成"业务名",若改不出来 → 说明该合并
// 判据:改不出名字的方法,通常是"代码块"而不是"概念"
7. 边界:什么情况不必提取
| 场景 | 为什么 | 做法 |
|---|---|---|
| 方法本来只有 10 行且一件事 | 无收益 | 不提取 |
| 一次性脚本 | 不长期 | 不提取 |
| 提取后只在一处调用且名字难起 | 多一层无收益 | 保留内联 |
| 性能敏感的热点(内联被 JIT 保证) | 通常无影响 | 视情况 |
判断口径一句话:"我能不能给这一段起一个"业务上说得通"的名字"。最后一条与"团队"有关的边界:"提取"是"给未来的读者写路标",所以它应当按"读者会不会关心"来判断:"运费规则"读者关心(要看它);"循环累加"读者不关心(只看结果)。
能,提取(它会把意图写进代码);不能(只能起"代码形态"的名字),保留原样——因为"短"不是目标,"清楚"才是。
8. 自测
- 两种提取动机的判据差别是什么?为什么"名字里出现 loop/check"是信号?
- 三种"该提取"的情形里,哪一种被低估?它为什么能证明"概念独立"?
- "提取后要传 5 个参数"为什么是"反信号"?正确的做法是什么?
- "需要注释解释"为什么是"该提取"的信号?
- "搬家式重构"的三种代价里,哪一种在排障时最明显?
- 判断"该不该提取"的那个问题是什么?