函数式编程17:Applicative与独立输入的组合
数量与单价分别解析完成后,可以用乘法构造金额。若两者都是普通整数,函数只需要两个参数;若两者各自放在 Option 中,单次 map 只能取得其中一个值。组合规则还需要回答:任一输入缺席时怎么办,两个输入都有多个候选时又怎么办。
Applicative 描述这种不依赖前一步结果来决定下一步计算结构的组合。本章使用 Scala 3.3.7 手写 pure、ap 与 map2,用 Option 表达必须齐备的输入,用 List 表达候选组合,并检查四组常见定律。独立只表示数据依赖关系;它既不自动创建线程,也不保证错误累积。
从二元函数缺少一个参数说起
设数量为 Some(2),单价为 Some(125)。对数量 map,可以把二元乘法固定第一个参数,得到一个等待单价的函数。但这个函数也放在 Option 内,而单价仍是 Option[Int]。普通 map 接收裸函数,此时还需要一种操作,将上下文中的函数应用到上下文中的值。
1 | |
pure 将已有的普通值放入上下文;ap 将上下文里的函数与上下文里的参数组合。由这两个操作可以派生 map,再由 map 和 ap 派生 map2。这些方法的参数顺序不是装饰:ap 的第一组参数装着函数,第二组才装着输入。Cats Applicative 文档还展示了 product 的等价表达,本章选择 ap 以便直接跟踪函数类型。
逐行看 map2。最初 fa 是 F[A];映射以后得到 F[B => C];随后 ap 与 fb 的 F[B] 合作,得到 F[C]。在任何一步,都没有提取 A 到上下文外面,也没有凭借 A 去创建新的 F[B]。fb 在进入组合方法时已经作为独立参数提供。
这与需要先读数量、再决定调用哪个报价服务不同。后者的下一项计算可能取决于前一个值,单靠这里的接口不能表达这种选择。判断是否需要依赖组合,要看下一项上下文是否能够预先确定,而不是看业务函数里是否出现条件判断。构造最终结果时根据两个现成值做条件判断,并不自动构成上下文之间的依赖。
Option 实例要求两边都有值
1 | |
当函数和值都存在时应用函数;任一不存在时结果缺席。这意味着 map2(Some(2),Some(125)) 的乘法得到 Some(250),而数量为 None 时得到 None。缺席原因不会在这个类型中保存,不能把 None 的返回解释为“已经收集所有字段错误”。
pure 选 Some,表示提供的值直接进入有值分支。这里不将 null 自动转为 None;示例的数值函数也不产生空引用。若系统需要接纳可空 Java 数据,应该在调用 pure 之前完成归一化,不能让泛型组合算法同时承担外部数据清洗。
Option 的 ap 可以理解为有值条件的交集,但这个说法仍只描述本实例。其他 F 可能保存错误、列举候选或描述任务,不能因为 Option 会在缺席时返回 None,就认定所有 Applicative 都以同样方式处理失败。
实现中对两个参数进行了匹配,这不代表两个产生参数的计算一定按需执行。Scala 的普通参数是严格求值的,调用方法之前,实参表达式已经依次求出。本章稍后用事件记录明确验证这一点,避免将“最终结果缺席”误读成“第二项表达式未执行”。
List 实例枚举笛卡尔积
1 | |
函数列表中的每个函数依次应用于参数列表中的每个值。若第一个列表是两个候选数量,第二个列表是两个候选价格,map2 会生成四种组合,而不是按位置生成两种。实验用加法让顺序更直观:List(1,2) 与 List(10,20) 得到 List(11,21,12,22)。
先固定左边的 1,遍历右边得到 11、21;再固定左边的 2,得到 12、22。输出顺序由实例的遍历约定决定。交换两个输入,再相应交换函数参数,可能保留结果集合却改变结果列表顺序;本章用列表相等作断言,所以这种变化可被观察。
zip 得到的则是位置对齐的两项,例如 11、22。它适合配对已经一一对应的数据,但不是这里定义的 List Applicative。两种操作都可以有业务用途,不能因为方法都叫组合就互换。审查泛型代码时,知道 F 是 List 还不够,还要知道选择了哪一种组合实例。
pure 使用单元素列表而非空列表。若用空列表包装一个值,后续 ap 将没有候选可用,恒等律立即失效;若重复包装两次,恒等映射又会增加候选数量。单位式的包装方式受定律约束,不能根据“多给几个默认候选”随意调整。
空列表的 ap 没有结果,这也是实验中的负向边界。只要一侧没有候选,就不存在完整组合。它不等于错误信息,也不表示某个表达式没有执行;它只是枚举语义下候选集合为空。若需要区分“确实没有候选”和“候选读取失败”,还要增加其他类型层次。
四组定律检查什么
本章采用 pure、ap 形式的恒等、同态、交换与复合等式。Cats 文档也以 product 给出结合与左右单位规律。不同表达形式关注同一套一致组合约束,但测试必须说明自己实际执行了哪些等式,不能笼统写成“全部法则已经证明”。
恒等律要求 ap(pure(identity))(v) == v。在 Option 中,包装恒等函数不应改变缺席状态;在 List 中,不应改变候选的顺序和个数。它也检查 pure 的选择是否与 ap 一致,而不仅是单独检查业务函数返回值。
同态律要求 ap(pure(f))(pure(x)) == pure(f(x))。两边都没有原先存在的上下文影响,因此先算再包装与分别包装后应用应该一致。若 pure 隐式引入重复候选,或者 ap 又额外添加一个默认值,这个等式可能失败。
交换律比较 ap(u)(pure(y)) 与 ap(pure(h => h(y)))(u)。左边将包装的值交给上下文里的函数,右边将“把 y 交给函数”这一动作包装起来,再作用于同一个函数上下文。这里没有交换列表元素顺序,也不是说任何两个业务运算都可交换。“交换”的对象是这个特定等式中的函数和值位置。
复合律检查分层函数应用是否一致。令 compose 为先应用 g 再应用 f 的柯里化函数,则 ap(ap(ap(pure(compose))(u))(v))(w) 应等于 ap(u)(ap(v)(w))。两边都维持先 g 后 f,只改变上下文组合的组织方式。
实验直接使用整数函数和具体上下文结果比较,不比较函数对象。对 Option,函数上下文包含 None、加一与乘二;对 List,包含空函数列表、单函数以及双函数列表。值上下文既有空也有非空。恒等和同态单独断言,复合则枚举提供的函数上下文和值上下文三元组合。
这种样本选择能暴露函数缺席、候选为空以及函数次序的问题,但不能覆盖无限列表、所有函数或所有整数边界。定律是实例应遵守的契约,有限测试是维护实现时的回归工具。增加样本可以提高发现错误的机会,不能将测试次数换算成数学证明强度。
手动展开一次复合
在 Option 实例里,令 u 为 Some(加一),v 为 Some(乘二),w 为 Some(3)。右边先将乘二作用于三得到 Some(6),再应用加一得到 Some(7)。左边把 compose 放入 Some,再依次提供加一、乘二和三,最终也得到 Some(7)。
若 v 为 None,左边在组合第二个函数时缺席,右边在计算内部应用时缺席,最终同为 None。这里的不变性来自实例对缺席的处理与 pure、ap 的一致配合。仅验证全有值的情况,不足以说明边界分支正确。
在 List 中,复合展开需要同时保护结果顺序。u 的第一个函数应与 v 的全部函数、w 的全部值组合,再进入 u 的后续函数。若某个 ap 实现把参数循环放到函数循环外层,它可能仍生成相同元素,却以不同顺序输出。这种修改是否满足当前法则,必须按当前 pure 与 ap 的整体定义检验,不能只比较集合。
手算时可以给函数加名称而不加副作用,例如 f 表示加一、g 表示乘二,再列出每一项对应的函数路径。名称用于纸面区分,不写入运行时日志。这能避免把纯值定律与回调调用次数混成同一个实验。
独立组合不等于并发
本章方法的实参按严格求值规则构造。实验故意让 left 记录一次事件后返回 None,让 right 记录一次事件后返回 Some(2),再调用 Option 的 map2。
1 | |
断言要求 result 为空,同时 events 精确等于 List(“left”,“right”)。两项表达式都执行了,而且本例中顺序固定。没有线程池,没有异步任务,也没有调度器;把这个组合称为“自动并行验证”会与实际运行行为矛盾。
相反,若 F 描述尚未执行的任务,组合产生的是一个任务描述,具体实例才决定执行策略。这样的程序还需考虑取消、资源与失败传播,本章没有验证它们。因此不能把当前同步 Option 的观察推广成所有效果实例都严格、都串行或都不短路。
数据独立的价值在于组合结构能提前确定:处理第二个字段不需要第一个字段的成功值。这个事实为某些并发实现提供了可能,但并发不是由语法名字保证的。验证“能否并行”与验证“这次是否并行”属于两个不同问题,后者需要实际执行模型和观察证据。
也不能把上面的双执行现象当成错误累积。虽然两个表达式都计算了,Option 仍只返回 None,没有保存哪两项失败。求值次数与结果保留策略必须分别检查;前者可以用事件序列观察,后者要检查结果数据结构。
选择组合方式时先画依赖
解析商品编号和解析数量可以分别进行,因为数量格式不依赖商品编号的解析结果。库存检查却可能需要已经确认的商品编号和数量。如果把库存检查也强塞进独立 map2,就只能使用原始、尚未验证的数据,或者隐式在函数内部重新解析,都会使错误边界变得模糊。
一个清楚的分层是先组合独立字段,得到完整的请求值,再将该值送入依赖检查。前一层可以选择积累多条字段错误;后一层在前置值不可用时不运行。第十八篇会用真实 Cats Validated 表达这种分层,本章只提供组合接口及两个最小实例。
独立不意味着字段之间永远没有关系。例如开始时间与结束时间可以独立解析成时间对象,但“结束不得早于开始”需要两个解析成功的值。这个跨字段规则可放在第一层构造成功后,再形成下一层检查,不能在解析失败时编造一个默认时间继续比较。
当业务修改了依赖关系,组合结构也应随之改变。以前价格直接来自输入,后来需要根据商品查询价格,原来的两个独立输入就不再存在。保留旧 map2 外观而在参数表达式里隐藏查询依赖,会降低类型签名的解释力。应让读者从函数参数看出哪一步已经需要前一步的结果。
组合数量和资源边界
List 的笛卡尔积还有一个直接成本:左右候选数分别为 m 与 n 时,完整结果有 m 乘 n 项。本章两两组合只有四项,但三个各含一百项的输入会形成一百万种组合。这个数量来自组合规则,不是运行基准,也不依赖某次机器速度。
如果业务只需要对应位置配对,改用 zip 会从语义上改变问题,可能是正确选择;如果业务确实需要所有组合,则要面对结果规模本身,考虑限额、流式消费或更早的业务筛选。不能把 Applicative 的抽象当成消除组合爆炸的优化。
同样,纯函数回调也可能执行昂贵计算。对 List,回调次数随候选组合增长;对 Option,回调至多用于一组有值组合。泛型签名隐藏了具体上下文,却没有让不同实例的成本相等。调用方如果依赖某个规模上界,应把约束写进输入或业务接口,而不是只写“支持任意 Applicative”。
本章未测内存峰值、吞吐量或并发扩展性。源码中的事件缓冲区只用于验证两项严格实参的调用顺序,不是性能工具。读者可以据此复现语义,但不能从短小的运行输出推导生产容量。
相同签名仍需一致的实例约定
ap 的签名允许程序员写出很多实现,并不是所有实现都适合成为 Applicative。可以写一个总返回空列表的 ap,也可以写一个每次多复制一份结果的 ap,它们都可能通过类型检查,却无法维持前面要求的定律。因此类型检查解决接口是否能连接,定律检查解决这些连接是否具有约定的一致性。
map 由 pure 与 ap 派生,也限制了单独优化 map 的自由。若实例另写一个 map,结果却与派生版本不同,同一泛型程序使用不同组合方法时就可能产生不同含义。工程优化可以减少临时分配,但必须保持约定的可观察结果,不能以“这个方法用得少”为理由允许它悄悄过滤元素。
本章用同一组 law 检查函数分别接收 Option 和 List 实例,说明法则代码可以复用;它并没有证明两个实例的业务意义相同。Option 的一个缺席与 List 的没有候选分别对应不同问题,不能仅因为两者都能传进测试函数,就把接口返回类型互换而不修改调用方。
同一个数据类型也可能存在其他合理的组合视角。例如位置配对与所有组合都可用于列表,但它们需要各自一致的包装和组合约定。若代码库允许多种实例,最好使用明确包装类型或显式实例参数,让调用点可辨认。把两种策略混进同一个默认实例,会让泛型方法名失去解释力。
从元组到业务对象还需要验证构造函数
map2 最后的函数可以把两个成功值构造成业务记录,但纯构造本身仍可能有前置条件。若数量和价格各自通过格式解析,却不满足业务范围,简单相乘并不会替代范围验证。Applicative 保证的是按实例规则组合,不是保证所有输入都已经具有足够强的领域类型。
因此可以让前面的字段步骤返回受控构造的数量和价格类型,再用一个只组合这些强类型的构造函数。也可以明确让构造函数返回另一层失败结果,但此时 map2 的结果会出现嵌套,需要后续步骤处理。不要把返回 Result 的函数当作返回普通值的函数后,又忘记最终结果多了一个错误层次。
假设最终构造函数因为金额上限而可能失败,这条规则依赖两个现成成功值,可以在字段组合完成后执行。它不应该在一个字段仍缺席时使用零作为临时替代,再产生貌似完整的金额。以默认值补洞会改变业务输入,测试中需要专门验证缺席不调用构造回调或不产生伪造业务对象。
本章的乘法使用小整数,只验证组合结果,不声称包含全部金额领域规则。数量、单价和货币精度在真实接口中还需要明确值域;本章没有测整数溢出,也没有将乘法实现称为可靠的财务计算器。这些限制与抽象能否复用并不冲突,只是决定了哪一层负责哪一项保证。
用负例区分错误的解释
若 List 结果只剩两项,应先看实际实现是不是使用了 zip;若结果有四项但顺序不同,应检查函数循环与值循环的嵌套顺序;若结果为空,应检查是否有空候选,或 pure 是否错误地返回空列表。三个现象需要不同修复,不能统一归为“泛型推导有问题”。
若 Option 在第一项缺席时仍记录了第二项调用,这与本章严格参数的预期一致,并不是短路失效的库缺陷。需要按需构造第二项时,接口就必须显式提供这种能力,且重新说明它与独立输入组合的关系。仅在调用处换一个变量名,不能改变实参已经求值的事实。
若想验证异常边界,应当单独加入受控异常场景,并准确记录它发生在实参构造还是最终回调。前者可能在进入 map2 之前就中断,后者只在实例提供完整输入时执行。把两者都写成“组合失败”会丢失重要定位信息,也使后续的错误管理设计难以选择捕获位置。
复现与练习
完整 Main.scala包含两个实例、四类定律检查及严格求值反例。运行 node examples/functional-programming/run.mjs 17,对应的 result.json记录本次编译执行环境、命令、源码哈希和断言。实验同时断言 List 的四项结果与空列表结果,不只验证成功金额。
已有 Scala 高阶类型与组合定律提供高阶类型背景;本章把范围缩到 pure、ap、map2 的独立组合,不将它们与依赖计算或异步执行合并介绍。阅读 Cats 文档里的 product 与 tupled 时,可以先将其理解成获得一对上下文中的值,再映射构造最终结果。
手算任务:写出 map2 中三处关键表达式的类型,然后用 List(2,3) 与 List(10,20) 的乘法逐项展开结果。把同样的输入改用位置配对,说明差异来自实例语义而非函数乘法。再令左边为空,解释为什么不能输出右边的原值。
修改任务:在当前接口上实现 map3,组合数量、单价和固定附加费。要求只调用现有 pure、ap 或 map2,不用类型判断;分别测试 Option 的任一缺席、List 的八种候选组合以及空候选。最后把 right 改为抛出一个明确的测试异常,确认即使 left 返回 None,严格实参仍会抛异常。该修改实验是练习要求,不属于已保存的运行结果。
代码审查时,可以先问“第二个上下文能否在不知道第一个成功值的情况下构造”,再问“选定实例如何组合上下文”。前一个问题决定是否适合当前抽象,后一个问题决定缺席、候选与执行观察。仅看到 map2 或 mapN 的方法名,两个问题都还没有答案。

