函数式编程20:Monad 定律与 Kleisli 组合,接续计算需要什么保证
能编译的 flatMap 也可能无法可靠组合
一个列表组合器先运行普通 flatMap,再把整个结果反转。它的输入输出类型完全正确:接收 List[A] 和 A => List[B],返回 List[B]。有些单元素测试也能通过。但把一串组合提取成辅助函数以后,结果顺序可能变化。类型正确还不足以支持这种重构,组合还需要满足规律。
Monad 描述一族类型 M 的接续能力及其规则。pure 把已有值表示为 M 中的计算,flatMap 允许后一步根据前一步得到的值选择另一个 M 计算。这个定义既适用于缺席传播,也适用于多结果、环境读取和状态传递。将 Monad 解释成“一个装东西的盒子”,无法说明环境函数为什么也能满足同一规则。
前置是 map、join 与 flatMap的四种查询形式。已有 Scala 高阶类型与组合定律中“Monad 加入 pure 与依赖组合”展示了最小接口与 Option/List 实例,本章承接这个背景,重点增加可观察等价、坏实现反例、Kleisli 组合及 for 的局部展开。
独立实验源码使用 Scala 3.3.7、Cats Core 2.12.0、JDK 21.0.11。执行公共 runner:
1 | |
结果文件保存实际编译运行信息、源码摘要和断言输出。101 次比较是明确的有限回归集,不是对全部整数和全部函数的数学证明。
从普通函数组合到需要接续规则的组合
普通函数 f: A => B 与 g: B => C 可以组合成 a => g(f(a))。f 的输出正好是 g 的输入,不需要额外决策。若 f 改成 A => Option[B],g 仍接收 B,原来的组合立即出现类型缺口:g 不知道怎样处理 None,也不应被迫负责前一步的缺席规则。
将 g 也写成 B => Option[C],仍然不能直接使用普通 andThen,因为 f 输出 Option[B],g 要 B。第 19 篇的 flatMap 正好补上这个连接点:先得到 Option[B],有值时调用 g,缺席时保留缺席。组合结果又是 A => Option[C],可以继续与第三个同形函数连接。
1 | |
这个函数称为 Kleisli 组合的一种具体实现。它组合的是返回 Option 的函数,而不是把函数的输入也改成 Option。业务函数可以继续接收正常领域值;“前一步没有正常值时怎么办”集中由 Option 的 flatMap 处理。
实验取 parse: String => Option[Int] 与 positive: Int => Option[Int]。parse 使用 toIntOption 处理格式与范围,positive 只接受大于零的数。输入 "2" 得到 Some(2),输入 "0" 在第二步缺席,输入 "bad" 在第一步缺席。第二步不需要重复解析字符串,也没有接收到占位的零。
Kleisli 组合中的 K 是名称的一部分,不是一种新效果。把 Option 换成另一个满足相应组合能力的 M,同样可以写 a => f(a).flatMap(g)。至于 flatMap 调用 g 零次、一次还是多次,依赖具体实例。List 会对每个已有结果调用,Reader 会构造共享环境的函数,不能从 Option 反推所有 Monad 都会在失败时短路。
三条定律中的等号要先定义
写出两段表达式并用等号连接之前,需要说明什么算相同。本章纯 Option 数据按分支与内部整数比较;List 按长度、元素、顺序和重复项比较。List(1,2) 与 List(2,1) 不相等,即使转成 Set 后相同。相等关系若过弱,会把错误实现隐藏起来。
函数不能靠对象地址比较是否相等。Reader 应在相同环境下比较输出;State 应在相同初态下同时比较返回值与终态。对有限输入做测试只覆盖被运行的输入,不能从两个环境上的通过推导所有环境上都成立。涉及 IO 时,还需要定义受控运行中的事件、失败与资源观察,不能只比较 IO 包装对象的字符串。
本章定律样本中的函数终止、不读取时钟、不修改外部状态,值域排除 null。若引入一个每次调用都读取随机数的函数,左右表达式在分别运行时可能拿到不同样本,这样的测试首先改变了观察模型,不能立即归罪于 Option 实例。测试框架必须为两边提供可比较的输入条件。
同样,异常不在这份 Option 的结果分支内。一个回调直接抛异常,就没有正常返回 Option。可以为异常增加观察契约,但不能在只比较成功数据的定律测试中忽略它,然后声称已经验证异常行为。声明输入域让定律有明确含义,也暴露测试遗漏。
左单位元:pure 不应给后续计算增加行为
左单位元写为 pure(a).flatMap(f) ≡ f(a)。左边先把 a 放入 M,再通过 flatMap 交给 f;右边直接调用 f。对 Option,pure 采用 Some,因此 Some(a) 有且只有一个成功值,flatMap 调用一次 f,直接得到 f(a)。
这里的 pure 不是“保证参数表达式纯”的运行时检查器。pure(readClock()) 会先执行 readClock,再接收其返回值。定律中的 a 是已有值,不能偷偷把两边的 a 替换成会重复产生不同结果的表达式,然后认为定律应该消除这种差异。延迟求值需要一个明确接收函数或按名参数的构造器。
考虑 List 的错误 pure:把 a 复制两次,返回 List(a,a)。即使 flatMap 完全使用标准实现,左边也会运行两次 f,而右边只运行一次。对于返回单元素列表的 f,结果长度已经不同;对于包含调用探针的 f,还会观察到次数差异。pure 的单位含义要求它加入的结构不改变接续语义。
对 Writer,pure 需要附带空日志;若每次 pure 都追加“created”,左单位元就会多出一条记录。对 State,pure 应返回原状态;若它顺便推进序号,左单位元也会改变 f 的初态。这些实例说明定律约束的是组合的具体行为,而不只是泛型方法长什么样。
右单位元:接回 pure 应保留已有结构
右单位元是 m.flatMap(pure) ≡ m。这里 m 已经是 M[A]。接续函数只把取得的 a 放回相同结构,没有领域计算,因此原来的信息应保留。Option 的 None 仍是 None,Some(a) 仍是 Some(a)。List 则必须保留原来的顺序与重复值。
本章故意损坏的 bind 在每次组合后反转结果:
1 | |
对 m=List(1,2)、pure(x)=List(x),结果变成 List(2,1),与原列表不同。实验断言“不相等”,因此负例通过的意思是成功识别了坏实现,绝不是这个实现通过 Monad 认证。若只测试空列表或单元素列表,反转不可见,错误就会漏掉。
这个反例也说明为什么不能用排序后相等来测试 List Monad。排序会主动抹去被破坏的顺序,重复项若进一步转成 Set 也会消失。测试所选的等价关系必须匹配领域可观察行为;订单候选的优先级往往就在顺序里。
右单位元也可揭露无意的“清洗”。如果一个 flatMap 每次都去重,看上去减少了冗余,但 List(1,1).flatMap(pure) 会丢失一次出现。对普通 List Monad,它已经改变语义。需要集合语义时应选择对应的数据模型,而不是继续承诺列表的规律。
结合律允许换括号,不允许换顺序
结合律写为:
1 | |
左边先把 m 与 f 组合成一个中间计算,再接 g。右边先写出“对一个 a,完成 f 后再完成 g”的函数,再把 m 接到该函数上。两边都按 m、f、g 的依赖顺序处理,区别只在组合分组。提取辅助函数、展开内联调用,常会改变这种括号结构。
对 Option 可以逐分支理解。m 为 None 时,两边都不执行 f。m 为 Some(a),但 f(a) 为 None 时,两边都不执行 g。m 与 f(a) 都成功时,两边都把同一个中间值交给 g。这个推导解释了当前定义为什么合理;有限实验再检查实现是否确实遵循预期分支。
坏 bind 的反转会使不同分组中的反转次数与作用范围不同。实验使用 m=List(1,2),f 把 x 展开为 List(x,x+10),g 把 x 展开为 List(x,-x)。两边结果分别为:
1 | |
输出来自 broken-associativity 的实测记录。差异在顺序,不在值集合。若某次重构把原先的左分组改成右分组,使用这个坏实例的业务就可能改变候选优先级。三条定律把这种潜在影响转成可以独立验证的承诺。
结合律与交换律无关。实验再用 x => Some(x+1) 和 x => Some(x*2):从一开始先加一再乘二得到四,先乘二再加一得到三。两个都是合法的 Option 计算,改变先后顺序当然可以改变结果。不能以 Monad 满足结合律为理由并行或重排一条有数据依赖的链。
101 次比较究竟覆盖了什么
实验的值是整数 -3 到 3。函数集合包含“加一后成功”“零时缺席,其余乘二后成功”和“始终缺席”。m 的集合是 None 加七个 Some。左单位元遍历七个值与三个函数,得到 21 次比较;右单位元对八个 m 得到 8 次比较;结合律对八个 m 与九对函数得到 72 次比较,合计 101。
1 | |
这些输入使成功、起点缺席、中间缺席、末段缺席都参与比较。每条定律的一边有独立参照:左单位元直接调用 f,右单位元直接使用 m,结合律比较两种组织。坏 bind 单独提供测试敏感性检查,避免整个测试只会产生绿色输出。
没有随机生成器,因此本章没有随机 seed 或缩减器输出。反例输入是手工选择的最小可读场景,保存在源文件中。后续性质测试可以扩大函数族和数据范围;增加测试数量依然不能替代适用于全部允许输入的推理。对于递归或大数输入,还要额外考虑终止与溢出条件。
Cats Kleisli 把这种函数组合保存成值
Cats 的 Kleisli[F,A,B] 表示 A => F[B]。实验把 parse 与 positive 分别包装,再用 andThen 组合,与手写 composeK 对照:
1 | |
run 在这里是取得包装函数并应用输入的入口,它没有自动创建线程。封装的价值是把组合行为和相关方法归到一个明确类型中,便于保存、传递和复用。若只有两次局部调用,直接 flatMap 已经足够,不必为了使用 Kleisli 增加一层公共接口。
从能力上看,两个 Kleisli 箭头的连接只需要外层支持 flatMap;表示不做额外工作的恒等箭头,还需要 pure。于是 Monad 的单位元与结合律可以对应到这种组合的恒等与重分组。这种对应有助于理解定律的用途,不要求业务读者先掌握完整范畴论。
Kleisli 的第一个类型参数 F 固定了共有上下文。一个函数返回 Option,另一个返回 Either,无法只靠名称相同的 flatMap 自动连接。必须在边界选择如何把缺席转换成错误,或者用能够同时表达两者的类型。若把所有 Left 都丢成 None,只是为了通过编译,错误原因就会丢失。
for 表达式隐藏的是调用写法
本章选择最简单的两个生成器、无守卫、无模式解构的 for,以避免把版本相关语法细节混在 Monad 定义里:
1 | |
第二步 next(a) 需要 a,所以它位于第一个 flatMap 的回调内部。最后的 a+b 是普通整数,用 map 变换结果。每一条生成器都不等于“调用一次”:m 为 None 时 next 零次;m 为 Some(1) 时 next 一次;若接收者换成 List,则要根据元素数量计算。
实验分别给 for 与显式版本重置调用计数器,断言返回值、次数相同,并断言次数等于 m.size。这样可排除只比较结果却忽略过早调用的实现。假如将 next(a) 提前到外面,既没有 a 可以使用,也改变了原本的依赖结构。
包含 if 守卫时可能需要 withFilter,无 yield 的形式涉及 foreach,可失败模式和中间绑定又有额外翻译规则。因此“所有箭头 flatMap、最后 map”只适合作为本章简单表达式的展开。已有 Scala for 推导式中“守卫需要 withFilter”与“第二个生成器位于第一个回调内部”可继续阅读。这里验证的是 Scala 3.3.7 上两个具体程序的等价,不宣称核查了所有编译器转换。
接口能力与运行时能力分别验收
教学最小接口通常只写 pure 与 flatMap,并由它们定义 map。Cats 的真实 Monad 还要求 tailRecM,用来支持栈安全的 Monad 递归。实现两个方法就自称实现了 Cats Monad,会遗漏库要求;给教学接口另起名称,则能明确它只展示组合的核心。
结合律也不能证明手写深 flatMap 链栈安全。两段表达式在数学结果上等价,不表示 JVM 的调用栈占用相同。调度、取消、资源释放和错误捕获同样不是这三条定律的直接结论。此处代码只有同步有限计算,不报告这些未运行的能力。
Java 的泛型能够实现具体的 Maybe、State 等类型,却不能直接用 Scala 的 F[_] 形式表达“任意一元类型构造器”。统一接口的表达成本与实例本身是否守律应分别判断。没有统一的 Monad 接口,并不会使一个正确的具体 flatMap 失效;有接口也不会使 badBind 自动正确。
定律怎样约束一次真实的局部重构
假设业务原先连续写订单查询、地址查询、配送区查询,现在希望把后两步提取成 delivery(order)。原表达式在取得订单后先 flatMap 地址,再 flatMap 配送区;新表达式在一个回调里完成这两个步骤。这正是结合律的两种括号分组。提取后的辅助函数仍接收订单,不应偷偷读取另一个全局订单编号,否则已经改变了函数输入。
一次安全的重构检查可以先比较三件事:是否仍使用同一种 M,三个领域函数是否保持原样,是否只改变了组合分组。若答案都成立,并且实例守律,就可以用结合律解释结果保持。若同时增加缓存、重试或错误恢复,就不能把整个修改归因于结合律,那些新行为需要独立断言。
比如在辅助函数入口新增一次日志打印,数据结果可能不变,轨迹却多了一条记录。若可观察契约包含日志,原来的等价关系已经不够。又如将失败转换成默认地址,代码仍能接续配送区,但缺席传播规则发生变化,不能因为最终类型还是 Option 就认为重构仅仅是提取方法。
单位律则允许去掉没有增加行为的提升。某一步取得正常值后立即 pure,再接下一个函数,左单位律说明这个中间单位操作可以消去。已有计算最后 flatMap pure,右单位律说明可直接保留原计算。这不是鼓励机械删除所有构造器;如果 pure 的输入表达式还有严格求值时发生的动作,就应先把表达式与已有值区分清楚。
为坏实例选择能暴露问题的样本
空 Option、空 List 和单元素 List 都很容易让测试通过。空结构可能根本没有调用回调;单元素列表不能观察反转;全部返回 Some 的函数族无法检查中间短路。测试集合应针对实例可能丢失的结构挑选样本,而不是只根据业务数据的常见程度抽样。
本章坏 bind 的右单位反例用两个不相同的元素,使顺序变化可见。结合律反例让 f 和 g 都产生多结果,使两层分组都有不同的可反转区段。如果两个函数总返回 Nil,左右都为空;若所有元素相同,某些顺序变化也不可见。反例之所以有意义,在于它主动避免这些退化输入。
也应防止测试实现复用被测错误。若所谓参照答案也调用同一个 badBind,再按同样方式反转,就可能让两边共享缺陷。右单位测试直接拿原列表作为参照,左单位测试直接调用 f,都是为了保留独立预期。结合律必须比较两种不同组织,不能先计算一个变量再把它复制到等号两边。
测试失败时,要先保存最小输入、函数定义和两边完整结果。只保存布尔值 false 无法判断是数据值、顺序还是错误原因不同;只打印集合又会抹去顺序。源码中的确定性输入与证据中的实际输出一起,使这个反例可以被重复检查。本章没有调用自动缩减器,因此不声称这些输入是全局最小反例。
Kleisli 的恒等函数不是普通 identity
普通函数组合的恒等函数是 A => A。Kleisli 组合要求箭头输出 M[A],所以相应的恒等箭头是 A => M[A],也就是 pure。直接拿 identity 连接 A => Option[B] 时,输出类型可能不符合预期;必须先分清此时需要普通值的恒等,还是计算箭头的恒等。
以 parse 为例,先通过 s => Some(s) 再接 parse,应与直接 parse 相同,这是左单位律的函数形式。parse 后接 n => Some(n),也应保持结果,包括解析失败时的 None,这是右单位律。把三个返回 Option 的函数按两种方式接起来,则对应结合律。Kleisli 没有另外发明一组与业务无关的规则,而是把已有规则用于函数组合。
这也解释了为什么“有 flatMap”与“完整 Monad”不是完全同义。可以先有接续能力,再讨论能否构造不增加行为的单位箭头。真实库还会有递归、安全性和继承关系要求。读教学接口时应识别它为了展示哪一部分而删减,而不是根据几行方法声明推断已经包含生产库的所有能力。
对于接收共享配置的函数,Kleisli 还有另一个连接方向:多个步骤可能使用相同输入环境,再根据前值构造下一步。后续 Reader 会具体展开这个过程。此处只需保留一个判断方式:Kleisli 表示函数,Monad 解释函数返回的计算如何接续。无需假设某处必须存在一个装着 A 的集合。
手算与修改练习
手算 composeK(parse, positive)("0"),标出 String、Option[Int]、Int 三个位置。parse 成功产生零,positive 接收这个零并返回 None。再解释 "bad":parse 已经 None,positive 根本没有合法整数输入,因此不执行。答案应包含分支与类型,不能只写最后 None。
修改实验,为 Either[String,A] 复制同样的有限定律检查。pure 使用 Right,函数族加入“负数返回 Left(“negative”)”,m 加入一个已存在的 Left(“start”)。断言左右单位元与结合律同时覆盖错误原因;错误传播后仍应保留 start 或 negative,不能都转成一个空串来获得表面相等。
再给 List 的 pure 写一个故意重复元素的版本,用单元素输入触发左单位元失败。这个反例与本章反转 bind 的职责不同:它证明 pure 的实现也参与 Monad 契约。测试通过的判据是正确实例比较相等、坏实例比较不等,并在输出中保留造成差异的输入。
如果只能背出三条等式,却不能说明提取辅助函数会改变哪一组括号,还需要回到 composeK 的展开。一个有用的自测是把三个查询分别命名,先组合前两个,再组合后两个,最终写出两条表达式。业务步骤的先后不能变化,变化的只应是接续函数的组织位置。
参考资料
- Cats Monad:核对组合操作与 tailRecM 的实际接口要求。
- Cats Kleisli:核对函数表示与 andThen/compose 的能力要求。
- Scala for comprehensions:语言语法入口。本文具体等价式由冻结版本实测支持。
