软件分析与设计笔记 25:代码重构:坏味道与手法
重构不改变外部行为、只改善内部结构:四种常见坏味道与对应手法、为什么动手之前必须先有测试、什么时候该重构,以及重构和前面那些设计模型是什么关系。
本文是 《软件分析与设计笔记》 系列的第 25 篇,共 28 篇
这是我在学习软件分析与设计时整理的系列笔记。概念部分是我按自己的理解写的,示例与图示都是自己编、自己画的,重构手法的名称参考的是公开的重构目录类资料。理解有偏差的地方欢迎指出。
贯穿案例:片区家电维修派单系统。24 篇讲怎么判断设计好坏,这一篇讲发现不好之后怎么动手。
0 一段「能跑但不敢改」的代码
有这么一类代码:功能正常、测试通过、线上没出过问题 —— 但没人愿意改它。
本例里有个现成的候选:客服端的派单逻辑。
public void dispatch(String orderNo, String engineerId, String operatorId,
String remark, boolean urgent, boolean needPhoto,
String photoUrl, int priority) {
// 280 行
}
改之前要看半小时;改完之后不确定有没有影响别的地方;于是每次新需求都绕着它走,在外面再加一个 if。这种代码不是坏了,而是正在变坏。
重构(refactoring) 就是针对这种状态的:在不改变外部行为的前提下,改善内部结构。
1 什么是重构
1.1 两个关键词
这个定义里有两个关键词。
「不改变外部行为」 —— 这一点决定了重构和「改需求」「修 bug」是不同的事。重构完成后,系统的输入输出不应该有任何变化,测试应该全部照旧通过。
本例里这条特别关键:如果重构派单逻辑时顺手「优化」了派单规则(比如改成优先派给昨天没派过的人),那就不是重构,而是改需求 —— 它必须经过业务确认、要更新需求文档、要重新验收。
「改善内部结构」 —— 改善的标准就是笔记 24 讲的:内聚更高、耦合更低、更容易理解和修改。
1.2 为什么给手法起名字
重构手法的名称大多很直白,比如「提取方法」「上移方法」「下移方法」。它们不是新东西,只是给「把代码动一下但不改行为」这件事起了名字 —— 有了名字,团队之间才能说「这里做个 Pull Up」而不用解释半天。
命名本身就是一种协作工具。它让「我觉得这里该改」变成「这里该做 Extract Method」,后者可以被讨论、被反对、被执行。
2 四种坏味道与对应手法
坏味道(code smell) 指的是代码里那种「不用运行就能感觉不对劲」的特征。它不等于 bug:坏味道是提示,不是错误。
2.1 长参数列表(long parameter list)
一个方法要传七八个参数,调用方每次都要数顺序。
本例开头那个 dispatch() 就是现成的例子:八个参数,其中 needPhoto 和 photoUrl 是一对(有图才有 URL),urgent 和 priority 又重复表达同一件事。
手法:把这几个参数打包成一个对象(Introduce Parameter Object),或者把一部分逻辑挪回它数据所属的类(Extract Class)。
改完是这样:
public void dispatch(DispatchRequest request) { ... }
class DispatchRequest {
String orderNo;
String engineerId;
String operatorId;
String remark;
boolean urgent;
String photoUrl; // 有图才有值
}
参数对象还有个额外好处:它能承载校验。「有图必须有 URL」这条规则现在可以写进 DispatchRequest 的构造函数里,而不是散在每个调用方。
2.2 依恋情结(feature envy)
某个方法对别的类的数据比对自己类的数据更感兴趣,反复调用对方的取值方法。
本例里一个典型:如果 :RequestPartsController 里写了这样一段 ——
// 在控制器里
boolean canRequest = order.getStatus().equals("IN_PROGRESS")
&& order.getEngineerId().equals(engineer.getId())
&& stock.getOnHandQty() >= qty;
它连续取了 order 的三个方法、stock 的一个方法,而自己一个字段都没用 —— 这就是依恋情结。
手法:把方法搬到它真正依赖的那个类里(Move Method)。这条直接对应笔记 20 的原则:行为应该由持有状态的对象提供。
改法是让 WorkOrder 自己回答:
// 在 WorkOrder 里
boolean canRequestParts(String engineerId) {
return status == IN_PROGRESS && this.engineerId.equals(engineerId);
}
2.3 重复代码(duplicate code)
同一段逻辑出现在两三个类里,改动时只改了其中一处 —— 那没改的地方就永久不一致了。
本例里一个真实的风险点:「工单状态是否允许某操作」这件事,在客服端、工程师 App、仓管端各写了一遍。
手法:相同的部分提到父类(Pull Up),或抽成独立方法(Extract Method)。
但要小心 —— 24 篇 §4.2 已经提醒过:「校验工单状态」在派单里要求 CREATED、在申请配件里要求 IN_PROGRESS,它们看起来一样其实是两个规则。所以这里该抽的不是「一个通用校验方法」,而是「状态机的判定能力」:
// 在 WorkOrder 里(不是抽成一个通用工具类)
boolean canDispatch() { return status == CREATED; }
boolean canRequestParts() { return status == IN_PROGRESS; }
共同点抽出来的是「能力归属」,不是「代码形状」。这两者的差别,就是「好重构」和「制造耦合」的差别。
2.4 被拒绝的馈赠(refused bequest)
子类继承了一堆自己用不上的父类成员,只能空着或者抛异常。
这正是 27 篇考试里那道重构题考的坏味道。两个真题例子都是这个形态:
SubContractedProject extends Project——addMember()被重写成空、removeMember()直接return null;OutsourcedEngineer extends Engineer——accept()、requestParts()同样被重写成空(27 篇 §10.2 举的例子)。
手法:把用不上的成员下推到真正需要它的子类(Pull Down),或者干脆改用组合。
判断只需要看一件事:子类有没有把父类的方法重写成空、或直接 return null。有,就是被拒绝的馈赠。
2.5 记住习惯比记住清单更重要
这四个只是最常见的一批。真正有用的不是记住全部,而是养成看到这类特征就停下来想一秒的习惯。
3 为什么动手前必须先有测试
重构的风险只有一个,但很实在,通常叫回归风险(regression risk):你可能会在「改善结构」的同时不小心改变了行为,而且自己没发现。
如果有一组能快速运行的测试,这个过程就变得安全:改一步、跑一次测试、通过了继续,不通过就回退。这就是为什么笔记 14 讲的单元测试在这里格外重要 —— 它不是重构的额外负担,而是重构的前提条件。
本例里重构那个 dispatch() 之前,最该先有的测试是这五条:
| 测什么 | 为什么先测它 |
|---|---|
派单后工单状态从 CREATED 变 ASSIGNED | 这是核心行为,不能变 |
| 同一工单不能派给两个人 | 并发/重复的边界 |
| 工程师当天满 5 单时拒绝派单 | 业务规则,容易被「优化」掉 |
有 photoUrl 时 needPhoto 必须为 true | 参数之间的关系 |
| 紧急单的优先级高于普通单 | 排序规则 |
这五条测试写出来之后,重构才有意义 —— 因为每改一步都能验证「行为没变」。
没有测试时,重构的唯一保障是你的细心程度。而细心程度不能作为工程保障。
4 什么时候重构
4.1 三次法则
常见的判断依据是三次法则(Rule of Three):
- 第一次遇到这段逻辑,先照常写;
- 第二次遇到相似的东西,先记下来,但忍住不动;
- 第三次遇到时,再动手抽象。
这条规则的意义在于克制:过早抽象和重复代码一样有害 —— 那时你还没弄清它们真正的共同点。
本例里可以验证这一点:第一次做「派单」时需要校验状态,第二次做「申请配件」时又需要 —— 如果这时就抽一个通用的 validateStatus(),那第三次(「回填工单」)时你会发现它要求的状态是第三个值,于是只能给它加参数。等到第三次再抽,你才知道该抽的是「状态机」而不是「校验方法」。
4.2 另外两种时机
另外还有两种时机:
- 顺手重构。 每次改需求时,顺手把要碰的那一小块整理干净。成本低、效果好;
- 专门的重构迭代。 当积累的结构问题已经影响开发速度时,专门留出时间。这种情况通常说明前面顺手重构做得太少。
本例里两者都会出现:改派单规则时顺手把那段 if 抽成方法(顺手重构);而那个 280 行的 dispatch() 要专门排一个迭代 —— 因为它需要先补测试。
5 重构和前面那些设计模型的关系
回到这个系列的主线。重构不是「写代码时候的单独技艺」,它和前面对应得很清楚:
- 类图是目标。 重构就是让代码重新回到类图所描述的结构上。发现代码和类图脱节了,要么改代码,要么改图(说明当初的设计判断需要修正);
- 职责划分是标准。 依恋情结之所以是坏味道,正是因为当初的职责划错了位置 —— 22 篇那个「控制器里写数量校验」的例子就是同一个问题;
- 测试是护栏。 没有测试,重构就变成赌博。
还有一个相关的说法叫技术债(technical debt):为了赶进度而接受的结构妥协,会像债务一样产生「利息」—— 每次新需求都要多绕一点路。重构是还本金的方式。债务本身不一定坏,坏的是不还。
本例里那笔「技术债」可以量化:因为 dispatch() 太长,每次改派单规则平均要两天(一天读代码、一天改+测)。如果重构一次花三天,后面还有五次派单相关需求,那就省下七天。这就是「利息」的具体形状。
6 常见的三个顾虑
- 「没有时间重构。」 顺手重构的成本其实很低,代价是不做的话会持续累积。上面那个「两天」的账就是回答;
- 「重构会引入 bug。」 在测试保护下的小步重构,风险比「在外面再加一层判断」低得多;
- 「不如重写。」 重写的风险是很难完全复制现有系统的隐含行为 —— 那些行为往往没人记得,但用户依赖它们。本例里「紧急单优先」这条规则就没写在需求文档里,只存在于代码里;重写的人很可能漏掉。多数情况下,渐进重构更稳。
第三条的解法是:重写之前先给老系统补测试,让那些隐含行为变成显式断言。补不出来,就说明你还不知道它做了什么 —— 那更不该重写。
本节自测
- 重构的定义里,「不改变外部行为」这一条为什么重要?举出本例中一个「以为是重构其实是改需求」的例子。
- 依恋情结的对策为什么是把方法搬走,而不是把数据搬过来?
- 为什么重构之前必须有测试?重构那段
dispatch()之前,最该先补哪几条测试? - 三次法则为什么会建议「第二次先忍住不动」?本例中第二次就抽象会犯什么错?
- 技术债这个说法里,「利息」指的是什么?怎么用本例量化它?
- 「重复代码」这一节里,为什么不该抽成一个「通用校验方法」,而该抽成「状态机判定」?
- 四个坏味道里,哪个是考场上最常出现的那一个?判断依据是什么?
参考答案
- 因为不改变行为才是重构,改变行为就是改需求或修 bug,要走业务确认与验收。本例:重构
dispatch()时顺手把派单规则改成「优先派给昨天没派过的人」—— 那是改需求,不是重构(§1.1)。 - 因为行为应该由持有状态的对象提供。把数据搬过来会让控制器持有业务数据,职责更乱;搬方法则是让
WorkOrder自己回答canRequestParts()(§2.2)。 - 因为重构的唯一风险是在改善结构时不小心改变行为而不自知,测试是唯一的验证手段。本例最该先补五条:状态从 CREATED 变 ASSIGNED、同一工单不能派两人、满 5 单拒绝、有 photoUrl 时 needPhoto 必须为 true、紧急单优先(§3)。
- 因为那时你还没弄清它们真正的共同点。本例第二次就抽象会做出「通用 validateStatus()」,到第三次(回填工单要求第三个状态值)只能给它加参数 —— 于是它再也不是共用逻辑(§4.1)。
- 「利息」是每次新需求都要多绕的那点路。本例:因为
dispatch()太长,每次改派单规则要两天(一天读、一天改测);重构花三天,后面还有五次需求,就省下七天(§5)。 - 因为「校验工单状态」在派单里要求
CREATED、在申请配件里要求IN_PROGRESS—— 看起来一样其实是两个规则,抽成通用方法后一遇新需求就要加参数。该抽的是「状态机判定能力」,让WorkOrder提供canDispatch()与canRequestParts()(§2.3)。 - 被拒绝的馈赠(refused bequest)。判断只需要看一件事:子类有没有把父类的方法重写成空、或直接 return null。两道真题(
SubContractedProject与OutsourcedEngineer)都是这个形态(§2.4)。
术语表
| 术语 | 中文 | 一句话解释 |
|---|---|---|
| refactoring | 重构 | 不改变外部行为,改善内部结构 |
| code smell | 坏味道 | 代码里提示结构问题的特征,不等于 bug |
| long parameter list | 长参数列表 | 方法参数过多,调用方易出错 |
| feature envy | 依恋情结 | 方法对别的类的数据更感兴趣 |
| duplicate code | 重复代码 | 同一逻辑出现在多处,改动容易漏 |
| refused bequest | 被拒绝的馈赠 | 子类继承了用不上的父类成员 |
| extract class | 提取类 | 把一部分职责拆成新的类 |
| extract method | 提取方法 | 把一段逻辑抽成独立方法 |
| introduce parameter object | 引入参数对象 | 把多个参数打包成一个对象 |
| move method | 移动方法 | 把方法搬到它依赖的数据所在的类 |
| pull up | 上移 | 把子类中相同的成员提到父类 |
| pull down | 下移 | 把父类中用不上的成员推到子类 |
| rule of three | 三次法则 | 同样的重复出现第三次再抽象 |
| regression risk | 回归风险 | 改动导致原有功能被弄坏的可能 |
| technical debt | 技术债 | 结构妥协带来的长期额外成本 |