价格相同,计算记录也必须相同吗

一个报价计算先取得单价一百,再乘数量二,最后加附加费五。结果是二百零五。如果测试只断言这个数字,就无法区分“按数量计价后增加附加费”和某段直接返回二百零五的代码。某些场景还需要一份计算说明:经过哪些规则、命中了哪些折扣、为什么没有使用另一个价格。这份说明可以成为返回值的一部分,不必在计算过程中直接写到控制台。

Writer 表达的就是结果与附加记录的组合。它的重点不在记录使用字符串还是结构化对象,而在连续计算如何合并各自生成的记录。前一步给出结果,后一步根据这个结果选择计算;两步记录按约定的运算合并。业务步骤不需要接收并手动传递整个历史记录。

前置是 State 与显式状态传递。State 将新状态交给后一步;本章普通 Writer 回调只得到业务结果,不会自动读取此前的日志。把两者都理解为“顺便带上一些数据”,会漏掉这个输入方向上的区别。

已有 Scala map、flatMap 与 fold介绍了集合转换与归约。本文借用归约时的结合运算来解释日志合并,但不把 Writer 当作另一种集合遍历工具。报价步骤之间仍然有数值依赖,记录只是与结果一起返回的另一部分数据。

先写一个结果加记录的类型

实验把记录固定为 Vector[String],减少类型参数对阅读的干扰:

1
2
3
4
5
6
7
8
9
case class W[A](value: A, log: Vector[String]):
def flatMap[B](f: A => W[B]): W[B] =
val next = f(value)
W(next.value, log ++ next.log)

def map[B](f: A => B): W[B] =
W(f(value), log)

def pure[A](a: A): W[A] = W(a, Vector.empty)

W[Int] 的值不只是一个整数。比较两个 W 时,实验同时比较 value 和 log。只比较 value 会把丢日志、重复日志、调换日志顺序等错误全部忽略。相等关系必须覆盖调用者能够观察的内容,这也决定了后面如何检查 Monad 定律。

map 只转换业务结果,记录原样保留。flatMap 则先用当前结果调用 f,取得新的结果和新记录,再将旧记录放在新记录前面。这里没有“从容器里取值后再装回去”的统一运行机制;真正需要保留的规则是记录合并方式。若直接返回 next,代码仍然能得到正确价格,但第一段记录已经丢失。

pure 创建没有新增记录的计算。它不是“记录一次对象创建”。纯粹把一个已有结果纳入计算流程,不应凭空制造业务事件,否则加入或删除一个 pure 就会改变可观察的日志,单位律无法成立。记录创建动作当然可以是合法业务需求,但那应当是一个明确的步骤,而不是 pure 的定义。

把三段报价按类型接起来

实验中的业务函数很小,便于手算每个中间值:

1
2
3
4
5
6
7
8
9
val quantity: Int => W[Int] =
n => W(n * 2, Vector("quantity"))
val surcharge: Int => W[Int] =
n => W(n + 5, Vector("surcharge"))

val unit: W[Int] = W(100, Vector("unit"))
val quote: W[Int] = unit.flatMap(quantity).flatMap(surcharge)

assert(quote == W(205, Vector("unit", "quantity", "surcharge")))

第一次调用 quantity 时,传入的是一百,不是 W,也不是当前记录。quantity 返回二百和自己的单条记录。第一次 flatMap 的结果因此是二百与两条记录。随后 surcharge 接收二百,返回二百零五与附加费记录;第二次 flatMap 得到三条记录。日志顺序来自组合规则,不需要业务函数知道自己处在第几个步骤。

如果改成 unit.map(quantity),类型是 W[W[Int]]。外层保存 unit 记录,内层保存 quantity 记录。map 没有承诺合并这两份记录,不能因为内外类型名称相同,就认为日志会自动连在一起。与第十九章的 Option 不同,此处展平还必须选择合并运算;直接取内层会损失外层记录。

如果把 surcharge 提前,最终结果变成二百一十,而记录变成 unit、surcharge、quantity。Writer 不会使业务运算可交换。它仅在明确的结合规则下让括号重组保持含义,并不允许重新排列有依赖的步骤。日志本身也通常不交换:先验证后计价与先计价后验证是两份不同的说明。

为什么日志需要 Monoid

把 Vector 换成任意 L 后,flatMap 需要一个 combine: (L, L) => L。单独实现合并所需的是结合运算;如果还要提供 pure,就需要一个空记录 empty。结合律与左右单位律合在一起,正是 Monoid 的要求。Cats 的 Writer 文档区分了合并所需的 Semigroup 和空值所需的 Monoid;run 返回日志与业务值组成的元组。Cats Writer

这并不意味着任何叫日志的类型天然有合适的 Monoid。字符串连接可以结合,空字符串可以作为单位元,但没有分隔符时,"ab" + "c" 与 "a" + "bc" 会给出相同文本,失去事件边界。结构化的 Vector 能保留每条记录边界,之后再决定如何显示。合并运算的代数性质与记录是否足够表达业务,是两个需要分别检查的问题。

日志也可以不是文字。若只关心触发规则的数量,可以使用整数加法与零;若关心命中的不同规则,可以使用集合并集与空集;若关心顺序与重复次数,则集合不合适。选择集合后,同一规则触发两次和一次得到相同记录,这不是 Writer 的缺陷,而是选定表示主动丢弃了信息。

也不能把“合并后只保留右边”随便配上一个空值,就宣称得到了合适的 Monoid。对所有记录直接返回右参数,通常会破坏右单位律:把非空日志与空日志合并会丢掉原日志。类型检查只要求两边都是 L,不会替作者检查代数性质。

定律检查必须包括记录

实验对整数负二到二逐个检查左单位、右单位和结合律。业务函数分别生成 quantity 与 surcharge,已有计算带 start 记录。这是有限样本检查,不是所有输入上的证明。它能捕捉本次实现的常见接线错误,但不能替代对合并规则的分析。

左单位要求 pure(a).flatMap(f) 与 f(a) 相同。数值部分直接来自 f;记录部分是空记录与 f 的记录合并,因此空记录必须是左单位元。右单位要求 m.flatMap(pure) 与 m 相同,对应原记录与空记录合并不变。两条性质分别限制了 pure 与 combine 的搭配方式。

结合律比较 (m.flatMap(f)).flatMap(g) 与 m.flatMap(a => f(a).flatMap(g))。在本例中,两边都依次计算一百、二百、二百零五,日志只是在 (unit ++ quantity) ++ surcharge 与 unit ++ (quantity ++ surcharge) 之间改变括号。Vector 连接保持相同顺序,因此这次重组不改变记录。

为了确认测试不是只看数字,实验还构造错误的 pure:返回原值但附带 Vector("created")。以一为输入,左边得到二和 created、quantity 两条记录,右边得到二和 quantity 一条记录。值相同,完整 W 不同。断言要求这两个结果不相等,直接暴露错误单位元。

另一个否定断言检查 a 接 b 不等于 b 接 a。它不表示结合律失败,而是明确拒绝“满足 Monad 定律就能交换步骤”的误读。结合、交换和幂等是不同性质,只有具体业务和具体实例允许的变换才可以使用。

对照 Cats 时注意元组顺序

教学 W 的字段顺序是 value、log;Cats 的构造和 run 展示的是日志在前、结果在后。实验用明确字段比较,避免两个分量碰巧类型相同而交换后仍能编译:

1
2
3
4
5
6
7
8
import cats.data.Writer
import cats.syntax.all.*

val library = Writer(Vector("unit"), 100)
.flatMap(n => Writer(Vector("quantity"), n * 2))
.flatMap(n => Writer(Vector("surcharge"), n + 5))

assert(library.run == (quote.log, quote.value))

此处 Writer 的记录合并能力来自 Vector 对应的实例,不是编译器识别了变量名 log。换用另一个 L 时,需要重新确定该类型的合并语义。业务代码可以继续使用 flatMap,但记录去重、顺序和成本可能已经改变,因此不能只做“替换类型后能编译”的检查。

这里的 run 是解开一个已经构造的纯值,不是启动日志上传。名称相同不代表与 State.run、Reader.run 或未来 IO 的运行方式相同。Reader.run 提供环境,State.run 提供初态,Writer.run 暴露两个分量。阅读代码时应先看签名,再判断是否发生求值或外部效果。

连接成本不能藏在抽象名称后面

把日志作为不可变数据返回,有利于测试和组合,但不会消除存储成本。实验刻意用不可变 List 的尾部追加建立一个朴素模型:每次 acc :+ i 都要处理已有前缀;程序累计每次追加前的长度。追加一千项时,这个前缀长度总和是零到九百九十九之和,即四十九万九千五百。

这不是耗时基准,也不是分配器统计,更不是 Vector 或 Writer 的统一复杂度结论。实验中的计数变量只记录模型中的前缀元素数量。它说明,如果把这一种 List 追加方式当作日志组合,反复追加会产生明显的累计工作量。真实耗时还受实现、分配、优化与运行环境影响,本章没有测量这些指标。

同一个实验用 Cats Chain 逐项 append,并断言最终转成 List 后与朴素版本内容相同。这个对照验证了更换记录表示没有改变本次序列结果,不提供“快多少倍”的结论。选择 Chain、Vector 或其他表示,应结合追加模式、最终消费方式与实际测量,而不是因为它们都能提供合并操作就忽略成本。

还有一种更容易遗漏的成本:记录的内容。若每一步保存完整订单快照,即使连接本身合适,整条流水线仍可能保留大量对象。只记录规则编号和必要解释,与保存所有输入字段,不是同一个内存需求。Writer 的类型不会自动限制记录大小,也不会替业务脱敏。

返回记录与写外部日志是两件事

本章的 Vector 是普通数据。构造完成后,可以直接断言、转换成页面说明,或者交给边界代码输出。单元测试无需截获标准输出,也无需模拟日志服务器。记录的产生与发送被分开,因而可以独立决定哪些记录需要保留。

但这不等于外部日志一定可靠。把 Vector 写文件时仍可能失败,把记录发往远端仍可能重试、重复或丢失。Writer 没有提交、持久化、取消和资源释放协议,不会把“记录已作为返回值生成”升级为“记录已可靠送达”。需要可靠审计时,应另外设计存储与业务变更之间的一致性关系。

失败时是否保留记录,也要看类型层次。实验构造 W[Either[String, Int]],结果为 Left,日志为 checked。这两个分量同时存在,因而可以保留失败前的说明。反过来使用 Either[String, W[Int]],Left 分支中根本没有 W,除非把记录也放进错误类型,否则无法从该值取出日志。

普通 W 的 flatMap 并不认识内层 Either。对 W[Either[E, A]] 调用 flatMap,回调收到的是整个 Either,不会自动按 Left 短路。这正是后续 transformer 要解决的组合问题:保留哪一层行为,在哪一层决定是否继续,必须写出明确规则。

手算与修改练习

先手算 W(3, Vector("a")).map(_ + 1).flatMap(n => W(n * 2, Vector("b"))) 的两个分量,再写出 map 回调与 flatMap 回调的类型。接着将第一个 map 改成返回 W(n + 1, Vector("m")) 的函数,只使用 map;说明此时为什么出现两层 W,以及两份记录各自位于哪一层。

修改实验时,把 String 记录改成 case class Rule(name: String, before: Int, after: Int)。为数量和附加费各保存一条结构化记录,保持最终价格二百零五,并断言 before、after 能连接起来。再故意交换两步,要求测试同时指出价格与记录顺序的变化,不能仅断言记录条数为二。

第二个修改是给报价增加一个拒绝条件。先选择 W[Either[E, Int]] 还是 Either[E, W[Int]],写明拒绝后是否需要保留计算说明,再编码。不要为了省一层类型而悄悄丢掉审计要求;也不要把所有记录都塞进错误字符串后失去结构。这个选择检验的是数据语义,而不是 for 表达式能否写得更短。

实验与参考

执行 node examples/functional-programming/run.mjs 23。实验的七组 PASS 覆盖结果与顺序、Cats 对照、五个输入的定律检查、错误 pure、不可交换、追加模型及失败记录。所有检查均针对本章模型,没有外部日志服务或性能基准。

完整 Scala 源码 与 真实运行证据 对应同一实验;证据包含环境、源码哈希、命令、退出码和断言输出。公共 runner 在临时目录编译,不要求在博客目录保留 class 或 Scala 缓存。

官方阅读材料为 Cats Writer 与 Cats Monad。本文的报价数据、错误 pure 和追加计数是教学实验,不是这些文档提供的生产案例。