订单金额可以求和,字段错误可以连接,处理日志也可以连接。三种操作都有“两份结果合成一份”的外观,但能否先分组计算再合并,要看合并是否满足结合律;能否处理空输入,还需要一个合适的单位元。仅仅提供名为 combine 的方法不够。

本章使用 Scala 3.3.7 手写最小 Semigroup 与 Monoid。金额用单币种、整数分的 BigInt 表示,错误与日志用有序 Vector。实验枚举有限样本检查结合与单位元,再用减法和 Double 加法构造反例。有限测试提供回归证据,不代替对全部输入的形式证明。

系列导读与能力自测

合并操作必须留在同一个类型中

1
2
trait Semigroup[A]:
def combine(a: A, b: A): A

两个 A 合成一个 A,称为对这个类型封闭的二元操作。例如两份错误向量合并后仍是错误向量;两个金额相加仍是金额。如果操作返回另一个无法继续参与相同合并的类型,就不能直接用这个接口反复归约。

Semigroup 还要求结合律:combine(combine(a,b),c) 与 combine(a,combine(b,c)) 相等。它只改变分组,不交换元素的位置。Cats Semigroup 文档给出这一要求及通用合并实例;本章采用自定义接口把依赖缩到最小。

对日志,先把 parse 与 quote 连起来再加 save,应与先连 quote、save 再放到 parse 后面一致。两边都得到同样的事件序列。若把 quote 放到 parse 前面,那是在交换顺序,结合律没有授予这种变换。

相等的定义需要跟业务一致。当前 Vector 用元素及顺序相等,重复错误也保留;金额则用整数分值相等。如果改成“只看错误集合是否相同”,顺序和重复就不再受保护,某些错误实现会通过更弱的测试。因此测试相等关系本身也是接口设计的一部分。

单位元使空输入有结果

1
2
3
4
5
trait Monoid[A] extends Semigroup[A]:
def empty: A

def combineAll[A](xs: List[A])(using m: Monoid[A]): A =
xs.foldLeft(m.empty)(m.combine)

empty 需要满足左右单位律:combine(empty,a) == a 与 combine(a,empty) == a。金额加法的单位元是零分,向量连接的单位元是空向量。这样 combineAll 可以从 empty 开始折叠,空列表直接得到 empty。Cats Monoid 文档

empty 的名字不表示值缺失。零金额是一份明确的金额,空错误列表是一份合法的“没有错误”,它们与不知道结果不同。若订单没有价格,拿零当作单位元默认进去就可能改变业务语义。Monoid 解决合并的起点,不决定缺席数据的补值政策。

非空错误列表可以连接,却没有仍然非空的连接单位元。在错误分支中要求至少一条原因时,Semigroup 已经足够:只有两边都失败才合并已有错误,不需要制造一个“空失败”。第 18 篇选择 NonEmptyList 正是利用这个区别。

同一个底层类型也可能有不同合并方式。整数加法以零为单位元,整数乘法以一为单位元;最大值运算又需要重新声明值域和单位元。让多个实例隐式竞争会令算法含义不清。金额、计数、倍率等最好用不同业务类型或显式传入实例,不能把一个裸 Int 的默认 combine 当成所有业务的通用答案。

金额使用精确的整数分

1
2
3
4
case class Cents(value: BigInt)
given Monoid[Cents] with
def empty = Cents(0)
def combine(a: Cents, b: Cents) = Cents(a.value + b.value)

该模型固定一种币种,单位为分。BigInt 避免固定宽度整数溢出,合并中也没有每一步舍入。样本允许负数,便于表示有符号调整;若业务对象只允许非负应付金额,应该另设构造约束,不要把所有业务含义压进同一个 Cents。

一百二十五分与二百五十分相加得到三百七十五分。这个结果不涉及小数二进制表示,也没有汇率转换。把币种加入记录后,不能直接把不同币种分值相加;需要按币种分别聚合,或先在明确的报价时点和舍入规则下转换。

BigInt 在机器上仍然受可用内存约束,这不影响本章对已成功计算的小型值进行等式检验。它也没有证明性能优于 Long。工程选型可以采用有界整数并明确溢出政策,只是随后对结合律的讨论必须包含该政策:取模溢出、抛异常和饱和截断具有不同可观察行为。

如果只允许合法金额而 combine 可能因超出上限失败,返回类型就需要表达错误,或合并域需进一步受限。一个会在某些输入上抛异常的函数,不能不加说明地当成对全部 A 都定义的二元运算。真实金额规则常比“选择 BigDecimal”复杂,数值类型仅是起点。

错误与日志保留顺序

1
2
3
given [A]: Monoid[Vector[A]] with
def empty = Vector.empty
def combine(a: Vector[A], b: Vector[A]) = a ++ b

这个实例与元素内容无关。它不需要合并两条错误,也不需要判断日志等级,只负责把右边元素按顺序放到左边之后。元素的领域规则由创建这些值的函数保证,集合实例维护序列结构。

错误样本包括空向量、单个 sku 错误,以及两个重复 quantity 错误。重复样本能发现意外去重;双元素样本能发现意外倒序。若只测空向量和单元素,许多错误实现也可能通过,增加随机次数无法弥补样本结构过于单一的问题。

日志样本包含 parse、quote、save。Vector("a") ++ Vector("b") 与逆序连接不相等,实验专门验证这一点,防止把结合误读为交换。分块合并时可以改变括号,但块的顺序和块内顺序都要保留。

返回日志向量也不等于执行日志 IO。这个实例只创建数据;什么时候把数据输出到终端或写入文件,是另一层行为。后续 Writer 章节会利用这样的 Monoid 合并日志,但资源、输出失败及运行次数仍需额外处理。

两个会破坏分组不变性的反例

减法不满足结合律。(10-3)-2 得到五,10-(3-2) 得到九。它有正确的 (Int,Int) -> Int 签名,却不适合作为任意重新分组的 Semigroup。类型签名无法替代行为规律。

浮点加法更容易造成误判,因为普通数学加法满足结合律。Double 每一步产生可表示的近似值,分组可能改变舍入发生的位置。实验选择 a 为 1e16、b 为 -1e16、c 为 1.0,得到左分组 1.0、右分组 0.0。

1
2
3
4
val a = 1e16
val b = -1e16
val c = 1.0
assert((a+b)+c != a+(b+c))

这个反例直接检查当前 JVM 的数值结果,不把它扩展成“浮点数不能用”。科学计算可以采用误差界、固定归约顺序或更稳定算法;这些选择定义的是另一种验收契约。若使用近似相等,也要说明误差如何累积,以及近似关系是否足以支持计划中的重排。

十进制也可能遇到类似问题。若每次 combine 都按有限精度舍入,分组仍可能影响结果。当前实验通过整数分和不舍入的加法避免该分支,并未运行任意 MathContext 的测试。选择数值表示时应先写业务精度与舍入点,再评价是否允许并行分块聚合。

定律检查怎样选输入

金额样本为负一百、零、一、一百二十五和九百九十九分,共五个值。对每个值检查左右单位元,对全部五的三次方个有序三元组检查结合律。错误与日志各取三个向量,因此各检查二十七个三元组。

测试没有使用随机生成器,所以不存在可记录的随机 seed。失败样本由源码里的确定列表和嵌套遍历顺序决定,可以直接重放。未来若扩大到性质测试库,应保存生成范围、seed 与缩减反例;不能把本章确定样本称为随机性质测试。

对于一个坏实例,应该先找能区分它的样本。例如 combine 总返回左参数,可能满足结合但通常不满足左单位律;combine 反转连接结果,可能在单元素场景躲过检测。定律应成组检查,而且测试输入要能暴露结构差异。

不同错误列表的合并还可能有成本差异。当前使用 Vector 只为明确有序结果,没有计时、分配或大数据实验。能重新分组意味着存在一些语义允许的优化空间,但是否值得并行、何种结构更快,需要单独测量,不能由 Monoid 名称推出性能结论。

分块合并需要保留哪些条件

结合律允许将连续输入分块后各自计算,再按原有顺序合并块结果。对金额,这样的分组不改变精确总额;对日志,块内部及块之间的顺序都必须保持。若并发计算完成后直接按完成先后连接日志,就不仅改变了括号,还改变了元素顺序,结合律无法保证结果不变。

每块为空时,单位元使归约仍然有定义。例如某个筛选条件让一个分块没有金额,它可以返回零分,再与其他块相加。这不是说筛选前缺失的价格可以当作零;一个是已知空集合的汇总,一个是某条记录未知的值。混淆这两个位置会把数据质量问题隐藏到汇总结果中。

同一段 combineAll 也无法检查币种一致性。本章 Cents 明确只有一个币种,若扩展为多币种金额,直接把所有分值相加即使满足整数加法规律,也没有正确业务含义。可以按币种分组,或显式完成汇率转换后再汇总;转换时点、舍入和汇率来源都需要新的约定与测试。

另一个边界是可变累加器。若 combine 修改左边对象并返回同一引用,单次汇总也许得到预期数字,但复用输入或多次检查定律时可能相互污染。当前 Cents 和 Vector 使用不可变值,等式检查不会靠恢复共享对象才能成立。把实现换成可变集合后,除了值等式,还必须重新考虑别名和并发观察。

实验没有执行多线程分块,只检查纯合并与反例。因此“可以重新分组”是由所要求的代数契约说明的允许变化,不是本次运行证明了并行实现安全。真正采用并行归约时,还应验证任务划分、输入覆盖、合并顺序和异常处理,防止算法层面丢块或重复计数。

运行与资料衔接

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

本章源码、公共运行器和结果证据包含精确输入及实测输出。验收不仅要求合法实例通过,也要求减法和 Double 的不等断言成立;若反例意外相等,整个程序会失败。

Scala Typeclass 与组合实例说明外部实例怎样传入通用算法。本章使用同一机制,把重点放在合并操作的闭合性、结合性、单位元及相等关系上。没有为这些最小接口引入 Cats 依赖;引用 Cats 文档用于核对概念,不声称执行了 Cats 的完整定律测试套件。

手算与修改练习

手算题:按日志连接实例,combine(combine([parse],[quote]),[save]) 与右分组结果相同;交换 parse 与 quote 后结果不同。再回答空输入应该返回什么,以及为什么只有 Semigroup 接口无法凭空决定那个值。答案需要同时提到元素顺序与单位元。

修改题:定义非空错误结构 Errors(head, tail) 及 Semigroup,合并后保留左侧全部错误再追加右侧。对单错误、重复错误和多错误执行结合检查,禁止提供伪造的 empty 错误。然后把两次校验结果合并,观察只有两边均失败时才需要这份能力。

另一个可运行修改是把金额实例换成减法,但保留全部原有定律断言。程序应失败,并定位到具体三元组。不要同时修改测试相等关系去迎合坏实现;定律测试的作用就是拒绝这种看似类型正确的替换。