Skip to content

把长方法拆开,哪些行为最容易被改掉 ​

一个下单方法一百多行,校验、计价、优惠、预占库存、写审计全混在一起。有人花一个下午把它拆成了五个小方法,读起来清楚多了,几个手算的订单结果也对。两周后,客服反馈有订单在下单失败之后库存却被占住了。

长方法难读,是因为不同层次的事情平铺在一起:读者想知道「下单的主要步骤是什么」,却不得不在细节里找。拆开它是对的。风险在于拆的过程中顺手做的那些「无害」调整:把两个循环合成一个,把两个独立的计算换个顺序,把校验按类型分组。每一处都看起来不改变结果,组合在特定的输入上,行为就变了。

本文用一个比较器检查拆分前后的行为:同一组随机订单分别交给原方法和拆分后的版本,比较返回值、异常和副作用。实验见文末。

返回值或异常不同副作用不同第一个差异按原顺序拆成五个方法00把预占并进校验循环0271数量非法前,前一行已被预占两个折扣的先后对调9595222 变成 221先查完数量、再查库存360报出的错误从缺货变成数量非法三个错误都能通过「挑几个订单手算一下」的检查:它们只在特定的输入组合上才表现出来字符化测试把原方法当作答案,用大量输入比较。它保护的是原来的行为,包括原来的缺陷修缺陷是另一次有意的行为变更,与重构分开提交
图 1 · 实测(JDK 25.0.4.1,2000 个固定种子的随机订单):不预先写期望值,以原方法的行为为准,逐个订单比较返回值或异常、副作用的顺序。按原顺序拆分的版本一个差异都没有;另外三个版本每个只改了一处「看起来无害」的顺序,比较器各自找出了几十到几百个不同的订单。

一、长方法是怎么形成的,该怎么拆 ​

很少有人一开始就写一百行的方法。更常见的是:最初十几行,每次需求加一个条件、一段特殊处理,几个月之后就成了一百行。每一次添加单独看都合理,累积起来就失去了结构。

拆分的依据是抽象层次,不是行数。先读一遍,写出这个方法在业务上做了哪几步:

text
下单:校验订单 → 计算小计 → 应用优惠码 → 应用会员折扣 → 预占库存 → 写审计

这几步就是拆出来的方法名。拆完之后,原方法只剩这几步的调用,读者先看主干,需要时再点进某一步。每个小方法里的局部变量也少了,名字更容易起准。

「方法不超过 N 行」这类规则是一种提示,不是质量标准。按行数机械切分,得到的是 part1、part2 这样无法命名的片段,读者要在几个方法之间来回跳,比读长方法更累。能起出一个业务名称的片段才值得提取。

二、拆的时候改掉了什么 ​

实验的原方法是这个结构:

java
static int checkout(Order o, World w) {
    if (o.lines().isEmpty()) throw new IllegalArgumentException("empty order");
    int total = 0;
    for (Line l : o.lines()) {
        if (l.qty() <= 0) throw new IllegalArgumentException("bad qty " + l.sku());
        if (w.stock(l.sku()) < l.qty()) throw new IllegalStateException("out of stock " + l.sku());
        total += l.qty() * l.price();
    }
    if (o.coupon() != null && o.coupon().startsWith("OFF") && total >= 100) total -= 20;
    if (o.vip()) total = total * 95 / 100;
    for (Line l : o.lines()) w.reserve(l.sku(), l.qty());
    w.audit(o.id() + " total=" + total);
    return total;
}

四个拆分版本,2000 个随机订单,与原方法比较:

text
extracted          按原顺序拆成 validate、subtotal、applyCoupon、applyVip、reserveAll:返回值或异常不同 0 个,副作用不同 0 个
merged_loop        把预占并进校验循环:返回值或异常不同 0 个,副作用不同 271 个
swapped_discounts  两个折扣的先后对调:返回值或异常不同 95 个,副作用不同 95 个
split_validation   先查完所有数量、再查所有库存:返回值或异常不同 36 个,副作用不同 0 个

把两个循环合成一个。 原方法先校验所有行,全部通过之后才预占库存。「反正都要遍历一遍」,于是把预占放进了校验循环。返回值和异常完全一样,但第二行数量非法时,第一行已经被预占了:

text
第一个差异 O14:原方法 [IllegalArgumentException: bad qty S4;],新版本 [IllegalArgumentException: bad qty S4;reserve S2x3]

这就是开头「下单失败、库存被占」的来历。原方法的两个循环表达了一个规则:全部校验通过之前,不产生任何副作用。合并循环把这条规则拆掉了。

交换两个独立计算的顺序。 优惠码满 100 减 20,会员打 95 折。「两个折扣互不相干」,顺序就随手写了:

text
第一个差异 O0:原方法 [returned 222;…],新版本 [returned 221;…]

先减后折和先折后减,结果差一块钱;会员折扣把金额打到 100 以下时,优惠码还会从可用变成不可用。两个计算之间有依赖(后一个用前一个的结果),它们就不是独立的。

按类型重新分组校验。 先检查所有行的数量,再检查所有行的库存,看起来更整齐:

text
第一个差异 O290:原方法 [IllegalStateException: out of stock S9;],新版本 [IllegalArgumentException: bad qty S1;]

一个订单同时有缺货的行和数量非法的行时,报出来的第一个错误变了。调用方如果按异常类型决定提示用户什么、是否重试,行为就不同了。

这三个错误有一个共同点:随手挑几个订单手算,大概率发现不了。它们只在特定的输入组合上表现出来(多行订单里同时有问题行、金额正好在 100 附近),而人挑测试数据时倾向于挑「典型」的情况。

三、字符化测试:以原来的行为为准 ​

对一段没有完整测试的代码做重构,事先很难写出所有期望值。字符化测试换了一个思路:不写期望值,以原方法的输出为准。

  1. 准备大量输入:随机生成、取自生产的脱敏样本、或者两者结合。要包含边界和异常的情况。
  2. 把每个输入分别交给原实现和新实现。
  3. 比较可观察的一切:返回值、异常类型和消息、副作用(写了什么、调用了什么、顺序如何)。
  4. 有差异就报告第一个差异的完整上下文。

实验里副作用通过一个记录调用顺序的替身来观察。真实代码里,可以在外部依赖的边界上放同样的记录器:数据库访问、消息发送、外部调用。

它保护的是原来的行为,包括原来的缺陷。比较时发现原方法本身有问题,不要在重构里顺手修掉,先记下来;重构完成、比较通过之后,再用一次单独的、有意的提交修它。混在一起,出了问题就分不清是重构引入的还是修复引入的。

遗留系统上做这件事的完整步骤(找观察点、建立接缝、影子比对),见精益切片与遗留改造。

四、小步,每步都能停 ​

一个一百行的方法,不要一口气拆完。每一步只做一件事,做完跑一次比较:

  1. 提取第一个片段为方法,原位置改为调用。比较。
  2. 提取下一个片段。比较。
  3. 全部提取完之后,再考虑调整顺序、合并循环等结构性改动,并且一次只改一处。每一处都单独比较。

IDE 的「提取方法」「内联」「重命名」这类自动重构,在编译器能看到的范围内是保行为的,可以放心用。但「合并两个循环」「交换两段代码」「把条件提到前面」都不是 IDE 的保行为操作,是人做的判断,正是比较器要检查的地方。

小步的另一个好处是每一步之后都可以停下。拆到一半要去处理别的事,代码仍然处于一个行为正确、能够提交的状态。

五、检查清单 ​

  • 按抽象层次拆,每个片段能起出一个业务名称;不按行数切。
  • 拆分前写出原方法隐含的规则:哪些步骤必须在哪些步骤之前,什么时候才允许产生副作用。
  • 合并循环、交换顺序、重新分组校验,都要单独验证。
  • 用字符化测试比较返回值、异常与副作用,输入覆盖多行、边界金额和多种错误同时出现的情况。
  • 发现原有缺陷时先记录,重构与修复分开提交。
  • 一次只做一处改动,每步之后比较,随时可以停下。

配套实验

参考资料

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