函数式编程19:map、join 与 flatMap,嵌套从哪里产生
查到订单以后,为什么还不能直接查配送区
订单查询返回 Maybe<Order>,地址查询接收 Order,返回 Maybe<Address>,配送区查询又接收 Address,返回 Maybe<String>。每一步都可能缺席。订单不存在时,没有订单可以交给地址查询;地址不存在时,也没有地址可以交给配送区查询。这条依赖关系决定后续函数能否调用,不能靠给空值补一个默认订单来消除。
本章采用内存中的合成查询,正常路径得到配送区 Z1。实验通过参数分别让第一、第二、第三步缺席,观察最终结果和三项调用次数。它不访问数据库,也不模拟异步请求。这样可以把关注点限定在组合规则上:同一业务链条分别写成嵌套分支、嵌套 map、map 后 join,以及 flatMap,比较改变写法以后究竟保留了什么。
前置是能够读懂函数参数与返回值、理解 Maybe 的有值和缺席两个分支,以及 map 对普通结果的变换。Scala map、flatMap 与 fold中“flatMap 连接每项贡献的结果段”已经解释集合展开。本章承接其类型分析方法,但改为逐步查询;多结果列表展开不能替代这里的缺席传播。
全部代码位于 Java 实验与 Scala 实验。固定 JDK 21.0.11、Scala 3.3.7、Scala CLI 1.9.1,使用公共运行入口执行:
1 | |
本次结果记录保存源码摘要、实际命令、退出码和两种语言的断言输出。正文中的数值来自这个小输入域,不代表外部服务的行为。
先把三个函数的类型排清楚
Maybe<A> 表示可能得到一个 A。类型参数 A 是已有结果的类型;Maybe 是形成这种结果类型的规则。Maybe<Order> 与 Order 不同,因为前者还可能表示缺席。把它交给只接收 Order 的函数,需要先处理缺席分支,而强制取值会把这个问题转成潜在异常。
| 函数 | 输入 | 输出 | 缺席的含义 |
|---|---|---|---|
| order | 订单编号 Int | Maybe |
没有对应订单 |
| address | Order | Maybe | 订单没有可用地址 |
| zone | Address | Maybe |
地址没有配送区 |
为了让 Java 实验保持短小,手写 Maybe 使用 Optional 保存分支,自己实现 map 与 flatMap。它限制值为非 null、回调对象为非 null,普通变换返回非 null,依赖变换返回一个非 null 的 Maybe。这个限制是教学定义的一部分,不意味着 Java 类型系统已经禁止了所有 null。外部系统输入仍应在边界转换。
1 | |
两个方法都把分支判断集中到实现内部。map 在有值时调用 f,再把普通结果放入 Maybe;flatMap 在有值时调用 f,直接使用 f 已经返回的 Maybe。空分支都不调用 f。代码中出现 get() 是因为同一表达式先判断了 isPresent(),其安全条件在局部可见;这不能推广为业务代码可以随处直接 get。
pure 是一个普通的静态方法,把已有 A 表示为成功。pure(loadOrder()) 的参数仍然先求值,方法名不会延迟 loadOrder。这里也不把异常自动转为缺席:回调抛出异常时,异常照常离开调用栈。要用类型表示异常或业务错误,需要另立契约。
嵌套分支明确了调用条件
直接实现对每一步检查有值后再继续。正常路径依次获得订单 o、地址 a、配送区 z;缺席路径在最近的判断处返回 none。
1 | |
这个实现没有概念错误。它把业务步骤与重复的分支传播放在同一个方法中,步骤增加以后缩进和判断会一起增加。引入组合器的目标是复用这些分支规则,并保留它的控制流契约。若业务本来就只有一个条件判断,直接分支可能更清楚,没有必要为了使用 flatMap 而拆函数。
订单缺席时,计数应为 (1,0,0),因为订单查询必须执行一次才能知道缺席。地址缺席时应为 (1,1,0)。配送区缺席与全部成功都执行三步,因此都为 (1,1,1),区别体现在返回值。只统计总调用数,无法区分哪个环节被跳过;只比较最终 empty,又会放过“先查完三步,再丢掉结果”的错误重构。
实验中 Lookup 保存计数器,它本身是有可观察修改的探针。真正讨论 Maybe 的纯组合规则时,可把查找理解为不可变映射上的查值;计数器只是让执行路径可测。把这个探针也当纯函数,将不利于后续讨论引用透明性,所以这里明确分开结果模型与观察工具。
map 为什么自然产生嵌套
map 的抽象签名是 M<A> × (A -> B) -> M<B>。这里的 B 没有规定必须是字符串、数字或领域对象;它也可以是 Maybe<Address>。把 address 交给 map 时,用类型代换得到 A=Order、B=Maybe<Address>,外层 Maybe 仍然存在,于是结果为 Maybe<Maybe<Address>>。
继续查询配送区需要在内层 Maybe 上 map。这样会再增加一层:
1 | |
最外层 map 的回调接收 Order。l.address(o) 得到 Maybe<Address>,它的 map 接收 Address,而 zone 返回 Maybe<String>,所以这个内层 map 得到 Maybe<Maybe<String>>。外层再把它当作普通回调结果保存,最终共有三层 Maybe。这是签名正常工作得到的类型,不是泛型推断失灵。
正常路径可以抽象记成 Some(Some(Some("Z1")))。第一步缺席得到 None;第二步缺席得到 Some(None);第三步缺席得到 Some(Some(None))。这些结构并不相等。外层的 Some 只说明更早一步成功,不能说明最深处有配送区。因此对嵌套结果只检查一次 isPresent,会把“地址缺席”误判成整个流程成功。
这三层也携带了有限的位置信息。把它们转成单层 none 之后,调用方无法再区分订单缺席与地址缺席。如果业务真的需要区分这些原因,应改用命名的错误分支,例如 Either<LookupError, Zone>,不要先压平再期待从空值恢复原因。嵌套可以表达结构,但它的形状不如显式错误类型直观。
map 版本仍然满足短路调用条件。订单缺席时,最外层回调不调用;地址缺席时,内层回调不调用。嵌套是返回类型的问题,短路是回调执行的问题,两者不能混为一谈。把嵌套版本描述成“所有函数都执行了,所以需要 flatMap 短路”是错误的。
join 只合并相邻的同种结构
对这份 Maybe,join 的输入是 Maybe<Maybe<A>>,输出是 Maybe<A>。外层缺席时返回缺席;外层有值时,使用里面那个 Maybe。因此 Some(Some(a)) 得到 Some(a),Some(None) 与 None 都得到 None。
1 | |
这里借已有 flatMap 简写 join,并不意味着理解 join 必须预先理解 flatMap。直接按上述两个分支也能实现。identity 的类型是 Maybe<A> -> Maybe<A>,正好符合 flatMap 的回调要求;它没有提取 A,没有判断配送区,也没有创造一个默认值。
业务链条可以在每一步 map 后调用 join,让类型始终回到单层:
1 | |
每个中间变量都标出类型,编译器可以验证衔接。第一次 map 的回调返回 Maybe
,join 接受这个二层结果。第二次 map 改为返回 Maybe也可以对三层 nested 连续 join 两次。第一次将最外两层合并,得到 Maybe<Maybe<String>>;第二次再合并成 Maybe<String>。若只 join 一次,仍然没有单层配送区结果。实验保留了二、三级缺席的结构断言,再做两次 join,因此能同时验证压平前的信息和压平后的统一结果。
join 不是通用的“去括号”操作。Maybe<List<A>> 的两层含义不同,没有本章定义的 join 可以直接把它变成 Maybe 或 List。选第一项、丢掉缺席、生成空列表都属于额外业务策略。后续 Traverse 和 Transformer 会分别处理不同形状之间的组合,不能把这些问题都交给一个泛化的 flatten 名称。
flatMap 把两步操作变成一个组合点
当业务反复使用 join(map(m,f)),可以把这套操作命名为 flatMap。签名是 M<A> × (A -> M<B>) -> M<B>。和 map 的区别落在回调输出:map 接普通 B,flatMap 接同一种 M 中的 B。名称里的 flat 反映某些数据结构的直观外形,实际契约仍要由输入、输出与实例实现确定。
1 | |
第一个 flatMap 在有订单时把订单交给 address;第二个在有地址时把地址交给 zone。若前一步缺席,本章 Maybe 的 flatMap 直接保留缺席。业务代码不再重复检查分支,分支规则仍存在于 Maybe 的实现里。
把“普通函数”改成“返回 Maybe 的函数”并不要求所有后续步骤都改用 flatMap。例如配送区代码取长度的类型是 String -> Integer,对应 map;根据配送区再查报价的类型是 String -> Maybe<Quote>,对应 flatMap。判断时先写回调的输出类型,比根据方法名或代码缩进猜测可靠。
两个互不依赖的字段校验则是另一个问题。如果地址是否可用不需要订单结果,先产生两份独立校验结果再组合可能更符合需求。此时选择 map2 或错误累积结构,是因为输入依赖不同。flatMap 的表达能力强,不代表它总能保留独立检查的全部错误。
Scala 对照保留相同的类型与次数
Scala 版本直接使用标准库 Option,其四种写法沿用同一查询协议。模式匹配版本负责显式分支,map 版本保留三层类型,flatten 对应此处的 join,flatMap 表达单层接续。
1 | |
实验为每种写法重新创建 Lookup,避免上一轮计数残留。两种语言分别运行四种输入与四种形式,因此各有十六组结果和调用次数断言。map 形式比较最终结果前显式压平两次,不能把它的原始三层值冒充与单层值相等。
| 场景 | 最终配送区 | order 次数 | address 次数 | zone 次数 |
|---|---|---|---|---|
| 全部存在 | Z1 | 1 | 1 | 1 |
| 订单缺席 | 无 | 1 | 0 | 0 |
| 地址缺席 | 无 | 1 | 1 | 0 |
| 配送区缺席 | 无 | 1 | 1 | 1 |
这些计数证明当前同步实现没有调用缺少输入的后续查询。它不证明异步任务没有提前启动。如果把某次请求先保存成一个立即启动的任务,再放入回调,回调不调用也不等于请求从未发生。效果的构造与执行需要另行观测。
Java Optional 的 null 边界不能省略
Optional 的正确用法中“map 是可能无限级联的”一段给出用户名链式处理,并用 Optional.of(user.getUsername()) 说明 flatMap。这个用法要求用户名非 null;如果用户名允许缺席,of 会抛异常。旧文可作 API 使用背景,不能把其中省略的值域前提视为定律保证。
JDK 21 的 map 将回调返回的 null 变为 empty,flatMap 则拒绝回调返回 null。这使标准 Optional 与本章拒绝 null 的 Maybe.map 不完全相同。API 行为以 JDK Optional 的 map/flatMap 说明为准。
实验令 f(x)=null,令 g(null)="recovered"。Optional.of("x").map(f).map(g) 在第一次 map 后已经 empty,g 不再调用;Optional.of("x").map(f.andThen(g)) 则先在同一个普通函数中把 null 交给 g,得到非空字符串。因此两个结果不相等。这是 map 组合一致性的具体反例,不能改名为“flatMap 结合律被推翻”。
若把输入、回调与中间值限定为非 null,并只观察返回值,本章 Maybe 的等式可以在这个域内讨论。若允许异常、共享变量或 null,必须重新说明观察方式。Java 缺少直接写 F[_] 的语法,与具体 Maybe 是否遵守组合规律是两件事;本章已经用具体泛型方法实现了需要的结构。
从编译错误倒推需要的操作
面对一个复杂链条,可以先忽略函数名,只给两个槽位填类型。假设当前表达式是 Maybe<Order>,接下来提供 Order -> Address,map 的 B 就是 Address,结果一层 Maybe。若接下来提供 Order -> Maybe<Address>,map 的 B 变为 Maybe
一个常见错误是试图把 address 直接与 zone 做普通函数组合。address 的输出是 Maybe
另一个错误是为了消掉编译提示,在回调中调用 get。比如从 Maybe<Maybe<Address>> 强行取出内层,再强行取 Address,看上去得到想要的类型,实际是把两个缺席分支都改成异常。编译器只证明最后提供了 Address,并没有证明所有执行路径上都存在 Address。修复类型不匹配时,需要保留原来的失败语义,而不是只追求返回类型变短。
泛型参数的数量也容易干扰判断。Maybe<Address> 只有一个类型参数,并不表示它只能包含一个对象;参数描述值的种类,而不是数量。Maybe<List<Address>> 的成功值本身是一整个地址列表,零个地址的空列表仍可作为 Some 的值。是否把空列表当作缺席是新的业务规则,不会由 Maybe 或 List 的名称自动决定。
四种形式的等价范围
实验不是直接断言四个原始返回值相等。分支、map 后 join、flatMap 三种形式直接返回单层 Maybe;嵌套 map 形式返回三层,要经过明确的归一化后才能比较最终配送区。归一化主动丢掉缺席发生层次,故而这个等价关系仅覆盖调用方约定需要的最终值与调用轨迹。
假设调用方还要在页面区分“订单不存在”和“地址未填写”,这个归一化就不够了。尽管四种形式在配送区结果上相同,嵌套形式留下的结构信息已经被其他形式合并。应先把业务契约改为显式错误类型,再检查重构前后的错误分支相等,不能拿旧的 empty 断言声称新需求也被验证。
调用轨迹也有明确的观察粒度。当前只统计 order、address、zone 的次数,未记录时间、线程或外部资源。相同次数不证明相同延迟,也不证明运行在同一线程;本实验根本没有多线程。由于三个查询有严格数据依赖,计数配合源码控制流足以检查本章的跳过条件,但不应扩写成对分布式执行的保证。
如果查询函数读取可变仓库,分别运行四种实现时还必须保持仓库快照一致。第一次运行得到订单,第二次订单被删除,结果不同可能来自输入世界改变,而不是 map 与 flatMap 的组合差异。这里用参数控制的内存 Lookup,每个场景重建实例,正是为了让比较对象拥有相同初始条件。
还可以从测试敏感性检查实现是否真正遵守短路:当订单缺席时,将地址查询替换为一旦被调用就抛出测试异常的探针,正确链条仍应正常返回缺席。当前源码使用零调用断言达到相同的针对性,未额外执行这个抛异常变体。说明一种可能的测试方式,与报告已经运行该测试,是两个不同的陈述。
从 flatMap 反推 map,避免把两者对立起来
在本章非 null 值域中,普通变换可以先计算 B,再用 pure 变为 Maybe,于是 m.map(f) 可以写成 m.flatMap(a -> Maybe.pure(f.apply(a)))。空分支不调用 f,有值分支计算一次 f。这个表达式说明 map 并不是 flatMap 的竞争者;map 对调用者表达了更窄的意图:只改变成功值,不由回调新增缺席分支。
反过来,用 map 加 join 定义 flatMap,表达的是先形成嵌套,再按 Maybe 的规则合并。两种定义都要求 pure、map、join 彼此一致。若 join 随机丢掉某些 Some,或者 pure 将普通值变成缺席,方法依然可能有正确签名,但这些关系就不能作为安全重构使用。下一章的定律正是进一步约束这种一致性。
Java Optional 的 null 反例也可以放回这个推导检查。标准 map 对 null 的处理相当于把它变成缺席,而使用 Optional.of 的提升会抛异常;若忽略值域,直接照抄“map 等于 flatMap 加 pure”便可能比较两种不同语义。更稳妥的做法是先规定哪些值与函数合法,再在该范围里讨论关系。
至于为什么不让所有普通变换都写成 flatMap 加 pure,原因是阅读信息会减少。看到 map,读者可以直接预期回调返回普通 B;看到 flatMap,则需要检查回调可能生成何种 Maybe 分支。使用满足需求的较窄操作,有助于从签名判断是否存在新的失败点,而不是为了统一外观将所有步骤都写成同一种形式。
回调的类型可以先于代码结构确定
以配送区转展示文案为例,函数接收 String 并返回 String,没有新增缺席,所以应接 map。若文案来自可选字典,函数返回 Maybe
如果有意保留两层含义,例如外层表示订单是否存在,内层表示订单是否配置可选配送提示,那么 map 产生的嵌套可能正是所需结果。调用者可以区分不存在订单与存在订单但无提示。此时为了“消除嵌套”而一律改 flatMap,反而删除了信息。嵌套不是天然错误,是否展平取决于对外契约。
在代码审查中,可以要求作者先写一句可验证的承诺:哪种缺席会停止哪一步,最终调用方需要区分哪些缺席,普通转换是否可能新增失败。三项说清以后,分支、map 和 flatMap 的选择通常已经确定。剩下的语法简化不应改变这些承诺。
类型推导与修改练习
手算 Maybe.pure(Maybe.none()) 的类型并写出 join 结果。若外层类型推断没有足够信息,可显式指定内层为 Maybe<String>。外层有值并不代表存在 String,join 后得到 Maybe<String> 的缺席分支。再比较 m.map(address) 与 m.flatMap(address):假设 m 是 Maybe<Order>,前者两层、后者一层,回调是否执行都取决于 m 是否缺席。
修改实验,在 zone 后增加 quote: String -> Maybe<Integer>,有 Z1 时返回教学报价 100,另设一条报价缺席路径。四种写法都需要更新:nested 变四层,join 版本增加一次 map/join,flatMap 增加一段接续,分支版本增加局部判断。新增 quote 计数器,断言订单、地址、配送区缺席时它均为零;只有前面三步成功才允许它为一。
另一项可失败断言是防止把回调提到链外。若为“复用”提前调用 quote,再让 flatMap 返回已经计算好的值,前三种缺席输入也会增加 quote 次数。保留这个反例能让练习检验执行契约,而不只是让所有写法最后都打印 empty。金额 100 是类型教学用的固定整数,不包含币种、舍入或生产报价策略。
组合式写法是否更好,应看它是否保留了这些契约并减少重复。能够从函数类型说明嵌套来源、从分支规则预测计数,再独立完成第四步查询,才说明 map 与 flatMap 已经被理解。后续 Monad 定律会约束这些组合在重分组时的行为。
参考资料
- Cats Monad:map、flatten 与 flatMap 的关系,用于核对操作签名与相互定义;本文查询场景及计数为独立实验。
- JDK 21 Optional,用于核对 null 处理与返回类型。
- Scala for comprehension 教程,后续语法对照入口;本章没有把语法糖当作 Monad 定义。
