Skip to content

从症状到重构手法 ​

评审意见写着「这段代码不够好,耦合太高」。作者问怎么改,评审者也说不出具体的一步。几轮讨论之后,代码被拆成了七个类,读起来比原来更难,因为每个类都只做一点点,要理解一个流程得在七个文件之间跳。

「代码不美」「耦合太高」这类评价很难变成行动。能变成行动的,是具体的信号和具体的手法:这个方法的名字说不出它做了什么,这两段代码一样,这个参数列表有七个参数。每一个信号背后可能是不同的问题,同一个信号,答案不同,手法可能正好相反。

这一篇是专题的总览:把常见的信号、要先问的问题、可能的手法和改动时的风险放在一起,再指向展开讨论的文章。

看到的信号先问可能的手法名字说不清在做什么它在业务上是哪一步?是不是装了几件事改名;先拆再命名两段代码一样需求变了,它们一定一起改吗是:合并成一个来源;否:保持分开方法很长能写出它的几个业务步骤吗按步骤提取,保持原顺序参数很多它们为什么一起出现参数对象 / 构造注入 / 拆方法同一个 switch 出现在多处新增一个类别要改几处多态;只出现一次用 switch 表达式外部直接改内部状态谁该守住这条规则用业务方法代替 setter一层只是转发这一层还有存在的理由吗内联,去掉中间人
图 1 · 示意:代码信号只是调查的入口。看到同一个信号,先问它背后是什么问题,答案不同,手法可能相反:同样是「方法很短但到处跳」,可能该内联;同样是「重复」,可能不该合并。每一步改动都用测试或比较器确认行为没变。

一、信号不是结论 ​

代码坏味道(code smell)这个说法本身就说明了它的性质:闻到了味道,要去查一查是什么,而不是一闻到就动手。几个容易被当成结论的信号:

  • 方法长。 长度只是提示。一个状态转换表有三十个分支,硬拆开只会让规则散落各处。真正要问的是:它是否混合了不同层次的事情。
  • 有 setter。 数据传输对象、配置对象、框架绑定的对象,有 setter 是正常的。要问的是:这个对象有没有需要守住的规则,setter 是否让外部绕过了规则。
  • 有 else、有 switch。 它们是表达规则的基本手段。要问的是:同一个分派是否出现在多处。
  • 类名带 Manager、Helper。 有时是职责不清的信号,有时就是准确的名字(TransactionManager)。

把信号当成结论,就会出现「禁止 else」「方法不超过十行」「每个类最多两个字段」这类规则。它们作为训练时的约束有价值(逼着人去想别的写法),作为生产代码的标准,会制造出更多问题。

二、信号、问题与手法 ​

信号先问可能的手法展开
名字说不清在做什么它在业务上是哪一步?是不是装了几件事?改名;先拆开再分别命名命名与改名
两段代码一样需求变了,它们一定一起改吗?是:合并成一个来源;否:保持分开、各自命名重复的知识与偶然的相似
方法很长能写出它的几个业务步骤吗?按步骤提取方法,保持原有顺序保行为的拆分
参数很多这些参数为什么一起出现?参数对象、构造器注入、按意图拆成多个方法参数放在哪里
嵌套很深主流程是什么,哪些是特殊情况?卫语句、提取单个元素的处理控制流与复杂度
同一个 switch 出现在多处新增一个类别要改几处?多态;只出现一次时用 switch 表达式控制流与复杂度
变量先给初值、后面零散修改这个值在哪一刻才算准备好?一次性计算;final 加确定赋值局部状态与集合的契约
外部直接改对象内部状态谁应该守住这条规则?用业务方法代替 setter;不交出内部集合不变量的入口
调用方沿着对象链一路取数据调用方真正要的是什么?把行为移到拥有数据的对象上封装、组合与多态
一层只是原样转发这一层还有存在的理由吗?内联,去掉中间人
一个类因为几种不同的原因被修改修改它的人和原因有几类?按变化原因拆分设计原则

最后两行说明了一个常被忽略的事实:重构手法是成对的。提取方法与内联方法、提取类与内联类、隐藏委托与移除中间人,方向相反。过度提取的代码同样需要重构,手法是把它们合回去。开头那个「拆成七个类」的例子,需要的正是内联。

三、每一步都要能证明行为没变 ​

重构的定义是不改变外部可观察行为的结构调整。「不改变行为」不能靠感觉,要有证据:

  • 自动化测试,覆盖要改的代码,并且断言足够强(只检查「没有抛异常」的测试保护不了什么,见覆盖率 100%,一个断言都没有)。
  • 字符化比较:没有足够的测试时,以原实现的输出为准,用大量输入比较新旧实现。
  • IDE 的自动重构:提取、内联、改名、移动,在编译器看得到的范围内是保行为的。编译器看不到的地方(反射、序列化、配置、模板)需要另外检查。

改动要小。一次只做一种手法,做完立即验证,验证通过就可以提交。这样每一步之后都可以停下来去做别的事,代码始终处于正确的状态;出了问题也只需要回退最后一小步。

四、什么时候不重构 ​

  • 没有保护的时候。 没有测试、也来不及建字符化比较的代码,先建保护,再重构。
  • 没有理由的时候。 重构服务于接下来的修改:要加一个功能,先把代码整理成容易加的样子,再加。与当前工作无关的「顺手重构」,会让变更难以评审,也增加了风险。
  • 与行为变更混在一起的时候。 一个提交里既重构又改行为,出了问题分不清是哪一个引起的。分开提交,先重构、后改行为。
  • 对外契约。 公开 API、序列化格式、数据库结构的改动不是重构,是契约变更,要走兼容流程。

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权