软件建模35:函数式领域建模——类型、纯函数与显式状态变化
同一条租期规则可以写成对象方法,也可以写成接收数据、返回结果的函数。两种写法都必须拒绝提前量不足的申请,也必须解释拒绝后原有状态是否改变。把方法改成静态函数,并不会自动得到更可靠的领域模型;差别要落在输入、结果和状态变化的表达上。
本篇沿用 24:身份与值对象 的设备生命周期,以及 26:规则与工厂 的租期、报价和合同草案。实验用 Java 21 编译两组实现,逐项比较结果,再让两份有意写错的程序进入编译器。
相同规则,两种调用形状
教学规则保持不变:租期至少提前一小时,最长四十八小时;按实际时长向上取整到小时报价;费率版本和金额保存在草案中。设备可以从待检进入在役,从在役回到待检,待检或在役均可退役,退役后不能恢复。报价成功只表示生成草案,不能证明设备已经被预订。
对象方案直接编译既有的 Equipment、RentalPeriodSpecification、PricingService 和 RentalContract.Factory。Factory 持有规则对象与报价服务,调用 create 后返回草案,违反租期规则时抛出异常。设备对象则通过方法修改自己的状态;身份在操作前后保持相同。
纯函数候选接收申请、费率、规则参数和显式的 now,返回 Created 或 Rejected;设备转换接收不可变快照与动作,返回新快照或拒绝原因。它不读取系统时钟,不写目录,也不改变传入的设备。规则参数仍复用原有值对象,因此这不是把已有模型全部改写为无类型字典。
flowchart LR
R[申请与费率] --> O[Factory.create]
P[租期规则] --> O
O --> D[合同草案或异常]
R --> F[纯函数 create]
P --> F
N[显式 now] --> F
F --> C[Created 含完整快照]
F --> E[Rejected 含原因]
这种比较只改变规则的组织方式。两边使用相同的 EquipmentId、Period 和 Money,避免把字符串解析、金额精度差异误认为编程范式的差异。纯函数中独立计算时长与报价,生成的快照还会经过原有构造校验;所以对照包含独立计算,但成功数据的最终边界仍共享既有合同。
成功和失败各自携带什么
“成功标志、草案、错误原因”三个字段的组合允许矛盾:成功却没有草案,失败却同时返回可使用的草案。结果类型把这几个字段拆成互斥分支。Created 只携带合同快照,Rejected 只携带 TOO_SOON 或 TOO_LONG,不存在同时存入两种结果的构造器。
Java 的 sealed interface 限定直接实现类型,record 给每个分支声明数据成分;消费方通过 switch 分别处理它们。这里使用 Java 21 的模式匹配语法,无须预览选项。分支集合限制来自语言规则,非空与金额一致性则来自构造器中的代码。JLS 21 接口限制、record 定义。
实际编译的反例有两份。IncompleteSwitch 只处理 Created,遗漏 Rejected,编译器报告 switch 未覆盖全部输入;UnexpectedVariant 尝试新增未经 permits 允许的结果实现,也以退出码一失败。正常入口会检查这两个失败码,不能把一次编译错误当成整套实验无法运行。
这份证据支持“普通 Java 源码不能添加第三个结果分支,以及该 switch 不能漏掉失败分支”。它不支持“所有非法状态都不可表示”。Java 仍允许传入 null,调用 new Created(null) 可以通过编译,必须由构造器拒绝。金额与费率不匹配同样是跨字段约束,仍需运行检查。sealed 也没有证明业务规则已经完整。switch 穷尽性。
record 的字段引用不可重新赋值,不代表引用到的任意对象都不可变。本例复用的设备描述会复制标签集合,金额、时间和值对象也保持不可变;若把可修改的列表直接放进另一个 record,调用者仍可能改变列表内容,纯函数的输入假设随之失效。
九个状态动作组合怎样对照
设备实验枚举三个状态与三个动作,一共九个组合。每次用同一个快照恢复对象,再调用对象方法;纯函数则直接使用原快照。四个合法组合都返回相同的目标状态,其余五个组合都拒绝。对象失败后快照不变,函数调用后原输入也不变,这两项分别断言。
flowchart TD
S[旧设备快照] --> T[transition 快照与动作]
T -->|合法| V[Changed 新快照]
T -->|不合法| X[Refused 原状态与动作]
S --> K[旧快照仍可读取]
V --> A[应用层决定提交]
A --> Q[共享存储的并发控制]
X --> L[返回拒绝]
差别在调用之后变得具体。对象成功时,持有同一实例的调用者读到新状态;函数成功时,旧快照仍表示过去,新状态必须由调用者明确接收。如果应用忽略 Changed,只把“成功”写入日志,设备实际上还没有更新。返回新值把提交责任显露出来,也增加了遗漏提交的可能,应用测试仍不可省略。
快照保留有利于比较前后差异,但不等于事件溯源。实验没有动作日志、持久化版本或历史重建协议。恢复可信快照与执行领域转换也不同:恢复入口接收既有状态,transition 才判断当前动作是否合法,不能用恢复任意快照绕过退役规则后声称转换通过。
相同输入与规则边界
六组租期输入覆盖整小时、一小时多一秒、四十八小时上限、超出上限、提前量少一秒,以及同时违反两个条件。费率固定为每小时十二元五角:前三组分别返回十二元五角、二十五元和六百元。最后一组按既有顺序先报告 TOO_SOON,不能因为重排判断就悄悄改变可观察结果。
每一组都会重复调用纯函数并比较完整结果。相同的 now 与规则参数得到相同值;没有偷偷读取墙上时钟。这个有限测试验证当前案例的确定性,配合代码检查确认无外部读写,不能泛化成对任意 Java 方法纯度的自动证明。
另一个对照保持申请和规则不变,只把传入的现在时间向后移动一秒。原先恰好满足一小时提前量的申请随即被拒绝。纯函数不承诺不同环境输入也得到相同结果;把时间显式传入,恰好使这个依赖可以控制并复现,不必等待真实时钟走到边界。
规则对象本身也能拥有纯方法。26 的 evaluate 与 quote 不修改接收者,所以“对象”和“纯函数”并非互斥分类。真正需要选择的是:状态封装在哪里、依赖是否显式、错误如何传递、何时提交变化。静态方法数量不是模型质量指标。
这里把违反业务提前量表达为 Rejected,把 null 当成调用契约错误。两者分开后,应用可以向用户显示可修正的租期问题,并让编程错误进入技术失败路径。若所有异常都被转成 TOO_SOON,设备未知、金额错误或空参数就会被伪装成同一种业务拒绝。
纯函数之外的一致性责任
两个调用者可以从相同空闲快照各自计算出可接受的预订。计算完全确定,最终同时写入时仍可能超租。纯函数没有独占共享资源,也没有比较存储版本。本篇没有并发实验替换 25:一致性边界 的锁与重放规则。
应用可以在既有一致性边界内调用纯计算,然后一起提交状态和回执;也可以采用版本比较并在失败后重算。选择哪种方式取决于实际存储。本实验只验证规则转换,不提供数据库事务或跨进程保证。为了函数式外观而删掉日历的历史回执,会破坏取消后重试的语义,与计算是否纯净无关。
对象方案适合把可变状态与允许的操作放在一起,减少任意改字段的入口。数据加函数方案便于保留旧值、对照变化并组合显式结果。两者都应让不变量进入构造或操作边界,让外部副作用集中到可验证的提交点;在这组规则中,可以同时使用这两种组织方式。
错误分支的扩展也有维护成本。增加一种拒绝原因时,消费具体枚举的代码需要重新检查;增加一种结果变体时,遗漏分支的穷尽式匹配会编译失败。这个反馈能定位受影响的调用点,但业务上该向用户展示什么文字、是否允许重试,仍要另行决定。
复跑与检查范围
源码位于 examples/software-modeling/labs/35/,两份编译失败输入位于 models/35/。将 JAVA_HOME 指向本机 JDK 21 后,从仓库根目录运行:
1 | |
入口采用 --release 21、-Xlint:all 与 -Werror。运行检查共四十四项,随后分别验证两份错误程序退出码为一。完整输出、独立负例退出码和源码哈希保存在 evidence/35/;它们证明这些具体场景,没有覆盖任意业务输入或并发提交。
下载代码、编译反例与原始证据。下载包保留 examples/software-modeling 目录前缀,包含实际编译的24/26依赖。修改规则后,应同时观察成功值、失败原因和原状态,不能只检查最终是否打印 PASS。





