函数式编程11:模式匹配、状态转换与结构折叠
已付款订单收到了第二次付款请求。程序能识别 Paid 分支,不代表它应该再次付款;所有 case 都写全,也不代表它已经实现了重复请求策略。模式匹配解决数据的分类与拆解,业务转换决定每个分支允许发生什么,这两项需要不同的验证。
本章在 Java 21 中定义一个三状态、两事件的教学程序。普通函数返回 Accepted 或 Rejected,另一个结构折叠函数把同样的状态转换成文本或数字。编译变异实验为层次新增一个状态,检查旧 switch 是否真的被编译器拒绝。所有结果局限于内存转换,不包含付款调用或数据库提交。
从数据结构推导处理分支
状态包含 Draft、Paid(receipt)、Shipped(receipt, tracking)。事件包含 Pay(receipt) 与 Ship(tracking)。Pay 是一个请求,Paid 是已被模型接受的结果。把二者写成不同类型,可以避免直接把输入事件当成业务事实传给后续程序。
1 | |
事件携带的信息也可能不合法。本章接收空白收据与物流号,以验证转换函数如何拒绝外部请求。用于进入 step 的状态假定来自已验证构造或成功前缀;这里为突出匹配语义而省略了第 10 篇的强值类型。公开记录仍可直接构造无效状态,因此不要把本章最小源码作为完整领域封装复制进生产接口。
step 的参数组合有三乘二种分支形状。草稿可以接受合法付款;草稿发货被拒绝。已付款可以发货;重复付款被拒绝。已发货是本实验的终态,两个事件都拒绝。这张转换关系由业务政策给出,编译器只能检查处理覆盖,不能自行判断哪个转换应该成功。
把状态放外层、事件放内层,能够让局部上下文清楚。例如在 Paid 分支中,收据已经可用,成功发货时必须把它带入 Shipped。如果重新构造一个只含 tracking 的发货记录,付款证据就会丢失。模式绑定的用途不只是减少强制转换,也能把数据保留要求暴露在表达式中。
switch 返回一个完整结果
转换结果也是一个和类型:Accepted 包含新状态,Rejected 包含错误代码。调用者不会在失败时收到一个半成品状态,也不必从 null 判断执行到了哪一步。
1 | |
这里使用 switch 表达式,所有正常路径都要产生 Outcome。没有 default 可以让新增状态在重新编译时暴露遗漏。JDK 21 分析 sealed 层次时,会利用 permits 声明检查覆盖。开放类型无法穷尽列举,需要更广泛的匹配或兜底。模式 switch 的覆盖规则
default 不是坏语法。解析一个允许扩展的外部协议时,未知类别返回专门错误是合理的。问题在于用它掩盖内部封闭模型的演化:新增 Cancelled 后,default -> "unknown" 仍可能编译通过,但展示页面已经漏了业务阶段。决定要不要兜底,应先确定新增分支是否必须迫使此处修改。
穷尽性也没有消除 Java 的 null。step 开头对 state 和 event 做 requireNonNull,明确把 null 参数视为接口违约。它与事件中收据文本为空是不同层次:前者没有事件对象,后者有一个可以被业务拒绝的 Pay 请求。统一把所有 null 都解释为“没有订单”会丢失这一区别。
匹配顺序与业务守卫
类型模式确定数据属于哪一类,守卫或分支内判断再检查内容。若只有“非空收据的 Pay”一条规则,空白收据并不会凭空消失,还需要一个返回拒绝结果的路径。当前程序把内容判断写在匹配分支内部,所以这个 Pay 分支在结构上已经完整,判断只选择结果。
Java 的模式支配检查能够拒绝被更宽模式完全覆盖的后续模式,但不应依赖它分析任意业务谓词。例如一个调用配置系统的条件与另一个条件是否互补,远不是“列举所有子类型”那么简单。复杂内容规则宜抽成返回显式结果的小函数,再用测试描述边界值。
本实验对已付款的重复请求一律拒绝,不区分同一收据的重试与另一收据的冲突。若要支持幂等重试,应先明确比较键:相同请求标识是否足够,是否还需要金额与币种一致。只写“已付款就返回成功”会让冲突请求也得到成功回应,匹配穷尽并不能阻止这个逻辑错误。
Rejected 没有改变旧状态。调用者若要处理事件序列,可以在成功时携带新状态继续,在失败时返回错误及成功前缀的状态。另一个程序也可能选择记录失败后继续处理,两者都能用同一 step 实现;批处理的继续策略不能藏在一个普通错误字符串里。
结构折叠集中分支知识
如果多个消费函数都要处理这三个状态,可以定义一个最小 fold。它为每个构造分支接收一个处理函数,统一返回 R:
1 | |
Draft 没有有效载荷,所以对应 Supplier;Paid 有一个字段,所以对应一元函数;Shipped 有两个字段,所以对应二元函数。这个签名是从数据构造方式推导出的。R 可以是展示文本、阶段序号或另一份只读数据,数据分支本身没有因此变化。
对 Shipped(“r1”,“t1”),文本折叠传入 (r,t) -> r + "/" + t,结果为 r1/t1。对 Draft,数字折叠的 Supplier 返回零。两个实验共享分支拆解,却采用不同的结果类型。这比让 State 自己实现所有展示格式更适合外部多种消费方式。
它不是列表 foldLeft,也没有遍历事件历史。当前 State 是非递归的和类型,fold 只消去一次构造器选择。若数据是包含子节点的表达式树,结构折叠还需要先折叠子节点,再把子结果交给当前处理器;不能看到相同方法名就推导相同复杂度或栈行为。
新增状态也会改变 fold 的接口。调用方需要新的处理函数,或接收一种明确的扩展政策。这种修改压力有价值,因为它指出哪些消费点需要重新考虑。若新增业务操作远多于新增状态,外部 fold 往往方便;若状态种类经常开放扩展,面向对象的方法分派也可能更直接。选择应根据实际变化方向判断。
让编译器实际检验一次遗漏
实验内嵌了一个极小 Probe 源码,通过 JDK 自带 JavaCompiler 编译。基线只有 A、B 两个 permitted 分支,show 的 switch 也只有 A、B。基线必须成功,否则负例失败可能只是编译器不可用或源码模板错误。
第二次只改变层次:增加 permitted 子类型 C,并定义它,但保持 show 不变。程序要求编译失败,还检查诊断代码包含 not.exhaustive。这样可以排除“因为别的语法错误而失败”的假通过。生成的 class 只放在临时目录,验证完成后删除。
编译输出实际包含 compiler.err.not.exhaustive,而整个实验进程退出零,因为这个失败正是测试期待的行为。读证据时需要区分被测编译的失败与测试驱动器的失败:前者是负例成功的观察值,后者才说明验收没有通过。
1 | |
完整源码及内嵌编译反例、公共运行器和本章结果证据共同给出可重放入口。行为断言验证付款、保留收据的发货、未付款发货拒绝、重复付款拒绝、终态拒绝和空白收据拒绝;结构断言验证两种 R 的折叠。
穷尽检查不能代替的边界
把 Paid + Ship 写成 Accepted(Draft) 仍然可能通过类型检查,因为 Draft 也是 State。只有对具体结果的断言才能发现付款信息被清空。编译变异验证的是结构遗漏,业务断言验证的是映射关系,这两类证据不能互相替代。
旧版本消费者与新版本模型分别编译再混用,也超出当前“同时重新编译”实验。JDK 文档讨论了编译时穷尽与运行时模型变化之间的差别。发布多个模块时,应做版本兼容验证,不能把本地全量重新编译的结果扩大成任意二进制组合安全。
Scala 模式匹配与提取器已经解释守卫、顺序与匹配警告。本章新增 Java 的编译诊断实验及构造器到 fold 参数的推导。Scala 警告配合 -Werror 与 Java switch 表达式的错误规则是不同的工具链契约,不能只凭两者都有 match 风格就互换描述。
新增分支后的修改面
新增退款状态会影响哪些函数,取决于它们承诺知道多少状态结构。直接匹配所有状态的展示函数、转换函数和计费函数,重新编译时都可能需要处理新分支;只接收某个具体已支付值的局部函数,未必需要改变。这是类型签名提供的影响范围信息,不等于所有变化都会自动修好。
fold 将分支处理参数集中到一个入口后,调用方可以不写 switch,但分支仍存在于参数列表中。若增加一个新分支,新版 fold 可能要求再传一个处理器,旧调用方同样需要修改。它改变了分支知识的组织位置,没有消除业务扩展成本。若给新参数安排一个统一处理所有状态的默认行为,迁移虽然更容易编译,却可能失去原本希望获得的提醒。
事件与状态也需要分开演进。添加一个新事件不一定添加状态,例如重复确认支付可以按业务约定返回原状态或明确拒绝;添加一个状态也不一定需要公开新事件,例如内部恢复过程产生的状态。实验选择拒绝重复支付,只是本章转换表的规则,不是所有订单系统共同遵守的事实。改变该规则时,应同时更新返回值与反例断言。
另一个常见误用是把事件重放成功当作外部动作可以重做。这里的 step 只是纯状态变换,没有扣款、发货或通知。如果实际事件处理器还执行这些动作,重复投递是否安全要由幂等键、持久化与事务边界决定;状态匹配穷尽并不能证明外部副作用不会重复。
审查一个转换函数时,可以先列出每个状态允许接受的事件,再逐格对照测试。编译器检查状态覆盖,测试检查转换表的内容,两者互补。仅覆盖每个 case 一次仍可能漏掉同一状态下另一种事件,因此本章同时保留未支付发货、重复支付和终态事件的拒绝路径。
手算与修改练习
手算题:从 Draft 开始,按“首个 Rejected 就停止”的策略处理 Pay(“r1”)、Pay(“r2”)、Ship(“t1”)。第一个事件得到 Paid(“r1”),第二个得到 already-paid,第三个不执行。最后成功状态是 Paid(“r1”),整个序列结果是拒绝。把“成功前缀状态”和“整个批次成功”分开记录。
修改题:新增 Cancelled 状态及 Cancel 事件,只允许草稿取消。先不改 fold,观察编译失败;再补转换与折叠,断言取消后发货被拒绝,取消前的 Draft 值没有被修改。不要加入无条件 default 来消除遗漏。最后重跑 11,保留原先“新增分支触发编译缺口”的独立变异测试。

