Scala E07:类型状态 DSL 与两种解释器
订单确认之后不允许继续添加商品,可以在运行时检查一个布尔字段,也可以让确认前后的对象具有不同类型。类型状态 DSL 把部分调用顺序约束交给编译器:草稿允许添加,确认结果允许提交,非法链条在运行之前就被拒绝。
这个能力有明确边界。编译器能检查当前引用暴露哪些操作,却不会替数据库判断订单是否已被另一台机器提交,也不会让一个普通 Scala 引用只能使用一次。正确的 DSL 需要同时说明它排除了什么,以及哪些业务事实仍然必须动态检查。
本篇冻结 Scala 3.3.7 与 JDK 21,不依赖第三方 DSL 框架。独立工程位于 examples/scala-lab/electives/E07,通过证据为 evidence/20261002-a/。工程同时运行纯解释器和带写入回调的执行解释器,并将非法操作放在单独编译单元中验证。
状态进入类型参数
订单有 Draft 与 Confirmed 两种状态。状态类型只参与编译检查,不需要为每个对象保存一个运行时布尔标记。订单内部保存命令序列,构造器限制在 DSL 定义内部,调用方通过入口方法获得草稿。
1 | |
泛型参数 S 并不自动产生约束。真正让状态起作用的是方法接收类型:add 和 confirm 只接受 Order[Draft]。确认后返回 Order[Confirmed],调用方因此不能继续使用仅适用于 Draft 的扩展方法。
1 | |
这里仍然保留 require。金额来自输入值,类型 Int 包含零与负数;把阶段编码为类型,不能证明每个整数都符合金额要求。阶段正确性与字段正确性是两个维度。若要继续收紧字段,可以引入经过验证的金额类型,但创建该值的边界仍需要运行时校验。
私有构造器也参与可信边界。若外部代码能随意创建 Order[Confirmed] 并提供任意命令,类型签名就会把未经确认的序列当成已确认程序。DSL 的保证依赖于公开构造入口,不能只展示一个漂亮的泛型方法而忽略构造器可见性。
本实验把命令向量作为只读字段暴露,便于观察程序结构。Vector 更新会产生新向量,外部不能原地修改现有序列。但如果命令参数换成可变对象,向量的不可变性仍然不等于整张对象图不可变。解释器必须审查其中保留的引用。
非法程序必须独立编译
反例文件只保留目标违规操作:
1 | |
编译器报告 Confirmed 与 Draft 的接收类型不匹配,指出 add 无法应用。这个失败来自所要验证的状态约束,而不是缺失 import、未定义类或运行环境下载失败。记录器同时要求编译退出非零,并在诊断中匹配 add、Confirmed、Draft。
如果把反例混入正常工程,再通过“不运行该方法”避开它,编译依然会失败;如果用注释保存反例,构建成功又不能证明编译器确实拒绝它。独立编译用例可以让通过与拒绝同时成为可执行验收。
不要用 asInstanceOf 给反例强行转换状态。显式强制转换会绕过 DSL 的正常入口,测试到的是不受支持的逃生通道。类型状态设计针对遵守接口的 Scala 程序,并非能够阻止反射、强制转换或恶意字节码的安全沙箱。
[PATTERN] 一个静态约束需要一对证据:合法程序编译运行,最小非法程序因目标规则被拒绝。单独的成功样例无法展示约束是否真的存在。
程序描述与执行分开
订单命令可以先保存为数据,再由解释器决定如何处理。这样,同一个合法程序可以用于预览,也可以用于执行,业务构造部分不必直接依赖数据库客户端。
1 | |
纯解释器只计算金额;执行解释器复用同一个计算,再调用传入的持久化动作。本篇的命令集合很小,直接模式匹配比引入自由单子、代码生成或解释器注册框架更容易检查。只有出现确切的组合需求,才需要考虑更复杂的表示。
解释器返回相同金额,并不意味着它们具有相同副作用。纯解释器可以被多次调用而不写入;执行解释器每运行一次,就调用一次 persist。为这两个性质分别断言,才能避免把“返回值相等”误当作“重复执行安全”。
实验中的 persist 把金额追加到 List,用于记录调用次数。它是可观察的执行替身,不是数据库事务测试。将来接入数据库时,需要额外验证连接获取、事务提交、超时后结果未知和重复请求的幂等规则。
解释器还承担运行时业务检查的职责。类型状态只能保证调用方经过了确认入口,无法证明库存充足、账户余额有效或商品仍可销售。这些事实随时间改变,即使构造 DSL 时检查过,执行时也可能失效。解释器应返回能够表达业务拒绝的结果,而非把所有失败都当成程序缺陷。
别名暴露类型状态的边界
下面的程序完全合法:
1 | |
confirm 返回新值,没有消耗 draft。原来的引用仍然具有 Order[Draft] 类型,因此能够再次确认。这个 DSL 描述“每条构造链的操作顺序”,并没有提供线性类型意义上的一次性使用。
如果业务要求同一订单只能提交一次,仅靠 phantom type 不能实现。可以把稳定的订单标识与版本号带入解释器,通过数据库唯一键或条件更新保护提交;也可以建立受控运行时状态机。无论选择哪一种,都要在并发执行时测试竞争结果。
更复杂的错误是两个引用共享一个可变后台对象,却在其中一个引用确认后假定另一个引用也自动改变类型。Scala 的静态类型不会根据另一个别名发生的运行时变化重新检查旧引用。把可变对象包装成多个状态视图时,必须在接口设计中处理旧视图继续使用的问题。
本实验使用不可变命令向量,避免共享内部可变状态,让别名行为能够直接理解。代价是旧草稿仍可继续分支构造。这在报价预览、计划生成等场景可能正是需要的语义;在支付执行场景则需要额外的唯一执行控制。
金额模型不能省略溢出边界
样例用分为单位的 Int,输入 2500 与 500,结果为 3000。这个有限范围适合展示程序结构,不代表已经设计完整货币模型。大量订单求和可能溢出,跨币种金额不能直接相加,折扣和税率还涉及舍入规则。
若把样例用于真实订单,应明确金额范围并在入口和聚合处检查。使用 Long 可以扩大范围,但仍然有上界;使用 BigDecimal 需要固定货币与精度策略。类型状态可以阻止确认后添加商品,却不会修复错误的算术运算。
这说明 DSL 的静态保证应写成具体句子。例如“通过公开入口得到的 Confirmed 值不提供 add 操作”是能够由反例证明的陈述;“所有合法 DSL 程序都产生正确订单”则包含库存、算术、数据库和执行时序,远超当前证据。
命令演化与解释器一致性
将来增加折扣命令,需要同时更新纯解释器与执行解释器的行为。当前执行解释器调用 pure,因此两者共享金额逻辑,减少了规则分叉。但若执行解释器以后按命令逐项写数据库,就可能出现预览与执行计算不同的情况。
测试可以固定同一命令序列,对比预览金额、执行返回值和持久化观测值。再为每种新命令加入一个能改变结果的案例,避免解释器遗漏分支却恰好通过旧测试。对空程序、重复确认和非法金额,也应当分别决定是静态禁止还是动态拒绝。
公开命令枚举还会影响外部模式匹配的演化。新增分支可能要求调用方重新编译处理,持久化旧命令又需要序列化版本策略。本篇将命令保存在内存,没有承诺跨版本存储格式。需要保存工作流时,应把语言版本、数据版本与解释器版本分开管理。
从外部数据重建状态
类型状态对已经进入程序的合法值有效,但订单也可能来自数据库或 JSON。外部字段写着 Confirmed,并不会自动证明历史上执行过合法确认。反序列化入口必须验证必需字段、命令顺序与业务约束,然后才能通过受控构造器生成对应状态。若把内部构造器公开给所有调用方,非法状态就可能绕过 DSL 操作直接出现。
持久化还涉及版本迁移。旧记录可能不包含新规则要求的字段,读取时需要显式拒绝、补充数据或转入待迁移状态。不能只在类型参数上标记新状态,就认为旧数据已符合新约束。编译器检查的是当前程序中的类型关系,无法替代对过去存储内容的验证。
两个解释器也需要共享语义测试。纯解释器可作为有限输入的预期结果,执行解释器则额外记录传给持久化函数的参数。若以后加入折扣,两个解释器都应该对相同命令得到相同金额;写入次数则单独断言。把金额正确与副作用次数正确拆开,能够定位计算规则偏差和重复提交这两类不同问题。
结果与练习
正常程序退出码为 0,非法程序编译退出非零,两者都通过各自验收。
| 场景 | 实测结果 | 含义 |
|---|---|---|
| 纯解释器 | 3000 | 两笔金额相加 |
| 执行解释器 | 3000 | 与纯解释器一致 |
| 同一个确认值执行两次 | 两次写入 | 没有隐式幂等 |
| 同一个草稿确认两次 | 合法 | 草稿未被消耗 |
| 确认结果调用 add | 编译拒绝 | 接收类型不满足 Draft |
手算题:val a = empty.add(100); val b = a.confirm; a.add(200) 能否编译?能。最后一个表达式操作的是仍为 Draft 的 a,产生另一个草稿;它没有修改 b 保存的命令。
修改练习:增加 Submitted 类型,使只有 Confirmed 能调用 submit,并写出重复 submit 的编译反例。随后保存两个相同 Confirmed 引用,分别 submit,观察类型约束是否阻止两次真实写入。这个练习要求保留动态幂等检查,不能把类型阶段当作数据库唯一性。
| 可迁移规则 | 需要补充的动态检查 |
|---|---|
| 状态类型约束方法可用性 | 外部业务状态是否仍有效 |
| 私有构造器保护入口 | 输入值、反序列化与强制转换 |
| 解释器分离描述和效果 | 写入次数与错误传播 |
| 不可变分支保留旧值 | 唯一执行与并发竞争 |
运行方式见实验说明。
参考资料
- Scala 3 extension methods:接收类型与扩展方法。
- Scala 3 enums:命令代数表示。
- Scala 3 opaque types:字段边界的进一步封装方式。
