函数式编程25:EitherT 与 OptionT,逐层解释嵌套计算
返回类型多了一层,原来的 flatMap 接不上了
查找数量的函数原来返回 Either[String, Int],报价函数接收数量,返回同样的 Either。直接 flatMap 就能在成功时继续、失败时保留错误。现在要求查找过程先保存为一段待执行计算,函数返回 F[Either[String, Int]]。业务错误仍然需要表达,外层 F 又有自己的求值规则,两个问题同时存在。
此时调用外层 flatMap,回调拿到的是 Either[String, Int],不是 Int。调用内层 flatMap,回调又需要返回 Either,不能直接返回报价函数产生的 F[Either[String, Int]]。这不是编译器推断能力不足,而是两个 flatMap 分别组合不同种类的计算。需要写出一种操作,把外层的顺序规则与内层的失败规则合起来。
EitherT 与 OptionT 将这种重复组合封装成类型。它们没有让两种效果凭空消失,也不是把任意两个 Monad 放在一起就自动得到第三个。本章从实际嵌套类型和分支代码推导操作,再与 Cats 对照;不会从一个盒子比喻跳到库用法。
前置是 Traverse 与批量校验。Traverse 组织一批独立结构中的计算,本章处理一条依赖链上同时存在的两层语义。已有 Scala for 推导式解释了生成式如何转换成 map、flatMap;本文进一步限定这些方法的接收者类型,避免认为 for 会自动穿过所有嵌套层。
先把外层 F 固定成一个教学 Delay
为了只观察组合问题,实验不访问网络、不创建 Future,也不引入真正 IO。外层先选一个保存零参数函数的 Delay:
1 | |
调用 map 或 flatMap 只创建新的函数,方法体里的 run 尚未执行。运行 flatMap 的结果时,先运行原计算得到 a,再调用 f(a) 得到下一段 Delay,最后运行下一段。这个顺序来自函数体中的表达式排列,不来自方法名称的约定。
Delay 只是同步教学模型。它没有线程调度、取消、资源释放协议,也没有深链栈安全保证;直接嵌套的函数调用仍可能消耗调用栈。它不会捕获异常并转换成 Either。把任何副作用写进闭包,确实可以延后发生,但仅有这个事实还不足以成为生产效果系统。
另外,pure 的参数是严格求值的 A。pure { counter += 1; 3 } 会先递增计数,再把得到的三保存在闭包中。实验断言构造后计数已经是一,多次 run 也不会再次递增。若要推迟代码块,必须明确写 Delay(() => ...)。pure 表示提升已有值,不能笼统翻译成“把任意表达式变懒”。
后续示例用 events 记录函数实际求值的顺序。这是测试仪器,不是纯业务逻辑;断言结果与轨迹是为了揭示求值差别,不是在声称这些带计数的函数仍满足所有纯函数替换规则。
原始匹配版本先说明所有分支
实验查找函数在成功时给出数量二,失败时给出 missing。报价函数把数量乘一百。两个函数都返回延迟的 Either:
1 | |
find 返回的外层类型是 Delay,所以第一处 flatMap 的回调类型必须是 Either[String, Int] => Delay[Either[String, Int]]。成功分支拿到 n,恰好可以调用 quote。失败分支没有 n,因此不能报价;它需要把 Left 放回 Delay,以满足整个回调的返回类型。
pure(Left(e)) 不是重试 find,也没有执行一个失败动作。它只是返回已经得到的错误值,使外层顺序计算能够结束。失败由内层 Either 表达,外层 Delay 仍然正常返回一个值。将 Left 理解成“外层计算抛异常”,会混淆两个不同的错误通道。
完整源码在闭包中记录 find、quote 两个事件。构造 manual 时不产生事件;执行成功输入时得到 Right 二百,事件为 find、quote;执行失败输入时得到 Left missing,事件只有 find。测试同时断言结果和轨迹,因而能够区分“错误值正确,但仍然错误地调用了报价”的实现。
如果再接折扣、税费与格式化,每一步都重复这个 match。重复的不是领域规则,而是相同的连接规则:等待外层得到 Either;Left 原样返回;Right 调用后续函数。这部分适合提取。先写清它的行为,再讨论抽象,才能知道抽象是否保留了正确的分支。
为这种连接规则定义一个类型
实验用 DE 表示专门针对 Delay 的 Either transformer:
1 | |
这里有两个不同层级的 flatMap。方法声明处是 DE 的 flatMap,调用者提交 A => DE[E, B];方法体内使用 Delay 的 flatMap,它的回调接收整个 Either。DE 将这两种签名之间的适配集中起来,业务函数因此只需要处理成功的 A。
Right 分支调用 f(a) 后得到 DE,必须通过 value 取出 Delay[Either[E, B]]。这不是运行 Delay,只是去掉 DE 的包装,以满足外层 flatMap 的返回类型。Left 分支使用外层 pure 提供同样形状的返回值。最后 DE 构造函数重新给组合结果附上这套连接操作。
调用处由此变成:
1 | |
这三个变量依次表示“带专用组合操作的计算”“原始嵌套计算”“执行后得到的业务结果”。不能省略中间类型后说 value 取到了 Int。value 仅退回原始表示,只有 run 才执行本章的 Delay,执行后还要区分 Left 与 Right。
DE 不会缩短错误路径应当完成的工作,也不会撤销此前的事件。它只保证 Left 时不调用这次 flatMap 收到的成功回调。已经执行完的 find 保留在轨迹中。把业务短路称为回滚,会对外部写入或审计记录作出错误承诺。
map、pure 和 lift 分别改动哪一层
DE 的教学源码只实现本次对照需要的 flatMap,没有伪装成完整 Cats Monad 实例。若要推导 map,可以对 value 使用外层 map,再对成功的 Either 使用内层 map;类型从 Delay[Either[E, A]] 变到 Delay[Either[E, B]]。Left 保持原错误,Delay 的执行时机也不应被提前。
transformer 的 pure 则需要同时提供两层单位:先有成功值 Right(a),再通过外层 pure 得到 F[Either[E, A]]。外层是否延迟任意表达式,仍要看 pure 的参数求值规则,不能因为构造了 transformer 就改变语言本身的严格求值。
liftF 处理的是已经存在的 F[A]。它不应该运行 F 再取出 A,而是用外层 map 将 A 变为 Right(a),得到 F[Either[E, A]]。OptionT 的同类操作把 A 变为 Some(a)。因此 liftF 承诺的是:只要外层正常产生 A,就把这个 A 视为内层成功;它不负责验证 A 的业务合法性。
已有 Either 与已有 F[A] 也不是同一种输入。前者需要把现成的业务结果提升进外层,后者需要给外层计算的成功值增加内层标记。已经有 F[Either[E, A]] 时,只需包装,不能再次 liftF,否则会把整个 Either 当作成功值,再额外增加一层 Right。
| 手头的值 | 目标 EitherT 的构造含义 | 原始结果形状 |
|---|---|---|
| A | 外层 pure 加内层 Right | F[Either[E, A]] |
| Either[E, A] | 保留已有左右分支,提升到 F | F[Either[E, A]] |
| F[A] | 外层 map 成 Right | F[Either[E, A]] |
| F[Either[E, A]] | 直接包装,不求值 | F[Either[E, A]] |
这张表按输入类型选择操作,不按函数名称猜测。遇到类型错误时,先写出当前值和目标值的完整类型;通常可以看出是缺少一层,还是重复加了一层,而不必反复尝试不同的构造器。
与 Cats EitherT 做相同路径的对照
Cats 的 EitherT 使用 F[Either[E, A]] 作为底层表示。外层 F 有 Monad 实例时,可以提供对应的 Monad 组合;不同输入形状对应 fromEither、liftF 或直接构造等入口。value 返回底层 F,而不是解除所有效果。Cats EitherT
本章对照将 Delay 换成 Cats Eval,使用 Eval.always 保留每次求值都会执行闭包的特征:
1 | |
完整实验还包含失败输入和 events。手写匹配、DE、Cats EitherT 三种写法必须得到同样的 Right 二百或 Left missing,并保持相同事件顺序。构造每一种计算时也检查 events 为空,防止代码为了适配类型而提前执行外层计算。
这里连续出现两个 value,含义不同。program.value 是 EitherT 的底层表示访问;raw.value 是 Eval 的求值入口。两者名称碰巧相同,不能因此合并解释为“取两次盒子里的值”。后一个调用受 Eval 的求值策略影响,前一个只是得到这个 Eval。
Eval 支持不同同步求值策略。always 延迟且不记忆结果,later 延迟并记忆,now 使用已经求出的值。实验使用 always,并在第二次求值后断言事件序列重复了一遍。这个重复不是 EitherT 额外执行的行为,而是选定外层求值策略的结果。Cats Eval
不要把上述对照推广成 Delay 与 Eval 在所有性质上等价。它们只在本次短链、成功失败和重复求值场景上给出相同观察结果。Eval 的实现还有栈安全设计,教学 Delay 没有。本章没有对无限递归、异常或资源占用进行等价性检验。
OptionT 只改变缺失分支的规则
将内层 Either 换成 Option,手写组合变成 None 时返回外层 pure(None),Some 时调用下一步:
1 | |
这个类型保留外层 Delay 的顺序求值,同时增加“没有值就不调用后续成功回调”的规则。None 没有错误原因,因此 DO 无法凭空记录缺失是正常结果、输入错误还是网络失败。若这些区别影响调用者决策,应在建模时选择更丰富的内层类型。
实验分别使用 DO 与 Cats OptionT[Eval, Int],让前一步产生 None,后续回调递增 next。求值后结果仍是 None,next 必须是零。这条负向检查比只看结果更严格:如果错误实现调用后续回调,再丢弃它的结果,也会返回 None,但 next 会暴露额外调用。
Cats OptionT 的 value 同样返回 F[Option[A]],fromOption 接收已有 Option,liftF 接收 F[A]。实验用 OptionT.liftF[Eval, Int](Eval.always(3)) 验证最终得到 Some 三,并用 EitherT.liftF 验证对应的 Right 三。Cats OptionT
None 与 Left 的选择不能只看哪个代码更短。没有可选折扣可以是正常缺失;无法读取价格规则可能需要明确错误。把它们都转换成 None 后,调用者可能对读取故障误用默认价格。短路行为相似,并不表示业务含义相同。
转换类型与叠加类型有不同的信息后果
实验将 Left("network") 转为 Option,得到 None;同时保留 Right(None): Either[String, Option[Int]],并断言它不等于 Left network。前者的转换丢掉了错误原因,后者的嵌套保留了“查询失败”与“查询成功但没有值”的区别。
Either[E, Option[A]] 至少有三类结果:Left 错误、Right None、Right Some 值。若外面还有 F,就成为 F[Either[E, Option[A]]]。可以分别为查询故障、正常缺失和正常存在设计处理逻辑,不能用一个 getOrElse 把它们都无声合成默认值。
换一种顺序,Option[Either[E, A]] 也能表达 None、Some Left 和 Some Right,但此时最外层 None 的含义必须重新定义。对于只有这几种纯数据的例子,可以写出人工对应;一旦加入状态、日志或外部求值,交换层次是否保留观察行为不能只靠分支数量判断。
第二十三章已经出现一个具体区别:Writer 包着 Either 可以保留错误结果旁的记录,Either 包着 Writer 的 Left 则没有成功分支中的 Writer。第二十二章的状态与错误也有类似问题:失败时能否观察到新状态,由返回形状决定。transformer 顺序是业务选择,不是任意调整的类型排列。
如果确实需要多层 transformer,应从最终底层类型倒推。例如明确要求 F[Either[E, Option[A]]],再判断哪些操作把正常缺失视作短路、哪些操作只处理业务错误。先堆出很长的类型别名,再依靠推断尝试方法,很容易让错误恢复发生在错误的一层。
for 不会选择错误策略
对原始 Delay[Either[E, A]] 写 for,生成器变量仍是 Either,因为调用的是 Delay 的 flatMap。改成 DE 后,生成器才有机会接收成功的 A,因为 DE 定义了另一套 flatMap。但本章最小 DE 没有 map,所以不能把任意带 yield 的 for 直接复制进去;最终的 map 也要实现才满足语法转换需求。
Cats EitherT 和 OptionT 提供相应操作,可以使用 for。语法只是调用这些操作,仍然不会决定错误该用 String 还是领域 ADT,也不会替作者选择哪些错误能恢复。过滤条件还涉及 withFilter 等额外能力,不能从存在 map、flatMap 就推出任意守卫都受支持。
阅读一段 for 时,可以逐行标注接收者:原始 F、EitherT 还是 OptionT。接收者确定后,生成器变量的类型、短路位置和最后返回类型才明确。若中途混入原始 F[A],应先按需要 liftF;如果混入已有 Either,就用对应提升。隐式转换不是理解这段代码的必要前提。
外层失败不会自动成为 Left
本章 Delay 的 run 若抛异常,DE.flatMap 中的 match 根本拿不到一个 Either,因此不会进入 Left 分支。EitherT 也不意味着所有运行时故障都自动转换为指定 E。外层效果如何表示异常、取消或失败,要由具体 F 和显式转换操作决定。
如果业务 E 只是“库存不足”,把网络断开也强行映射成同一个错误,会丢失可恢复性信息。调用者可能把临时故障当作正常拒绝而不再尝试。反过来,把所有业务拒绝抛为异常,又会失去 Either 在类型中表达的显式分支。错误分类应先由业务和运行边界确定,再决定转换的位置。
本章没有执行网络故障或取消实验,因此不提供这些场景的运行结论。真实 IO、异步边界、资源和取消将在后续效果管理部分分别处理。这里可验证的范围是同步延迟、内层 Left/None 短路、提升和重复求值,不包含生产运行时保证。
从具体 Delay 推广到 F 时需要哪些能力
DE 的实现里,除了构造包装,只对外层使用了 flatMap 和 pure。把 Delay 改成 F 后,这两处不能继续直接调用教学函数,必须由 F 的能力实例提供。失败分支需要在 F 中返回 Left,成功分支需要继续 f(a).value;这正是为什么拥有一个裸类型参数 F 还不够。类型参数命名为 F,不会自动赋予组合方法。
与此同时,不同操作所需能力并不相同。只改变内部成功值的 map 可以通过外层 map 完成;提升已有 F[A] 也可以通过外层 map 加成功标记完成;从一个已有 Either 创建 F 中的值则需要外层 pure。只有执行依赖接续时,才需要更强的外层 flatMap。具体库方法可能通过不同的类型类约束表达这些要求,不能因为最终类型是 EitherT 就认为每次操作都需要运行整个 Monad 协议。
这个推导也能定位常见的多包一层错误。假设成功回调返回 DE[String, Int],其 value 已经是 Delay[Either[String, Int]]。如果在 Right 分支再写 pure(f(a).value),得到的是 Delay[Delay[Either[String, Int]]],比所需返回值多了一层 Delay。若直接在分支里执行 run,则虽然可以取到 Either,却把外层执行藏入适配代码。正确做法是不新增提升也不提前运行,直接交回已有的 value。
失败分支则相反:手头只有 Left(e),没有 F,所以需要 pure。不能因为成功分支不需要提升,就删去失败分支的 pure。两个分支的起始类型不同,经过不同操作后才汇合成同一个 F[Either[E, B]]。这类局部类型推导通常比记住构造器清单更有用。
为什么不能对任意 F[G[A]] 照抄同一实现?因为外层 flatMap 得到 G[A] 后,还需要知道如何将它与 A => F[G[B]] 接起来。对于 Either,Left 与 Right 提供了明确规则;对于 Option,None 与 Some 提供了明确规则。任意 G 并没有统一的两个分支,尤其多个结果与其他效果叠加时,还必须决定顺序和相互作用。存在两个独立 Monad 实例,不足以让这个缺口自动消失。
因此 transformer 不只是缩短嵌套类型的别名。仅声明 type Result[A] = F[Either[E, A]] 可以改善签名外观,却不会改变 F.flatMap 的回调仍收到 Either 这一事实。EitherT 除了保存底层值,还提供了理解内层分支的组合操作。别名可以与 transformer 配合提高可读性,但不能替代这段语义。
什么时候应当保留显式匹配
只有一处嵌套分支时,manual 往往已经足够清楚。引入 transformer 的收益来自多步组合共享同一套连接规则,而不是消灭所有 match。领域错误最终仍要被解释为页面、响应或恢复动作,那些有业务含义的匹配不应为了统一语法而隐藏。
如果每一步失败策略都不同,例如某一步缺失使用默认值、另一处缺失要中止,先写清各步契约更重要。可以在边界把不同返回值规范成统一类型,然后在内部组合;也可以保留局部匹配。是否值得引入 transformer,应看重复的控制逻辑和可读性,而不是类型嵌套达到两层就机械添加。
同样,不能因为需要组合就把所有函数都扩大成最复杂的效果类型。一个纯折扣计算只需 Int => Int,可以用 map 接入;一个已有 Either 的校验可以提升;只有真正需要外层能力的步骤才返回 F。让签名保留尽可能准确的能力范围,有助于审查求值和错误边界。
手算与修改练习
先逐个写出 DE.flatMap 中 value、外层回调参数、f(a)、f(a).value 与最终 DE 的类型。再将成功输入改为失败输入,指出哪一个表达式不再求值。最后解释为什么 program.value 不等于业务 Int,以及为什么 pure(expensive()) 不能在本章定义下推迟 expensive 的调用。
修改实验,给报价增加第二个失败条件:数量超过十时返回 Left too-large。分别验证查找失败、报价失败、完整成功三条路径的事件顺序。查找失败应没有 quote,报价失败应保留 find 和 quote;这两条错误路径不能用“都得到 Left”合并成一个断言。
再给 DO 和 Cats OptionT 各补充一个 Some 成功链,断言后续回调恰好调用一次,并在 None 链中保持零次。之后将输入改为 Either[String, Option[Int]],保留 network 与正常 None 的区别。只有在题目明确要求忽略故障原因时,才允许转换成 Option;这个转换必须有单独测试说明信息损失。
最后尝试为 DE 补 map 与 pure,并验证两个有限样本上的单位律与结合律。测试应比较运行后的结果和明确约定的可观察行为,而不是比较闭包对象地址。若使用 events,还需要说明事件记录属于观测手段;不要把有限样本通过写成对任意副作用程序的数学证明。
比较三种实现时,每条路径都先将 events 重置为空。否则 manual 的事件可能混入 Cats 对照,让正确实现被误判为重复执行。只有专门检验 always 重复求值的那一步,才有意保留第一次轨迹,再断言第二次追加相同序列。测试准备本身也影响观察结果,不能把探针状态残留误认成 transformer 语义。
实验与参考
执行 node examples/functional-programming/run.mjs 25。十八组 PASS 验证手写匹配与 DE 的成功失败、构造不求值、Cats EitherT 对照、Eval.always 重复执行、两种 None 短路、两种 liftF、错误转换的信息损失以及严格 pure。上述练习中的新增成功链和第二阶段失败不属于现有十八组结果,修改后需要自行增加断言再运行。
完整 Scala 源码、真实运行证据 和 公共 runner 对应本章可复跑实验。证据记录实际环境、命令、源码哈希、退出码与断言输出,未记录或暗示真实 IO 测试。
官方阅读材料为 Cats EitherT、Cats OptionT、Cats Eval 与 Cats Monad。DE、DO 和报价场景是用于拆解组合步骤的教学实现,不是生产库的替代品。

