软件分析与设计笔记 25:代码重构:坏味道与手法

重构不改变外部行为、只改善内部结构:四种常见坏味道与对应手法、为什么动手之前必须先有测试、什么时候该重构,以及重构和前面那些设计模型是什么关系。

学习#重构#代码质量#软件设计#学习笔记

本文是 《软件分析与设计笔记》 系列的第 25 篇,共 28 篇

这是我在学习软件分析与设计时整理的系列笔记。概念部分是我按自己的理解写的,示例与图示都是自己编、自己画的,重构手法的名称参考的是公开的重构目录类资料。理解有偏差的地方欢迎指出。

贯穿案例:片区家电维修派单系统。24 篇讲怎么判断设计好坏,这一篇讲发现不好之后怎么动手。

0 一段「能跑但不敢改」的代码

有这么一类代码:功能正常、测试通过、线上没出过问题 —— 但没人愿意改它。

本例里有个现成的候选:客服端的派单逻辑。

java
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)。

改完是这样:

java
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 里写了这样一段 ——

java
// 在控制器里
boolean canRequest = order.getStatus().equals("IN_PROGRESS")
                  && order.getEngineerId().equals(engineer.getId())
                  && stock.getOnHandQty() >= qty;

它连续取了 order 的三个方法、stock 的一个方法,而自己一个字段都没用 —— 这就是依恋情结。

手法:把方法搬到它真正依赖的那个类里(Move Method)。这条直接对应笔记 20 的原则:行为应该由持有状态的对象提供。

改法是让 WorkOrder 自己回答:

java
// 在 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,它们看起来一样其实是两个规则。所以这里该抽的不是「一个通用校验方法」,而是「状态机的判定能力」:

java
// 在 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。」 在测试保护下的小步重构,风险比「在外面再加一层判断」低得多;
  • 「不如重写。」 重写的风险是很难完全复制现有系统的隐含行为 —— 那些行为往往没人记得,但用户依赖它们。本例里「紧急单优先」这条规则就没写在需求文档里,只存在于代码里;重写的人很可能漏掉。多数情况下,渐进重构更稳。

第三条的解法是:重写之前先给老系统补测试,让那些隐含行为变成显式断言。补不出来,就说明你还不知道它做了什么 —— 那更不该重写。

本节自测

  1. 重构的定义里,「不改变外部行为」这一条为什么重要?举出本例中一个「以为是重构其实是改需求」的例子。
  2. 依恋情结的对策为什么是把方法搬走,而不是把数据搬过来?
  3. 为什么重构之前必须有测试?重构那段 dispatch() 之前,最该先补哪几条测试?
  4. 三次法则为什么会建议「第二次先忍住不动」?本例中第二次就抽象会犯什么错?
  5. 技术债这个说法里,「利息」指的是什么?怎么用本例量化它?
  6. 「重复代码」这一节里,为什么不该抽成一个「通用校验方法」,而该抽成「状态机判定」?
  7. 四个坏味道里,哪个是考场上最常出现的那一个?判断依据是什么?

参考答案

  1. 因为不改变行为才是重构,改变行为就是改需求或修 bug,要走业务确认与验收。本例:重构 dispatch() 时顺手把派单规则改成「优先派给昨天没派过的人」—— 那是改需求,不是重构(§1.1)。
  2. 因为行为应该由持有状态的对象提供。把数据搬过来会让控制器持有业务数据,职责更乱;搬方法则是让 WorkOrder 自己回答 canRequestParts()(§2.2)。
  3. 因为重构的唯一风险是在改善结构时不小心改变行为而不自知,测试是唯一的验证手段。本例最该先补五条:状态从 CREATED 变 ASSIGNED、同一工单不能派两人、满 5 单拒绝、有 photoUrl 时 needPhoto 必须为 true、紧急单优先(§3)。
  4. 因为那时你还没弄清它们真正的共同点。本例第二次就抽象会做出「通用 validateStatus()」,到第三次(回填工单要求第三个状态值)只能给它加参数 —— 于是它再也不是共用逻辑(§4.1)。
  5. 「利息」是每次新需求都要多绕的那点路。本例:因为 dispatch() 太长,每次改派单规则要两天(一天读、一天改测);重构花三天,后面还有五次需求,就省下七天(§5)。
  6. 因为「校验工单状态」在派单里要求 CREATED、在申请配件里要求 IN_PROGRESS —— 看起来一样其实是两个规则,抽成通用方法后一遇新需求就要加参数。该抽的是「状态机判定能力」,让 WorkOrder 提供 canDispatch() 与 canRequestParts()(§2.3)。
  7. 被拒绝的馈赠(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技术债结构妥协带来的长期额外成本

© 2026 HuanYu Blog

记录技术,也记录思考