函数式编程21:List 与 Reader,多结果和共享环境怎样接续
一个报价程序可能有多个答案,也可能等待配置
订单数量有多个候选,每个数量又对应若干价格方案,程序需要保留所有组合及其顺序。这可以用 List 表达。另一种报价程序已经确定数量,只差单价和附加费配置,此时计算可以表示为 Env => Amount。两者都能提供 pure 与 flatMap,但接续规则完全不同。
List 的 flatMap 为每个已有结果产生一段后续结果,再依次连接。Reader 的 flatMap 构造一个函数,等环境到来后计算前一步,用其结果选择后一步,并将同一个环境交给后一步。Reader 无需内部存放一个现成的金额;这正是“Monad 必须是物理容器”解释不充分的地方。
前置为 Monad 定律与 Kleisli 组合。Scala for 推导式的双生成器示例已经介绍两层有序组合,本章保留其顺序语义,再加入依赖候选、重复项和函数环境。已有 Scala Typeclass 与组合实例说明能力对象与数据可分离,Reader 的核心则是运行时函数组合,不要把它与 given 搜索混为一谈。
实验源码固定 Scala 3.3.7、Cats Core 2.12.0、JDK 21.0.11。通过公共 runner运行:
1 | |
证据记录保留命令、环境、源码摘要和断言。金额用整数教学单位,只观察组合;生产货币模型仍需币种与精度规则。
List 接续的是每一个候选
数量列表为 List(1,2,2)。数量一有价格 100、120 两个候选,数量二只有价格 90。第二个价格列表的选择依赖 q,因此不能先无条件拿到一份价格列表,再宣称它适合每个数量。
1 | |
外层第一项一产生 (1,100)、(1,120);第二项二产生 (2,90);第三项二再产生一次 (2,90)。结果为四个元素,重复项没有被删除。flatMap 对每个输入位置工作,它没有依据领域标识合并候选的职责。
这个次序类似两层循环:先固定外层 q,完成该 q 的全部 p,再移动到下一个 q。若把循环顺序调换,需要先定义能够反向枚举的价格到数量关系,即使最终有相同配对,顺序也可能不同。Monad 的结合律允许调整组合括号,不能把列表次序视为无关信息。
对于 List,pure(a) 是 List(a),不是空列表,也不是无限重复 a 的列表。已有列表接到 pure 会把每个元素变成单元素段,再连接回原列表。若 pure 复制两份,每个候选会膨胀;若 pure 返回 Nil,所有结果都消失。第 20 篇的单位元契约在这里可以直接数出来。
空列表的 flatMap 不调用后续函数。实验使用独立计数器,断言对 List.empty[Int] 的接续既得到空结果又保持零调用。但某个候选返回 Nil,只删除该候选贡献的结果段,不会使其他外层候选全部失败。这与 Option 在整个成功链上只有零或一个值的情况不同。
因此,用 List 表示失败时要格外谨慎。一个订单解析失败返回 Nil,就会从总结果中消失。结果数量减少也许是允许的过滤,也可能是数据丢失;List 本身没有错误原因与原始位置。需要逐条反馈时,应保存 List[Either[Error,Order]],再选择后续处理策略。
Reader 将缺少的输入留在函数参数里
环境定义为 Env(unit,surcharge),分别保存单价与附加费。数量固定为二,报价公式是 unit*2+surcharge。直接函数 e => e.unit*2+e.surcharge 已经足够完成这一项任务。Reader 的价值在于当几个步骤都需要环境时,把传递模式归入可组合接口。
1 | |
Read 保存一个函数 run。尚未传入 R 时,不能得到 A。map 先构造一个新的函数;它以后接收 r,运行原函数得到 a,再用普通 f 转为 b。输入环境类型 R 保持不变,输出从 A 变 B。
flatMap 的 f 接收 A,返回的不是 B,而是另一个 Read[R,B]。为了得到最终 B,需要两次使用 r:先执行当前 run® 得到 a,再构造 f(a),最后执行 f(a).run®。把这些动作放入 r => ...,新结果仍是一个 R => B。
类型推导中最容易漏掉最后一次 run。f(run(r)) 的类型仍是 Read[R,B],不能直接冒充 B。也不能给第二个 run 随意生成另一份环境;本章 Reader 的契约是同一次运行中共享同一输入。若业务需要局部改配置,应通过 local 明确表达。
固定环境以后,组合退化为普通计算
1 | |
运行 quote.run(Env(100,5)) 时,price 先从环境取到 100,回调构造使用 p 的新 Reader,后者又从同一环境取附加费 5,结果 205。换成 Env(90,0) 得到 180。程序值 quote 没有改变,改变的是显式输入。
这里外层回调依赖 p,但第二个 Reader 仍需要 e。两种依赖可以同时存在:前一步结果通过闭包捕获,公共环境通过 run 参数传递。Reader 将反复手工传 env 的部分集中起来,不会消除业务实际需要的依赖。
Cats 对照采用 Reader[Env,Int],与手写 Read 在两个环境上逐一比较。Cats 的 Reader 可以从 Kleisli 与 Id 理解:Kleisli[Id,R,A] 保存 R => Id[A],而 Id[A] 就是 A。这里引用的是官方类型表示,本文实测的是这两个具体环境中的结果一致。
函数内部若只访问环境的两个字段,仍然能拿到整个 Env。Reader 不自动实现最小权限或最小依赖。一个巨大 Env 包含几十个服务时,所有接收它的函数都有较宽的访问面。可以拆出小环境,再用 local 将大环境投影成小环境,使类型公开的依赖更接近真实需要。
local 改变输入视图,不修改原环境
实验对 quote 应用 local[Env](_.copy(surcharge=0)),从 Env(100,5) 得到 200;随后直接运行原 quote,结果仍是 205。copy 产生一个新的不可变环境,原来的附加费没有变。
local 的类型是 (Q => R) => Read[Q,A]。已有 Reader 接收 R;如果能把 Q 转成 R,就能构造一个接收 Q 的 Reader。它改变输入端,而 map 改变输出端。两者方向不同,排查类型错误时不能互换。
这也说明 local 的作用范围由组合位置决定。对整个 quote 调 local,两个内部步骤都会看到转换后的环境;只对某一个子 Reader 调 local,则仅该子计算使用新的视图。如果环境转换本身有副作用或返回共享可变对象,纯度问题依然存在,名称 local 不提供隔离保证。
固定不可变配置通常适合 Reader 模型。数据库连接、可变缓存、正在进行的事务对象即使放入 Env,也不会自动变纯;它们的方法调用仍然有生命周期和外部效果。Reader 能表达“依赖哪个对象”,却不能替代资源释放、错误处理和并发协议。
Reader 的定律比较函数结果
左单位元把 pure(a) 展开为 _ => a,再 flatMap 到 f,得到 r => f(a).run(r),与 f(a) 在相同 r 上的结果相同。右单位元把已有结果交给不读取环境的 pure,得到 r => run(r),原行为得以保留。
结合律展开时,两侧都会从 r 得到 a,随后将 r 传给 f(a),再将 r 传给 g(b)。这给出正确实现应有的传递顺序。实验在 Env(100,5)、Env(90,0) 上运行三条规律,并同时与手工公式比较;它并未枚举所有 Env 或所有回调函数。
不能比较 reader1 == reader2 来验证这些函数的行为。两个分别构造的闭包可能不是同一个对象,却在每个输入上给出相同输出。相反,同一个 Reader 对象若捕获可变状态,重复运行可能得到不同值。实验特意构造每次增加 external 的 Reader,断言两次结果不等,证明包装类型没有阻止不纯代码进入。
这份反例不是宣称 Reader 定律失败,而是展示实例的函数域必须有约束。讨论纯 Reader 时,run 与传入回调应按相同输入产生相同结果,不能偷偷读取可变全局变量。对于工程依赖注入,允许函数访问外部效果也很常见,但此时要另外建模效果,不能继续使用纯函数的全部替换规则。
何时保留直接函数
一个局部报价公式只接 Env,直接写普通函数更容易读。Reader 适合重复的环境传递已经影响组合,并且这些步骤需要作为值保存或复用的场景。即使采用 Reader,也可以在入口调用一次 run,把程序所需环境注入,而不要在每个内部步骤随意构造另一份环境。
List 的使用同样需要规模意识。每个候选又产生多个后续候选,结果规模可能按分支数量增长。flatMap 让这种表达简洁,不会自动限流、去重或选择最优方案。本实验只有四个最终配对,不能从它推导大量候选时的性能结论。
两种实例共同提供“前值决定后续计算”的组合形式,但一个枚举多种结果,一个等待共享输入。读者若能分别展开这两个 flatMap,已经不需要依赖容器比喻。后续 State 会保留函数表示,同时将环境从只读输入改成显式传递的新状态。
把环境依赖限制在适当范围
Reader 的环境类型应当与函数真实需要的能力相称。报价只读取整数配置时,Env 可以是纯数据;如果为了省参数,把数据库、邮件发送器和缓存全部放进去,任何步骤都可能调用这些对象。类型中的 Reader 不会自动审查哪些方法被使用,也不会使测试天然轻量。较小的环境能缩小构造测试输入的成本,较大的环境则需要有明确的组合理由。
local 可以作为这种边界的适配器。例如较大的应用配置包含一个 PriceConfig 字段,内部报价只接收 PriceConfig。通过从大配置投影到小配置的函数,可以复用原报价,而无需让它知道应用配置的其他字段。这个转换发生在输入端,返回值的含义不变;若转换时顺便修改计费参数,则应将它视为业务规则并增加测试。
还要分清“复用同一个 Reader”与“复用一次求值结果”。quote 是一个函数表示,运行两次就应用两次函数,不会自动缓存第一次结果。若配置不同,预期结果可以不同;若配置相同且函数纯,结果应相同。将第一次结果保存在 val 中再复用,则是调用方主动选择的一次求值,不是 Reader 的默认记忆策略。
List 也有对应的复用问题:同一个数量出现两次,choices 会分别处理两个位置。若希望按不同数量只计算一次,再将结果复用到原始位置,就需要单独的缓存或索引逻辑,并保持输出顺序与重复项。单纯把输入转为 Set 会改变本章契约,不能作为透明优化。
手算与修改练习
手算把 quantities 改成 List(2,1) 后的结果。应先得到 (2,90),再得到 (1,100)、(1,120);顺序不是按价格排序。再写出 quote.local[Env](_.copy(unit=50)).run(Env(100,5)) 的值 105,并说明 unit 为什么变了而 surcharge 没变。
修改 Reader 实验,新增数量字段 quantity,用 Read[Env,Int](_.quantity) 取得数量,随后选择价格规则。测试 quantity 为一与二的路径,同时保留原环境未改变的断言。如果价格规则需要 unit,应在回调构造的 Reader 中读取;不要把某个固定环境捕获在定义外,让第二次 run 失去作用。
再把 List 的 choices 增加一个数量三返回 Nil 的分支,输入 List(1,3,2)。验收应得到三个现有候选,数量三的结果段为空,但数量二仍被处理。这个练习用于区分 List 的零结果与 Option 的单链缺席,不应通过提前 return Nil 让整批结果消失。
参考资料
- Cats Kleisli:Reader 与 Id 的关系,用于核对 Reader 的函数表示。
- Cats Monad,用于核对接续能力;具体列表顺序与环境行为由本章实验断言支持。
- Scala for comprehensions,用于对照有序生成器写法。
