连续导入两条订单,第二条必须使用新的序号

导入程序保存下一个可用编号与已接受编号列表。初态是编号十、列表为空,接受两条订单后,应返回十与十一,终态的下一个编号变为十二,列表为十、十一。若第二次分配又拿初态来执行,就会重复使用十,即使函数签名和代码缩进都没有异常。

State 将这种“计算结果加新状态”表达成函数。它不要求通过共享变量修改当前状态,而是接收一个状态值,返回结果和新的状态值。组合器负责把前一步返回的新状态传给后一步,业务函数负责每一步的具体转换。

前置是 List 与 Reader 的组合。Reader 在同一次运行中共享原环境;State 在连续步骤之间传递更新后的状态。这一处差别直接体现在 flatMap 的函数展开中,不能仅凭两者都叫 run 就认为行为相同。

已有 Scala ADT 与 enum中“命令是请求,状态是已经成立的事实”说明了领域状态与事件建模。本章的 State 是一个组合抽象,类型参数 S 可以保存领域状态,但名为 State 的抽象本身不会验证哪些订单迁移合法。旧文的付款、发货模型仍归原系列,这里只做序号与接受列表实验。

完整源码使用 Scala 3.3.7、Cats Core 2.12.0 与 JDK 21.0.11。用公共入口运行:

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

结果记录包含初终态断言、重放、Cats 对照及错误传递反例的实际输出。

先用普通函数显式传状态

定义 ImportState(next,accepted)。分配编号时,结果是当前 next,新状态的 next 加一;接受编号时,结果不需要携带业务值,因此使用 Unit,新状态把编号追加到 accepted。所有数据都不可变,旧状态值可以继续用于断言或另一条计算。

1
2
3
4
5
6
7
case class ImportState(next: Int, accepted: Vector[Int])

val allocate: St[ImportState,Int] =
St(s => (s.next, s.copy(next=s.next+1)))

def accept(id: Int): St[ImportState,Unit] =
St(s => ((), s.copy(accepted=s.accepted :+ id)))

去掉 St 外壳,allocate 就是 ImportState => (Int,ImportState)。accept(id) 是 ImportState => (Unit,ImportState)。元组的第一项是这一步对外提供的结果,第二项是供后续步骤继续使用的状态。两个位置不能交换,即使某些实验中它们恰好都能用整数表示。

手写两次操作时,需要先运行 allocate(s0) 得到 (id,s1),再运行 accept(id)(s1) 得到 ((),s2)。编号 id 与状态 s1 承担不同职责:前者用于决定接受哪个编号,后者包含分配已经推进的 next。只传 id 而忽略 s1,会丢失一次状态更新。

这与返回一个可变对象后继续修改它不同。这里状态变化由新值表示,s0 不会因为 s1 出现而改变。实验使用 case class、Int 与不可变 Vector,所以这份状态没有嵌套可变字段;换成 ArrayBuffer 后不能继续声称旧状态稳定,需要重新检查别名。

State 的 flatMap 必须使用 s1

1
2
3
4
5
6
7
8
9
10
case class St[S,A](run: S => (A,S)):
def flatMap[B](f: A => St[S,B]): St[S,B] =
St(s0 => {
val (a,s1) = run(s0)
f(a).run(s1)
})
def map[B](f: A => B): St[S,B] =
flatMap(a => pure(f(a)))

def pure[S,A](a: A): St[S,A] = St(s => (a,s))

新 State 接收 s0。当前 run 产生 a 和 s1;f 根据 a 选择下一段计算;下一段计算再以 s1 为输入。最终返回的 (b,s2) 就是整段组合的结果与终态。闭包里没有把 s1 写回某个全局变量,它只作为普通参数流动。

pure 产生已有结果而保留输入状态。map 用 pure 实现,因此只变换结果 A,不额外改变状态。不过当前 State 自己已经可能改变状态:对 allocate.map(_.toString),分配仍会推进 next,只是结果编号变成字符串。说“State.map 不改状态”应理解为 map 不增加新的状态修改,不能理解为它撤销被映射计算的状态变化。

St[S,A] 的 A 可以与 S 完全不同。一个读取状态的操作可返回布尔值,一个修改状态的操作可返回 Unit,一个分配操作返回编号。不要把每一步都写成 S => S 后再到外部猜测业务结果;保留独立的 A,才能让下一步根据前一步的结果选择工作。

两次导入的值与状态分别移动

1
2
val one = allocate.flatMap(id => accept(id).map(_ => id))
val two = one.flatMap(a => one.map(b => (a,b)))

one 先获得 id,再接受 id。accept 的结果只有 Unit,所以最后 map 把此前的 id 作为业务结果返回。这个 map 不重新分配编号,也不恢复旧状态。two 第一次运行 one 得到 a,第二次 one 得到 b,最后用 map 组成 (a,b)。

从 ImportState(10,Vector.empty) 开始,可以手工跟踪四次状态转换:分配十后 next 为十一;接受十后列表为 [10];分配十一后 next 为十二;接受十一后列表为 [10,11]。two 的结果是 (10,11),终态是 ImportState(12,Vector(10,11))。

源码将整个结果与终态作为一个元组比较,避免只检查结果编号正确却漏掉 accepted 列表,或者只检查 next 正确却漏掉重复编号。再次从相同初态运行 two,得到相同元组。这里的“重放”指纯内存转换可以重复计算,不意味着真实订单写入数据库两次也安全。

State 程序值 two 本身没有保存“已经运行到十二”的隐藏位置。运行后若要继续第三次导入,调用方必须拿到前一次终态并将其传入下一次 run。再次传原始初态就会重新得到十与十一,这是纯函数语义,不是状态丢失的缺陷。

Cats 的元组顺序需要明确适配

本章教学表示采用 S => (A,S),Cats 文档使用 S => (S,A),实际运行还通过 Eval 取得结果。因此 Cats 的 run(initial).value 返回终态在前、结果在后。比较两份实现时,实验显式调整元组顺序。

1
2
3
4
5
6
val catsOne: State[ImportState,Int] = State(s =>
(s.copy(next=s.next+1, accepted=s.accepted :+ s.next), s.next)
)
val (s,a) = catsOne.flatMap(x => catsOne.map(y => (x,y)))
.run(initial).value
assert((a,s) == expected)

catsOne 把一次分配与接受合成一个状态转换,手写 one 则用两个步骤组合。两者在本章无失败的模型中有相同结果与终态。这个对照检查的是指定输入下的行为,不是声称两份代码内部执行路径相同。

如果加入失败,例如分配成功后接受被拒绝,两种组织还需要明确中间状态是否保留。无失败时合并相邻步骤容易成立,有失败时就可能涉及事务边界。不能拿当前成功对照作为以后任意错误恢复实现的证明。

使用旧状态的 bind 会制造重复编号

实验故意将第二步的输入从 s1 改成 s0:

1
2
3
4
5
6
7
def badBind[A,B](m: St[ImportState,A])(
f: A => St[ImportState,B]
): St[ImportState,B] =
St(s0 => {
val (a,_) = m.run(s0)
f(a).run(s0)
})

这个实现仍然返回正确形状的 St,编译器无法从类型看出它丢弃了新状态。把 one 接到另一个 one 时,两次都从十开始,正确结果 (10,11) 不再成立。stale-state-negative 断言用不相等识别这份错误组合。

这个失败与并发无关,单线程即可稳定复现。增加锁不能修复一个每次显式传错状态的函数;应先修正参数传递。若业务真正涉及两个线程修改外部序号存储,则还需原子分配或事务机制,纯 State 只描述每个计算应该怎样转换数据。

State 也不会自动回滚。类型 S => (Either[E,A],S) 即使业务结果是 Left,仍然返回一个 S;选择保留还是恢复初态,需要写在函数中。若改成 S => Either[E,(A,S)],Left 时没有终态可返回,语义又不同。Transformer 的顺序会影响这种信息结构,不能用一个 State 名称掩盖。

定律比较结果与终态

左单位元要求 pure 不改变传给 f 的状态。右单位元要求把已有结果重新 pure 后保留已有终态。结合律两边都必须按 s0、s1、s2 依次传递状态,不能让括号分组决定哪次更新被保留。

实验采用两个初态:ImportState(10,Vector.empty) 和 ImportState(0,Vector(99))。第二个初态确保已有 accepted 前缀不会被新组合清空。f 继续分配编号,g 接受计算结果;比较时检查完整 (A,S),从而覆盖结果计算与累计列表。

若只比较 A,某个把终态重置为空的实现可能漏检。若只比较 S,又可能漏掉错误的返回编号。观察模型应与 State 的两个输出分量一致。有限初态测试支持当前实现与这些路径,不能证明任何自定义 S 都深层不可变,也不能证明任意回调纯粹。

共享可变版本则将 next 放在变量 shared 中,两次 mutableNext 返回不同编号。它并非不能使用,但要重复测试必须显式重置共享状态,且测试顺序会影响结果。State 把这项输入依赖暴露出来,便于隔离与重放;它没有使程序完全不需要状态。

组合模型的适用边界

本例序号使用 Int,小范围内没有溢出。实际长寿命编号需要更大的值域、溢出策略及唯一性约束。这里也没有从数据库读取已接受订单,没有幂等键与重复提交检查,因此不能将实验当作生产导入系统的完整实现。

教学 St 的 flatMap 用普通函数调用嵌套,深链可能占用调用栈。本章只组合短程序,不声明它具备 Cats 那样的栈安全机制。Monad 规律保证合适观察下的组合一致性,运行时栈和分配成本仍需单独验证。

状态只有一两步时,直接写 val (a,s1)=... 很清楚。很多步骤重复相同传递模式,或者需要通用遍历组合时,State 才能明显减少漏传、错传状态的机会。引入之前应能手工展开其 run,否则一个长 for 容易让状态变化位置变得更难查。

状态边界与程序边界不必相同

one 和 two 是可复用的转换描述,真正选择初态的是调用边界。若程序需要连续处理多个批次,可以把上一批次的终态交给下一批次;若需要比较两种导入策略,则可以把同一个初态分别交给两个程序。两种用法都合理,区别是调用方是否延续状态,而不是 State 对象是否已经被使用过。

这个性质对排查状态错误很有帮助。发现 accepted 缺少某个编号时,可以记录作为普通数据的初态和输入,在不访问外部存储的情况下重算。但只有当前转换确实纯粹时,这种重放才稳定。如果转换内部读取时钟、随机数或可变集合,就必须把这些依赖也作为输入或另行建模,不能仅保存一个顶层 case class 就声称完整重放。

把状态拆成多个字段后,还需注意不变量。本例 next 应领先于新分配编号,accepted 保留已接受列表;通用 St 并不检查它们之间的关系。任何业务函数都可以构造 next 倒退的新状态,flatMap 仍会忠实传递这个错误值。组合器防止的是传递模式中的重复代码,不替代每个状态转换的领域验证。

因此测试可以分成两类:对 allocate、accept 等单步测试领域规则,再对 flatMap 测试新状态是否正确传到下一步。两类失败的位置不同。前者需要修复状态更新公式,后者需要修复组合实现;盲目在调用方多传一次参数,可能掩盖而不是解决问题。当前实验的完整终态断言与旧状态反例分别覆盖了这两种关注点的一部分。

手算与修改练习

手算从 ImportState(3,Vector(1)) 运行 two,结果为 (3,4),终态 next 为五,accepted 为 [1,3,4]。旧前缀一必须保留。再判断两次独立 two.run(initial) 是否应得到不同编号:答案是否,因为两个调用输入相同,程序没有保存全局进度。

修改实验,增加 acceptIfEven(id),偶数被接受,奇数返回拒绝信息。先明确契约:编号一旦分配就消耗,即使接受失败也保留推进后的 next。用 St[ImportState,Either[String,Int]] 表示结果,分别检查偶数成功、奇数拒绝、终态推进和 accepted 列表。返回 Left 本身不会让普通 State.flatMap 短路,后续是否继续需要显式决定。

如果改题为“拒绝就恢复编号”,应新增另一套断言,要求失败终态等于初态。两套需求不能混在同一通过条件中。这个修改练习的目的,是让错误和状态的组合语义成为显式选择,而不是从 State 这个名称猜测事务行为。

参考资料

  • Cats State:核对函数表示、map/flatMap 和 run 的元组顺序。
  • Cats Monad:核对核心组合规律对应的能力与库的额外要求。