把一段 Java 方法改成返回 Result,并不自动意味着重构保持了原行为。原方法可能先检查单价再检查数量,空列表可能合法,溢出可能抛出特定异常。新实现即使对正常订单计算正确,也可能在这些边界上改变调用方看到的结果。

本章用一个小型计价函数展示两次可独立验证的改动。第一步把输入保存成快照,暂时保留异常契约;第二步把预期失败转成显式结果,使用边界观察函数比较新旧行为。三个版本同时留在实验中,不假装已经迁移了某个生产服务。

先记录旧方法实际承诺的行为

旧方法接收数量列表和单价分值。单价必须非负,数量必须在一到一百之间。总价使用精确整数乘法和加法,溢出抛 ArithmeticException。空列表返回零。

1
2
3
4
5
6
7
8
9
static long legacy(List<Integer> quantities, long unit) {
if (unit < 0) throw new IllegalArgumentException("unit");
long total = 0;
for (int q : quantities) {
if (q < 1 || q > 100) throw new IllegalArgumentException("quantity");
total = Math.addExact(total, Math.multiplyExact(q, unit));
}
return total;
}

局部 total 是可变变量,却没有被其他调用共享。方法的主要迁移问题不是把这个循环消灭,而是输入列表的所有权、失败表达和外部保存动作的边界。若在第一步就同时换成 Stream、修改空列表政策、增加错误累计并调整金额表示,一次失败会有太多候选原因。

七张回归卡独立写出输入与期望。正常数量二和三、单价一百二十五,期望六百二十五;空列表期望零;数量零和一百零一分别拒绝;数量零且单价负一时,期望 unit;数量一百乘 Long.MAX_VALUE 触发乘法溢出;两个数量一、单价 Long.MAX_VALUE 触发累加溢出。

最后两张卡有意区分乘法与加法。只测试一个超大金额不能证明两个位置都用了精确运算。若开发者把 addExact 改成普通加法,乘法溢出卡仍然通过,累加卡才能发现错误。

这些期望不是由新实现计算得到,也不是把旧实现输出直接复制成所谓金标准。实验将它们写成 Accepted 或 Rejected 常量,用来约束三个版本。旧实现与新实现即使犯了相同错误,也仍会与独立期望发生差异。

第一处边界:保存输入快照

Input record 在构造时使用 List.copyOf。对元素为 Integer 的列表,这能隔离调用者后续替换元素的操作。实验先用包含二的 ArrayList 建立 Input,再把原列表改成九十九,显式版本仍返回二乘一百二十五得到的二百五十。

1
2
3
4
5
record Input(List<Integer> quantities, long unit) {
Input {
quantities = List.copyOf(quantities);
}
}

快照并不意味着所有输入都被原样接受。List.copyOf 会拒绝 null 列表或 null 元素。本章的行为保持域明确限定为非 null 列表及非 null 整数元素;没有把新构造器对 null 的异常时机变化悄悄算作兼容。如果旧 API 需要支持这些输入,就应先为它们建立卡片,并决定适配器怎样维持契约。

快照版本 pure 仍然按旧顺序检查 unit、quantity,仍使用 Math 的精确运算。第一步没有改变错误表达,便于把观察差异归因到输入边界。需要迁移的调用方可以先在入口建立 Input,再调用 pure,而不同时改动所有 catch 分支。

快照也有成本。它复制列表的结构,并且不能解决列表内可变对象的深层别名。本实验元素不可变,因此这个边界足够;真实订单若包含可变商品对象,需要选择不可变行快照或明确的深复制策略。为所有字段机械地增加 final,不能替代这些所有权决策。

第二处边界:将预期失败写入结果

显式版本返回 sealed Result,只有 Accepted(cents) 和 Rejected(code)。负单价、越界数量、算术溢出分别成为可观察分支。局部循环仍保留,使这一阶段只改变失败通道。

1
2
3
4
5
6
7
8
9
10
11
12
13
static Result explicit(Input input) {
if (input.unit() < 0) return new Rejected("unit");
long total = 0;
for (int q : input.quantities()) {
if (q < 1 || q > 100) return new Rejected("quantity");
try {
total = Math.addExact(total, Math.multiplyExact(q, input.unit()));
} catch (ArithmeticException e) {
return new Rejected("overflow");
}
}
return new Accepted(total);
}

catch 范围只包围需要转换的精确算术,不在方法外围捕获所有 Exception。空指针、程序错误或保存端失败不应该被伪装成用户金额溢出。显式错误能改善调用方的分支可见性,但过宽捕获会破坏这种好处。

JDK 的 Math 文档定义了 addExact 和 multiplyExact 在结果溢出时抛 ArithmeticException。这里的业务选择是把这类异常转换为 overflow,而不是饱和到最大值或按补码回绕。这个选择属于接口契约,修改时需要更新独立期望。Math API

错误优先级被保留下来。对于单价负一、数量零,explicit 仍先返回 unit。若把数量验证提到前面,正常输入卡不会受影响,但优先级卡会失败。这样的失败不一定说明新政策不合理,只说明它不再是行为保持重构,需要把政策变更独立说明。

本章不把 fail-fast 改为错误累计。累计多个错误在表单校验中可能更方便,但它改变返回结构、检查范围和顺序。让“函数式化”承担所有这些变更,会使兼容性讨论失去明确对象。

用观察适配器比较不同接口

旧版返回 long 或抛异常,新版直接返回 Result,两者无法简单使用同一个 equals。实验增加 observe:成功包装为 Accepted,IllegalArgumentException 映射为原错误码,ArithmeticException 映射为 overflow。每张卡分别检查 legacy、pure 和 explicit 是否等于独立期望。

observe 是测试侧的比较口径,不是对生产异常做全局吞掉。它不捕获 AssertionError,也不把任意 RuntimeException 转成相同失败。若实现出现卡片之外的异常,实验应直接失败,让缺陷暴露出来。

这种观察等价也有边界。它比较成功金额和约定错误码,没有比较异常堆栈、异常发生的纳秒时刻、对象身份或日志文本。如果调用方依赖这些额外观察,应将其纳入契约;不能在适配器中擦掉差异后,再声称所有可观察行为完全一致。

三个版本对七张卡的通过,证明的是已列输入上的契约保持。它不是对任意整数列表的形式证明。可以继续加入生成器检查非溢出域的结果一致,也可以把每一个发现的失败样本收回固定回归卡。固定示例与性质测试负责不同任务,不需要互相替代。

保存动作留在计算之后

最后一组断言模拟应用层收集写入:仅当 explicit 返回 Accepted 时,才把 cents 加入 writes。七张输入产生的写入列表必须恰好是六百二十五和零。所有拒绝,包括数量错误和两类溢出,都不能产生写入。

这个断言能够发现“捕获异常后用零继续保存”的常见错误。它也明确确认空列表的零是合法成功,与错误回退的零不是同一回事。若只断言失败时返回零,应用层将无法区分两种来源。

writes 是内存观察列表,不是数据库。实验没有执行事务、网络超时或重复提交,因此不能从 accepted-only-save 推导出生产环境中的恰好一次写入。这里建立的是效果调用的控制流边界;持久化一致性需要另一个层次的设计和证据。

当真实项目已经有保存接口时,可以保持接口不变,先让计算方法返回明确结果,再在原调用点集中处理成功和拒绝。每次修改都应让已有调用方能编译并运行回归,避免出现一半代码使用异常、一半代码把所有失败当空值的中间状态。

回归卡的选择不是测试数量竞赛

七张卡覆盖正常、空输入、数量上下界、错误优先级、乘法溢出和加法溢出。增加十张相似的正常金额样例,未必比增加一个同时非法的输入更能约束迁移。卡片应针对实现分支和契约歧义选择,并保留它们为什么存在的解释。

发生失败时,先比较独立期望、旧实现和新实现三者。如果旧实现也不符合期望,应检查期望是否记录了真实契约,或是否发现了一个原有缺陷。修复旧缺陷可以是合理需求,但应从行为保持重构中分离出来,避免把差异解释为“函数式版本自然更正确”。

保存列表的顺序也属于当前观察。把输入并行处理后,即使接受的金额集合仍是六百二十五与零,写入顺序可能变化。如果存储动作依赖顺序,就需要保持串行或明确排序;如果不依赖,测试可以改为更合适的等价关系。不能在不声明的情况下通过排序结果来隐藏改变。并行化应当作为独立迁移步骤,保留失败时哪些写入已经发生的观察。

如何安排实际迁移顺序

一个可回退的迁移通常先把旧行为锁在卡片里,再抽取输入快照和纯计算,随后改变内部错误表示,最后在适配器处决定是否改变对外接口。每一步都有独立的观察面,出现差异时可以定位到最近的边界变化。

如果外部接口暂时必须继续抛异常,适配器可以把 Rejected 转回旧异常类型。这样内部调用者先获得显式分支,外部迁移另行安排。反之,若直接改变公共接口,需要让所有调用者理解失败通道变化,而不是只让编译器通过。

迁移过程不要求清除所有对象或局部变量。保留一个清晰的循环,比为了组合形式引入多层嵌套更容易检查溢出和短路顺序。抽象的价值应体现在输入、输出、错误和效果的可见性上,而不是体现在代码出现了多少次 map。

旧文纯核心与应用边界把文件输入、解析和异步报价分开,且明确讨论了资源寿命。本章不重做那个 Scala 应用,而是展示 Java 代码如何在不一次替换所有机制的情况下迁移。旧文高阶函数替代了什么指出 lambda 不会消除生命周期责任;这里同样没有把保存边界隐藏进匿名函数。

验证与练习

完整源码保留三个阶段和独立 oracle;执行证据记录 stages=3、cards=7,以及错误优先级、溢出、快照隔离和仅成功保存的通过结果。执行:

1
node examples/functional-programming/run.mjs 36

手算题:输入数量列表为一、一,单价为 Long.MAX_VALUE,第一次乘法、第一次加法、第二次乘法、第二次加法分别是否溢出?只有最后一步溢出;这解释了为什么单独测试乘法不够。再判断空列表加负单价的结果:仍然拒绝 unit,因为检查发生在循环之前。

修改题:将“空订单合法且金额零”改成“空订单拒绝 empty”。先新增独立期望,并保留一份旧契约对照,明确这是一项行为变更;不要同时重写循环或改金额类型。运行三阶段测试,观察哪些版本应保持旧结果、哪些版本应采用新结果,再设计对外适配策略。

完成修改后,还应检查保存列表是否随契约变化删除了零,而不是只让计算测试通过。业务结果与效果调用必须共同回归,才能避免一个看似局部的纯函数修改在边界上产生错误写入。