当业务流程只需要“查询报价,再保存金额”,代码可以依赖两个操作,而不依赖操作最终由内存状态、数据库还是异步运行时实现。效果参数 F[_] 把返回值所处的计算上下文保留下来,使流程不必在每一步抽出普通值。

本章实现同一段 program 的两个解释器:State 版本把事件与保存记录作为纯状态返回;IO 版本通过 Ref 执行实际的内存状态更新。两者都不是远程报价服务。实验比较业务结果与事件顺序,并额外检查 IO 描述构造、重复执行和报价失败。这比只把类型参数写成 F 更能说明抽象是否承担了实际责任。

先从具体接口开始

假设数量三的报价是三乘一百二十五分,保存成功没有额外返回值。直接实现可以接收 Quote 与 Save 两个函数,先调用报价,再调用保存。只要暂时不需要切换执行上下文,这种普通依赖注入已经足够。

引入 F 的原因是两个解释器对“返回一个值”的含义不同。State 返回的是一个需要初始 World 才能解释的状态转换;IO 返回的是一个交给运行时执行的效果描述。若接口强制 quote 返回 Long,State 就必须提前拿到 World,IO 就必须提前执行,业务流程将失去组合所需的上下文。

因此接口写成两个操作:

1
2
3
trait Ports[F[_]]:
def quote(quantity: Int): F[Long]
def save(cents: Long): F[Unit]

F[_] 表示一个接受一个类型参数的类型构造器。它不是运行时从数据库读出的字符串,也不是 Java 式的“任意对象容器”。将 F 取为 IO 后,quote 的返回类型是 IO[Long];取为 State[World,*] 后,返回类型是 State[World,Long]。

本实验使用类型别名 Test[A] = State[World,A],因此 Test 是合法的一元类型构造器。World 这一参数已经固定,只留下结果类型 A。若直接把需要两个参数的 State 填进 F 的位置,类型形状就不匹配。

业务程序需要哪些能力

program 先检查数量,非法时返回上下文中的 Left;合法时查询报价,拿到金额后保存,最后返回 Right。它需要把普通结果放进 F,以及依赖前一步结果继续组合,因此要求 Monad[F]。

1
2
3
4
5
6
7
def program[F[_]: Monad](
quantity: Int, ports: Ports[F]
): F[Either[String, Long]] =
if quantity < 1 then Monad[F].pure(Left("quantity"))
else ports.quote(quantity).flatMap { cents =>
ports.save(cents).as(Right(cents))
}

Monad 能力用于 pure 和 flatMap,as 来自相应的组合语法。接口没有要求并发、取消、时钟或文件操作,因为当前流程不使用这些能力。把约束直接提高到某个庞大的运行时类型类,会让纯解释器承担不必要的实现义务。

保存依赖报价结果,不能把这两个步骤随意并行。类型里 Long 从 quote 流向 save,说明了数据依赖;flatMap 把这种依赖变成执行顺序。若业务改为两个独立报价再合并,可以另行选择并行组合,但不是机械替换当前操作符。

返回类型保留两个层次。外层 F 表示解释上下文,内层 Either 表示数量规则的业务拒绝。IO 解释器自身的技术失败仍在 IO 的失败通道中。本章没有把所有技术异常都捕获为 Left,也没有使用 MonadError 来抽象失败恢复。

Cats 的 Monad 文档定义了这些组合能力,并要求实例遵守相应定律。类型编译成功只能说明可用操作的形状匹配,不能证明某个自定义解释器在业务上无副作用或符合协议。Cats Monad

纯解释器如何记录动作

World 保存两个向量:events 记录 quote 与 save 的顺序,saved 记录保存的金额。State 的 quote 返回一个新 World 和计算出的金额;save 只更新 World,不产生有用的普通返回值。

1
2
3
4
5
6
7
8
9
case class World(events: Vector[String], saved: Vector[Long])
type Test[A] = State[World, A]

val purePorts = new Ports[Test]:
def quote(q: Int): Test[Long] =
State(s => (s.copy(events = s.events :+ "quote"), q.toLong * 125))
def save(c: Long): Test[Unit] =
State.modify(s =>
s.copy(events = s.events :+ "save", saved = s.saved :+ c))

运行 program[Test](3,purePorts).run(empty).value 得到一个二元组:最终 World 和业务结果。实验断言业务结果为 Right(375),事件恰好为 quote、save,保存记录恰好为一个三百七十五。不能只比较最终金额,因为错误的流程也可能先保存一个猜测值,再查询出相同金额。

非法数量零的纯解释结果为 Left(quantity),World 仍然等于 empty。这个负断言说明业务分支在解释器操作之前结束,没有查询也没有保存。若验证被移动到报价之后,返回值仍可能是相同 Left,只有状态观察能发现额外操作。

纯解释器的事件只是数据,不是已经发生的网络调用。它适合检验流程决定了哪些操作、顺序如何和最终状态怎样,不证明真实数据库驱动能连接,也不证明异步取消会释放资源。把测试解释器通过当成适配器通过,会漏掉整个效果边界。

World 中没有真实时间和随机数,因此同一输入与初始 World 得到同一结果。如果未来流程依赖时间,需要把时间作为端口或状态中的显式输入;不能在 purePorts 内直接调用系统时钟后仍称其为纯解释器。

IO 解释器保留同一业务流程

IO 版本创建 Ref[IO,World],quote 和 save 通过 Ref.update 更新记录。它复用同一个 program,没有复制业务分支。实验执行后读取 Ref,与纯解释器产生的 World 作结构比较。

1
2
3
4
5
6
7
8
val ports = new Ports[IO]:
def quote(q: Int): IO[Long] =
state.update(s => s.copy(events = s.events :+ "quote"))
.as(q.toLong * 125)
def save(c: Long): IO[Unit] =
state.update(s => s.copy(
events = s.events :+ "save",
saved = s.saved :+ c))

这是真实执行的 IO 内存效果:Ref 内容发生改变,并由后续读取观察。它没有访问数据库,不能称为生产持久化解释器。两个解释器的差别在执行机制和状态所有权,不在于一个“假”、另一个“真”的命名。

业务程序构造为 description = program[IO](3,ports) 后,实验立即读取 Ref,断言仍为空。随后执行 description,再读取状态。这个顺序把“构造描述没有更新”和“执行描述后有更新”分成两个可观察事件。

仅仅把方法返回类型写成 IO 并不能自动保证实现正确延迟。如果解释器在返回 IO 之前直接修改外部变量,program 构造期间仍可能触发效果。因此延迟测试应针对实际解释器实现,而不是把类型名当成证明。

当前端口操作使用 Ref 的效果返回值来更新状态,没有在构造时调用不受控制的同步代码。对于 JDBC、文件读取等阻塞接口,还需要在 IO 解释器内选择合适的阻塞边界和资源作用域;这不会改变 Ports 的业务名字,却会影响运行正确性。

同一个描述执行两次

实验再次执行 description,saved 变成两个三百七十五。IO 描述可重复执行,不等于结果自动缓存,也不等于保存自动幂等。第一次执行的 Ref 更新是真实状态变化,第二次会再次执行端口操作。

这个结果对重试很重要。若应用在超时后重新运行整个描述,报价和保存都可能再次发生。是否允许重复、如何识别同一业务命令、保存回执何时提交,需要独立协议。效果抽象只保留组合结构,不凭空增加幂等性。

State 解释器也可以从同一个 empty 多次解释,得到相同结果;如果把第一次返回的 World 作为第二次初始状态,则保存记录也会累积。差别在于状态输入由调用方显式传入,还是由 Ref 在效果环境中保存。

这种对照有助于辨别“描述重用”和“状态重用”。两个程序表达式可以相同,但它们所面对的初始世界不同。讨论引用透明时,需要把描述值与执行该描述对外部世界的影响分开。

若需求是查询结果共享,可以在明确作用域内引入缓存;若需求是保存幂等,应该增加命令键和持久回执。把整个 IO 缓存起来可能同时改变资源生命周期、异常重试和保存次数,不能作为无条件的修复办法。

技术失败保留在外层

失败解释器的 quote 返回 IO.raiseError,save 仍然有能力更新状态。执行 program(…).attempt 后,实验断言结果为失败,Ref 与失败前完全相同。它证明当前顺序组合在报价失败后没有继续保存。

这个失败不等于 Left(quantity)。前者是技术通道中的异常,后者是业务程序显式返回的合法拒绝值。调用方可以对非法数量直接反馈输入问题,对报价系统失败采取不同策略,不必通过解析同一个字符串猜测原因。

本章没有把失败路径同时放进 State 解释器。普通 State 的 F 不表达技术失败,若要在纯模型中模拟它,可以选择增加 Either 层或为世界加入结果脚本。但那将扩大实验的类型结构,需要明确错误在哪一层,不应为了“两个解释器都覆盖所有行为”随意吞异常。

同理,IO 的取消不属于当前 Monad 程序能够自行请求或恢复的能力。外层运行时可以取消执行,但端口中的资源释放必须由解释器保证。只写 Monad[F] 的业务程序无法从类型约束中推导所有 F 都拥有相同的取消语义。

技术失败可能发生在保存之后、响应之前。此时重新执行整个程序是否重复保存,不由 quote-failure-no-save 这条测试回答。测试名称已经限定了失败点;其他位置的故障需要另外注入,不能借用当前负断言扩大结论。

抽象端口与接口拆分

端口应该围绕业务需要,而不是把底层客户端的所有方法搬进一个泛型接口。当前 program 只需要 quote 和 save,因此测试世界也只记录这两个事件。若接口增加几十个未使用操作,每个解释器都需要应付它们,替换成本会迅速增加。

也不能把事务语义拆得过细后假定组合自动原子。假如业务需要“保存订单并写发件箱”不可分割,两个独立 save 端口未必足够表达约束。可以把原子操作作为一个端口,或者引入明确的事务上下文,再由实际适配器落实。

端口返回 Unit 表示调用方不需要普通结果,不表示执行没有效果,也不表示保存已经持久落盘。真实适配器必须说明返回成功对应哪个承诺:进入队列、事务提交还是外部确认。业务流程不能只凭 F[Unit] 判断耐久性级别。

报价金额在实验中固定由数量乘一百二十五得到,未进行货币舍入。这个简化让双解释器对照只关注流程结构;累计工程会使用明确的 CNY 十进制金额和折扣规则。不要把当前三百七十五分的端口实现当作通用金额模型。

如果多个业务程序需要不同端口子集,可以拆成更小接口,或者让一个解释器实现多个接口。拆分是否值得,取决于独立变化与测试需求,不是类型参数越多就越抽象。当前两操作接口足够小,没有引入额外层次。

与普通依赖注入的关系

这个写法常被放在 tagless-final 风格下讨论。实际代码的重要特点是程序依赖代数接口与所需组合能力,没有先建立一个包含 Quote、Save 节点的完整语法树。解释器通过方法实现决定每个操作的含义。

普通对象依赖注入同样能替换 QuoteService 和 Repository。F 参数增加的是对返回计算上下文的统一抽象,使 State 和 IO 这样不同形状的执行都能复用同一组合代码。它不取代接口隔离、资源所有权或事务设计。

如果项目只使用一种具体 IO,且纯核心已经可以直接测试,保留具体 IO 的应用层往往更简单。引入 F[_] 需要团队能理解高阶类型、实例和错误层次;没有替换需求时,这些成本可能超过收益。

本章确实建立了两种解释器并执行对照,因此抽象有一个可展示的用途。是否继续扩展到整个系统,应检查边界是否稳定、测试解释器是否还忠实,以及真实适配器是否仍有独立验证。不能因为一个小实验优雅,就把所有类都改成泛型效果。

相反,若程序在业务逻辑中随处调用 unsafeRunSync 或直接取 State 的 value,再把普通结果重新包装,F 参数就可能只剩外观。组合过程中应保留上下文,把解释集中在应用边界,这样替换才不会被隐藏的执行点破坏。

两种解释器相等到什么程度

当前相等关系比较返回 Either 和 World 的完整事件、保存记录。它不比较耗时、分配、线程、异常堆栈或对象身份。相等关系越清楚,测试越不容易把无关实现细节当成契约,也越能防止把未观察的性质说成通过。

事件顺序严格要求 quote 在 save 之前,因为保存依赖金额。若未来增加两次独立查询,并允许并行,事件顺序就可能不是唯一。那时应比较必要的偏序或业务结果,而不是为了测试稳定强迫所有效果串行执行。

两个解释器使用相同的报价公式,可能共享同一种价格误解。因此实验还有独立的三百七十五常量作为结果期望。双实现一致只是一种证据,不能代替业务 oracle。对于税率、折扣等规则,应有外部确定的示例和边界卡。

测试解释器也可能太宽容。比如它总是接受重复保存,而真实数据库有唯一约束;或者它立即返回,而真实报价有超时。应该在适配器测试中覆盖这些差异,或把影响业务决定的规则提升为显式端口结果,避免纯测试一直通过而生产不断失败。

解释器的实现可以变化,但端口契约不能随意漂移。若 quote 从“总金额”改成“单价”,类型仍然是 F[Long],编译器不会发现,程序会保存错误金额。领域命名和更精确的值类型能减少这种歧义,仍需要独立回归金额示例。

旧文边界与后续组合

旧文高阶类型与组合定律解释了 F[_] 和基于观察相等的定律测试。本章把这种类型形状落到两个实际解释器上,不重复证明代数定律,也不把返回同一个数字误当成全部解释等价。

旧文纯核心与应用边界使用显式报价接口与异步流程,并提醒资源求值边界。本章保留同样的业务边界意识,但将可替换部分扩展到 State 与 IO;并发上限和取消仍留给需要它们的运行层。

本系列的状态不变量处理合法转换,契约回归处理渐进变更。双解释器可以与它们组合:把纯转换的结果交给端口执行,把旧接口适配器留在边界,而不必把所有状态都塞进一个巨大的 F 抽象。

解释器契约也需要负例

可以设想一个错误解释器:quote 正常记录事件,save 却丢弃金额。业务返回仍然是 Right(375),只检查结果的测试会通过,World 的 saved 比较才会失败。另一个错误解释器把 save 调用两次,最终金额同样正确,但事件和保存向量长度会暴露差异。这说明选择哪些观察不是测试细节,而是抽象契约的一部分。

还有一类错误发生在参数传递。若 quote 固定返回三百七十五,不读取数量,当前数量三的正例仍可能通过。扩大输入卡时需要选择不同数量,或让测试解释器记录实际参数。本文只报告现有输入上的对照,不能把一个正常样本扩写成任意数量的通用正确性证明。

纯解释器与实际解释器的差异也应受到有意约束。比如测试解释器允许所有保存成功,真实解释器可能因存储约束拒绝。若这种拒绝会改变业务下一步,应把它表示在端口结果里,并让纯模型能够表达相同分支;若只是基础设施故障,则由实际解释器的故障注入覆盖。把所有失败统一称为 mock 不够真实,无法指导具体修补。

在维护层面,端口变更后两个解释器同时无法编译是一种有用反馈。它迫使维护者思考新增操作在纯模型和实际执行中各是什么意思。但编译通过只是最低要求:给新增方法返回一个固定默认值,仍可能绕过真实契约。结果、事件和失败路径需要一起更新。

并不需要为每个库类建立一个对应测试解释器。应优先对业务稳定的动作边界建模,而把序列化、连接池和网络协议留在适配器测试。否则纯 World 会逐渐复制整个基础设施实现,维护成本升高,却仍无法证明它与实际驱动一致。

运行边界还应保持唯一归属:库函数返回描述,应用入口负责解释。若每个辅助方法都自行启动运行时,测试世界与实际世界的替换关系就会被隐藏的执行点破坏,错误与资源也更难统一管理。这个约定同样适用于非泛型应用:明确执行入口能让关闭、取消和最终错误观察集中在拥有任务生命周期的位置,而不是分散到任意辅助函数。

运行与练习

Main.scala保留完整 State 与 IO 解释器;运行证据记录环境、依赖、源码散列和实际断言输出。执行:

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

本次通过的观察包括同一结果、同一事件、非法输入不改变纯世界、IO 构造不执行、重复执行产生两次保存,以及报价失败不保存。没有执行真实数据库、网络或分布式事务,因此这些项目不在通过清单中。

类型题:把 F 依次替换成 Test 和 IO,写出 quote、save、program 的完整类型;再指出 program 返回的 Left 与 IO.attempt 返回的 Left 分别位于哪一层。两者都使用 Either 形状,但一个承载业务拒绝,另一个承载解释过程的异常。

修改题:为 Ports 增加返回 F[Instant] 的时间端口,让保存记录同时包含金额与时间。纯解释器从固定 World 中读取时间,IO 解释器也注入固定时间输入,先比较二者;随后仅在实际解释器边界接入系统时钟,明确哪些结果不再适合逐值相等。不得在泛型 program 内直接读取系统时间。

另一项修改是给保存增加命令键,先证明同一 IO 描述执行两次会产生两次端口调用,再在解释器或领域层实现明确的重放协议。保留原重复执行测试作为行为对照,不要删掉它后把“描述可重用”与“业务只执行一次”混成一个概念。