函数式编程16:Functor与映射定律的适用边界
订单数量可能缺席,也可能来自一组候选订单。对一个确定的整数加一很容易;对 Option[Int] 或 List[Int] 做同样变换,还要决定缺席是否保留、元素是否丢失、顺序是否改变。Functor 把这部分上下文规则从具体计算中分离出来:业务函数处理一个值,实例负责把函数用于上下文中的值。
具有 map 方法的类不一定满足 Functor 定律。map 如果额外反转列表,虽然能编译,却改变了结构。Java Optional.map 会把回调返回的 null 转为空,影响复合映射;回调中的日志也可能改变两种写法的可观察行为。本章用 Scala 3.3.7 手写 Option、List 映射,通过具体反例说明哪些改写需要额外前提。
先把函数与上下文分开
1 | |
F 不是某个具体的元素类型,而是等待元素类型的类型构造器。把 F 选成 Option,签名变成 Option[A] 与 A => B 得到 Option[B];选成 List,结果就是 List[B]。A 和 B 可以不同,因此把数量变成展示字符串并不要求修改承载数量的上下文类型。
只看这个签名,调用方不需要提供“没值怎么办”的分支,也不需要提供列表循环。相关行为属于实例。Option 的 None 没有可映射的值,应保留 None;List 的每个元素都要在原位置变换,空列表仍为空列表。这里“保留形状”是理解这两个实例的直观方式,不是对所有 Functor 的完整数学定义。Cats Functor 文档给出抽象签名与定律;下面的实现和反例是本章的独立实验。
业务函数仍应描述一个总的、可组合的值变换。如果函数在某些输入上抛异常,接口并不会自动将异常转换成 None;如果函数修改共享状态,map 也不会将这些修改变得纯粹。接口只说明调用结构,没有取消宿主语言中的异常、可变对象或空引用。
例如把数量转成金额时,n => n * 125 的输入输出都是 Int,可以用于两个上下文;但 Int 溢出仍按语言语义发生。若要求金额不溢出,应该改用精确数值或显式失败类型,而不是认为 map 的存在已经验证了金额规则。类型构造器与元素函数各自承担的责任要分开审查。
两个实例只改变元素
实验没有直接把整个实例委托给标准库,而是写出分支,便于观察缺席和递归的处理位置。
1 | |
Option 的有值分支使用 Some 而不是 Option 构造器。这是一个有意保留的差别:本章映射不通过构造器把 null 解释成缺席。有限定律测试只输入普通整数和不返回 null 的纯函数,并不为任意 Java 互操作函数提供安全保证。若系统约定禁止 null,需要在外部边界落实约定。
List 的递归先变换表头,再变换剩余部分,最后用相同的连接结构组织结果。对 List(1,2),执行加一后得到 List(2,3),长度和位置都保留。这个递归写法用于展示结构,不是栈安全的生产实现;超长列表应使用经过工程优化的库实现,不能把小样本通过当作大输入可用性证明。
实例可以复用同一个泛型算法。假设算法只需要将每个数量转成字符串,它只依赖 Functor,不需要知道上下文有零个、一个还是多个值。但算法也因此不能随意提取一个 A,更不能凭空产生一个 F[A]。本接口没有 get、empty 或 pure,调用方不能把自己希望获得的能力推断进来。
这能解释为什么 map 不负责过滤。过滤需要额外决定哪些元素消失,而这里的函数只能把 A 转成 B。若通过返回 null 来暗示删除,List 和 Option 可能出现不同的解释,泛型算法就无法维持统一约定。需要删除时应使用明确的 filter 或专门的组合操作,并重新分析它们的结构变化。
恒等律排除隐藏的结构修改
恒等函数返回收到的值。恒等律要求 map(fa)(identity) == fa。对于当前两个实例,这个等号使用 Option 和 List 的值相等:不仅检查元素,还检查缺席、有值、顺序、重复次数等可观察结构。
实验中的错误实现先正常映射,再反转列表。对 List(1,2) 使用 identity,结果是 List(2,1),立即违反恒等律。若测试只有空列表和单元素列表,错误会被掩盖;若测试把输出转成集合再比较,顺序变化也会被掩盖。测试输入和相等关系都决定了反例能否被发现。
恒等律的工程意义不是“没有业务函数时通常不做事”,而是实例不能隐式添加与值变换无关的策略。例如 map 后排序、删除重复项、截断数量,都可能在某些输入上改变结果。即使这些策略符合某个具体页面的展示要求,也应该作为独立步骤表达,而不是放进可供泛型算法调用的 Functor 实例。
本章的错误实现没有被注册为给定实例,只在局部计算结果并断言它不等于输入。这种负向断言明确说明预期失败的是什么:恒等映射不能保持原列表。程序本身仍正常退出,不能把“测试进程失败”与“成功检测到错误实例”混为一谈。
复合律允许合并纯映射
复合律比较先映射 f 再映射 g,与一次映射复合函数的结果:
1 | |
以 List(1,2) 为输入,f 加一,g 乘二。左边先得到 List(2,3),再得到 List(4,6);右边每个元素先加一再乘二,同样得到 List(4,6)。两边比较的是相同顺序下的值,不是比较函数对象地址,也不是比较执行过程中的临时列表数量。
对 None,两边都没有值供函数处理,结果仍是 None。对 Some(3),两边都变为 Some(8)。这些例子解释为什么同一泛型法则能用于不同上下文:实例决定如何沿结构访问值,函数复合决定每个值内部的转换顺序。
注意 f.andThen(g) 是先 f 后 g。若误写成先 g 后 f,上述例子会变为先乘二再加一,输出不同。复合律允许减少映射层次,不允许交换函数顺序。名称“复合”与前一章的结合律相关,但这里的操作对象、签名和等式都不同,不能仅凭术语相近就混用。
本章测试提供四个输入形状和四个整数函数:恒等、加一、乘二、常量零。每个上下文有四乘四乘四,共六十四个复合组合,另检查每个输入的恒等律。Option 包含 None、负数、零、正数,List 包含空、单元素、多元素和重复元素。这是有限回归检查,不是对所有整数、所有列表及所有函数的证明。
常量函数尤其值得保留。它会抹去输入之间的区别,如果实现错误地要求映射必须可逆,常量函数就可能暴露问题。重复元素则用于防止实现隐式去重。测试不只追求覆盖数量,还要让输入分别约束那些最容易被加入的额外行为。
Java Optional 的 null 反例属于 map 复合律
Java Optional.map 对回调返回的 null 使用空值语义。这是官方 API 明确规定的行为,不是实现偶然出错。Java 21 Optional.map
实验从 Scala 调用 Java API,使用 Java Function,避免将两种函数类型或两种 Option 构造规则混在一起:
1 | |
分开执行时,第一次 map 得到 null,Optional 将其转换成 empty;第二次 map 没有值可处理,因此 g 不会运行。合并执行时,f.andThen(g) 在同一次回调内部把 null 传给 g,g 返回字符串 missing,外层 Optional 最终得到一个有值结果。实验断言两边分别为空和 Optional.of(“missing”),实际输出保存于本章结果文件。
这个反例不涉及 flatMap,因此不能称为 bind 的结合律反例,也不能由此断言 Java Optional 的所有组合规律都失败。精确结论是:如果把允许返回 null、又允许接收 null 的 Java 函数一并纳入函数集合,上述 map 复合等式不成立。限制到合适的非空值与纯函数约定后,还需要针对该约定讨论规律。
这里的 g 明确处理 null,不会因空引用异常提前结束,因此反例不是依靠异常打断计算。两边都能正常完成,却返回不同的 Optional 值。这使观察点落在 Optional.map 的 null 归一化语义上,而不是日志、异常文本或运行环境差异上。
在代码审查中,将连续 map 融合成一个 lambda 往往被视为纯格式调整。若其中存在遗留 Java API 的可空返回值,这种调整就可能改变行为。修复方式应从边界契约入手:明确将缺席转换成空上下文,或保证转换函数不返回 null;不能仅以“少了一次遍历”为理由进行机械替换。
副作用会增加相等判断的观察范围
另一个实验使用列表回调记录调用顺序。first 在返回数字前记下 f 加数字,second 记下 g 加数字。两段代码返回的数字列表相同,但事件序列不同。
1 | |
第一种写法先完成整个列表的 first 映射,所以事件为 f1、f2、g1、g2;第二种写法逐个元素完成两次变换,事件为 f1、g1、f2、g2。本章断言这两个精确序列,不用计时结果推测执行顺序。
如果观察只看最终整数列表,两边相等;如果日志顺序也是程序行为的一部分,两边就不等价。这不是给定纯 Functor 实例的反例,而是说明带副作用的回调超出了简单值等式的推理条件。纯函数限制不是为了书写整齐,而是缩小可观察行为,使组合改写能够依据值规律进行。
同样的风险会出现在修改计数器、读取不断变化的配置或消费迭代器的回调中。即使每次返回类型都是 Int,函数也未必只由参数决定。把函数值作为参数传递,不会自动令其成为数学意义上的纯函数;必须审查函数闭包捕获了什么,以及调用过程做了什么。
实际重构时应先列出需要保护的观察:结果值、异常、外部调用次数、顺序、资源生命周期。如果业务要求保护后四项,就不能只拿结果列表相等作为验证标准。本章用可变缓冲区只是为了暴露这个边界,不是在推荐将日志副作用藏进泛型 map。
两层上下文可以分层映射
输入 List(Some(1),None) 有两层结构。外层 List 表示两条记录的位置,内层 Option 表示各位置是否有数量。若把缺席记录删除,外层位置就变化了;若给 None 填零,又改变了内层业务含义。
实验先用 List 的 Functor 遍历每个 Option,再用 Option 的 Functor 给有值数量加一,得到 List(Some(2),None)。外层长度仍为二,第二个位置仍缺席。这里没有 flatten,因为目标不是合并两层,而是在不改变两层含义的条件下改变最里面的值。
从类型上看,内层变换是 Option[Int] 到 Option[Int],恰好可以作为外层 map 的元素函数。若最终变成展示字符串,则内层变换可以是 Option[Int] 到 Option[String],最终类型是 List[Option[String]]。理解每一层的输入输出,通常比反复猜测需要几个 map 更可靠。
这个例子也说明嵌套不是天然的坏味道。列表位置与数量缺席是两个不同维度,合并它们会丢失信息。当然,若业务本来只需要所有存在的数量,选择删除缺席项是合理的,但那应该被命名为过滤或收集,而不是声称仍保持原来的结构。
需要警惕对嵌套映射的性能外推。实验没有做基准,没有测分配,也没有比较库优化;它只断言语义形状。生产代码选择组合实例或直接标准库调用,可根据可读性和真实性能证据决定,不能把类型上可组合等同于运行时零成本。
对包含函数值的上下文,默认对象相等未必能表达希望检验的语义。本章只用整数结果检验法则,避免直接比较两个 lambda 的对象身份。若扩展到函数结果,需要在明确的输入域上比较输出,并保留这种观察范围限制,不能把函数引用相等当作函数行为相等。
泛型能力的限制可以帮助审查
假设一个函数只声明需要 Functor[F],却在内部调用数据库来补一个空值,那么这个函数还依赖数据库能力,只是没有在签名中体现。map 的抽象不会替它解释补值时机,也不会处理查询失败。将补值政策单独放在适当的组合层,才能让 Functor 负责的部分保持清晰。
同理,只有 Functor 时不能实现一个对任意 F 都有效的取值函数。Option 可能为 None,List 可能为空或有多个值,函数没有通用依据选出一个 A。强行规定取第一个会引入对某些上下文才成立的结构假设,也会丢掉其他值。接口缺少提取能力不是功能漏洞,而是约束泛型算法不做超出约定的事。
map 返回 F[B],并不意味着 F[B] 与输入共享或不共享内存。不可变列表可以共享部分结构,也可能重新分配节点;当前等式只比较值行为。若调用方持有内部可变对象,即使外层结构不可变,元素函数修改对象仍可能让输入观察发生变化。需要纯函数推理时,应同时审查载荷的可变性,而不是只看容器是否不可变。
例如列表里每个元素都引用同一个可变订单对象,回调原地修改数量后返回该对象。输出列表长度不变,看似保留形状,但第一次映射已经改变了后续调用读到的值。再次对“原输入”执行恒等测试,观察对象已经不是测试开始时的状态。这样的实验需要独立构造样本,否则测试之间的污染会掩盖问题。
检查映射重构时保留类型中间值
将一条长映射链拆成具名中间值,可以先确认每一步的输入输出是否符合预期。名称不应仅表示 step1、step2,而应说明该值已经完成的变化,例如已规范化文本或已计算金额。如果某一步返回 Option[B],map 后的类型会变成 F[Option[B]],这通常是在提醒开发者结果又多了一层上下文。
多一层并不一定错。例如 List[Option[B]] 可以保留每条输入的缺席信息;如果需求改成只留下成功值,则需要另一种明确操作。不能因为嵌套类型看起来复杂,就先调用 get 或过滤,再补充解释。先决定需要保留哪些信息,再选择改变结构的方法,类型推导才能服务于业务判断。
对 Java Optional 的映射链,显式写出中间 Optional 还有助于定位 null 在哪一步被归一化。若第一步已经 empty,后面的函数不会取得 null;若将函数提前复合,null 可能在回调内部继续传播。检查中间类型不够,还要检查对应 API 的值语义,这正是本章反例要保留的边界。
对于可重复执行的纯输入,可以把重构前后实现并排运行,比较完整结果,同时加入会触发空值、重复值和顺序差异的样本。若涉及外部动作,不应在生产环境简单运行两次来比对,而应通过受控替身记录调用或将纯计算部分抽出。测试设计必须避免为了验证等价而重复实际扣款、写入或发送。
法则测试失败后怎样缩小原因
首先确认失败发生在实例还是元素函数。把回调换成 identity,如果仍然失败,通常应优先检查实例是否改变形状或值的相等定义;如果 identity 通过而复合失败,再检查函数是否返回 null、抛异常或读取外部状态。这个顺序不是完备诊断算法,但能避免一开始就把所有差异归咎于编译器优化。
其次保留最小反例。反转列表用两个不同元素就够了,Optional 只需一个有值输入和两个明确处理 null 的函数。反例越小,越容易看清是哪条契约被违反。添加大量随机数据不能替代对一个已知失败输入的解释,也不应在复现资料中只留下“某次随机运行失败”。
最后检查测试是否遗漏了外层结构。本章比较 List 的顺序和重复项,比较 Option 的分支,比较副作用事件的精确序列。若把这些检查都改成最终数值之和,多个错误实现可能给出相同总和。好的观察应与准备保护的重构相对应,不是选一个最容易通过的汇总数字。
这些诊断步骤不会把本章的有限测试提升为证明。它们的作用是让发现的差异能够被定位、解释并在后续修改中保留。真正需要论证某个实例对全部输入满足法则时,还需明确函数与值域的数学模型,不能将宿主语言中的所有可执行回调一概当作纯函数。
实验、边界与练习
完整可运行实现见 Main.scala。在仓库根目录运行 node examples/functional-programming/run.mjs 16,公共运行器复制源码到临时目录,使用固定 Scala 与 Java 环境执行;本次环境、命令、源码哈希和断言输出见 result.json。所有 PASS 都对应源码中的布尔断言,没有性能结论或无限输入上的证明。
已有文章 Scala 高阶类型与组合定律介绍了高阶类型和定律的整体位置。本章只展开协变映射,额外用 null 与副作用两个具体观察限定可替换性,不提前把 map 推广成依赖计算或错误累积。
手算任务:令输入为 List(Some(2),None,Some(2)),内层函数为转字符串后添加单位。写出每一层 map 的完整类型以及最终值,并解释为什么两个相同数量仍占据两个位置。接着用反转版 map 分别处理单元素与双元素列表,说明前一种测试为什么不足以发现缺陷。
修改任务:为实验添加一个有标签的 Box[A],标签用不可变字符串表示,map 只能修改值。检查恒等与复合,并增加一个故意修改标签的实现,使负向测试明确失败。不要仅比较 Box 中的 value;标签是上下文的一部分,忽略它会让测试放过错误实现。
进一步审查一个现有 Java 映射链时,应逐个检查回调是否可空、是否抛异常、是否有外部观察,再决定能否融合。若无法给出这些契约,保留原顺序往往比根据方法名推断定律更可靠。这里要维护的是可说明的行为边界,而不是某种固定写法的数量。

