同一个金额变换,能否同时处理 Option 和 List

数量可能缺失时使用 Option[Int],需要处理多个候选时使用 List[Int]。两种情况都能把数量乘以单价,但一个结果最多有一项,另一个可以有多项。若通用算法只关心“保持外部计算形状并变换内部值”,就可以把外层类型也作为参数。

普通 A 抽象一个完整类型,F[_] 抽象接受一个类型参数的类型构造器。把 Int 放进去后,F[Int] 才是完整值类型。List 与 Option 都符合这个形状,Int 本身则不符合。这里的下划线是参数位置,不是一个装着任意值的容器对象。

本文固定 Scala 3.3.7、标准库 2.13.16、JDK 21,手写最小 Functor 与 Monad 接口,分别用于 Option 和 List,再用类型 lambda 固定 Either 的错误参数。定律通过有限输入的断言检验,属于回归证据,不是对所有函数和所有输入的形式证明。

从 A 到 F[A] 多抽象了一层

1
2
trait Functor[F[_]]:
def map[A, B](fa: F[A])(f: A => B): F[B]

F 描述外部形状,A 和 B 描述变换前后的内部类型。实现必须接收 F[A] 与 A => B,返回 F[B]。如果 F 是 Option,那么输入可能不存在;如果 F 是 List,那么输入可能有多个元素。接口本身不告诉调用者元素个数,而是把相关行为交给具体实例。

这个抽象不同于把输入写成 Any。Any 删除类型之间的精确关系,Functor 则保留“同一个 F,内部从 A 变成 B”的约束。编译器仍能检查回调输入与输出是否相容,调用方仍能知道结果的外部形状。

也不同于函数值参数。普通函数 Int => String 在值层接收整数并计算字符串,类型构造器 List 在类型层接受 Int 并形成 List[Int]。两者有类似的“参数到结果”结构,但作用阶段和对象不同。把类型 lambda 当作运行时循环,会混淆它真正解决的问题。

并不是所有具有一个类型参数的东西都天然拥有合理 map。是否能实现、实现是否遵守预期规律,需要进一步讨论。F[_] 只是形状约束,Functor[F] 才提供对应能力对象,定律则约束这份能力如何组合。

identity 规律检查不应改变数据

对一个合理的映射,使用 identity 函数不应改变结果。Option(2) 映射为自身,None 仍为 None,List(1,2) 保持顺序和元素。若一个所谓 map 每次都把列表反转,即使返回类型正确,也会破坏这个规律。

本章构造反例 List(1,2).map(identity).reverse,断言它不等于原列表。这个反例没有编译失败,因为类型系统允许返回另一份 List[Int];失败发生在行为层。类型正确与代数规律正确是不同门槛。

为什么关心这个规律?泛型算法可能插入一个不会改变值的映射,或者在重构时消除它。如果 map 会删除、复制或倒序,消除操作就会改变业务结果。规律让某些局部改写具有可依赖的依据,而不是给方法起一个数学名字。

相等的含义也要明确。本章使用标准 Option、List 的值相等,顺序属于 List 的值语义。对于函数、资源或异步计算,不能直接拿 == 作为全部行为等价;需要定义可观察结果与执行协议,再设计适用的规律测试。

composition 规律连接两次变换

先把数量加一,再乘二,应与一次执行 n => (n + 1) * 2 得到相同值。Functor 的组合规律要求这两种映射方式一致。它把函数组合与容器映射连接起来,让重构不依赖某个具体元素数量。

这个规律通常讨论纯函数。若第一个回调修改全局变量,第二个回调读取它,连续两次严格 List.map 与合并后的逐元素处理可能具有不同副作用顺序。不能用带副作用函数的反例反过来声称标准列表没有正常映射语义,也不能忽略副作用而宣称任意代码都能按规律重排。

本章选择简单整数函数,并在有值、空值、多元素、空列表四种输入形状上验证。覆盖空形状很重要,因为错误实现可能在非空输入上看似正确,却在空输入时凭空添加默认值。类型签名并不会排除这种不合理实现。

规律测试还应避免只用对称输入。例如只有一个元素时,反转列表无法暴露顺序问题;只有零时,某些错误的乘法公式也可能碰巧相同。输入应能把有意制造的坏实现区分开,这比只增加同一种简单样例更有意义。

Monad 加入 pure 与依赖组合

1
2
3
4
5
trait Monad[F[_]] extends Functor[F]:
def pure[A](a: A): F[A]
def flatMap[A, B](fa: F[A])(f: A => F[B]): F[B]
def map[A, B](fa: F[A])(f: A => B): F[B] =
flatMap(fa)(a => pure(f(a)))

pure 把一个普通值放入对应计算形状,flatMap 让后一步依据前一步成功值产生同形结果。Option 的 pure 是 Some,List 的 pure 是单元素列表;flatMap 分别采用已有标准库行为。这里 pure 的名字不表示调用前的参数表达式没有副作用,普通严格参数在传入前仍会被求值。

用 flatMap 和 pure 定义 map,说明单纯内部变换可以作为依赖组合的一种特殊情况:取得 a,计算 f(a),再把普通 B 放回 F。它不意味着所有可能接口都必须用这个实现,只是本章选择一份容易推导的一致实现。

Option 的组合遇到 None 会停止,List 则对每个元素产生一段结果并连接。Monad 抽象没有抹掉这种差异,也没有承诺并行、可取消或自动资源安全。相同接口表示共有组合能力,具体效果仍由实例决定。

在订单例子中,先取得数量,再根据数量选择价格,是依赖组合;两个独立字段错误的累积则需要不同组合策略。不能因为 Monad 广为使用,就强行把所有校验都写成短路 flatMap。上一章的错误累积已经展示了接口选择必须符合任务依赖关系。

三条组合规律分别检查什么

左单位规律要求,把一个值 pure 后再 flatMap 到函数 f,等价于直接调用 f。它检查 pure 不应在这条路径里增加额外效果。例如 List 的 pure 若错误地复制两份元素,后续结果会重复。

右单位规律要求,一个已有 F[A] flatMap 到 pure 后应保持原值。它检查取出再放回不会丢失或增加结构。对于列表,顺序与重复项都应保留;对于 Option,None 不应被变成某个默认 Some。

结合规律比较两种括号:先把 fa 与 f 组合,再与 g 组合;或者让 fa 的回调内部把 f(a) 与 g 组合。两者应产生等价结果。它允许整理一串依赖步骤的嵌套结构,但不是把 f 与 g 的先后交换,后者可能改变依赖与业务含义。

本章每种输入检查上述三条规律,加上 Functor 的 identity 与 composition,共五类断言。对 Option 使用有值与 None,对 List 使用双元素与空列表。实际报告为四组输入、五条规律,不把它夸大成穷举了所有 Int 和所有函数。

失败时应检查哪个条件被破坏。若纯函数样例都失败,实例实现可能有结构问题;若只有带副作用的测试失败,先明确观察模型是否允许这些副作用;若自定义相等过弱,坏实现也可能通过。定律依赖操作、输入、相等关系三者共同定义。

类型 lambda 固定 Either 的错误参数

Either 接受错误和值两个类型参数,而 Functor 要求一个参数的 F。可以把错误类型固定为 String,只留下成功类型变化:

1
2
3
4
5
type Result[A] = Either[String, A]
val instance: Functor[[A] =>> Either[String, A]] =
new Functor[[A] =>> Either[String, A]]:
def map[A, B](fa: Either[String, A])(f: A => B): Either[String, B] =
fa.map(f)

其中 [A] =>> Either[String,A] 是类型 lambda。传入 Int 形成 Either[String,Int],传入 Long 形成 Either[String,Long]。它不在运行时处理 Right 或 Left,而是在类型层描述一个单参数构造器。

本章实现该 Functor,Right(2) 映射金额得到 Right(200),Left(“quantity”) 保留原错误。错误类型固定后,映射只改变成功类型。若需要同时改变错误通道,应使用不同接口,不能让当前 map 签名承担它没有声明的工作。

编译反例把 Int 直接作为 Functor 的参数,目标诊断说明类型参数种类不符。Int 已经是完整类型,不能再应用一个 A 得到 Int[A]。修复不是随意增加方括号,而是选择真实的类型构造器,例如 Option,或者用类型 lambda 明确剩余参数位置。

类型别名与类型 lambda 都能表达固定参数的需求,选择取决于是否反复使用。频繁出现的订单结果可以命名为 Result,局部一次性的抽象可以直接写 lambda。名称应帮助读者理解错误通道含义,而不是只为了减少字符。

最小抽象应留下可解释的使用点

如果业务只有一处 List.map,立即引入 Functor 可能增加阅读成本。抽象有价值的条件是同一算法确实需要用于多种计算形状,且算法只依赖已声明的共有能力。本章同一 laws 函数用于 Option 和 List,展示了这种复用。

扩展能力也应克制。若某个算法需要提取第一个元素,它已经超出仅有 Functor 的能力;若需要处理错误文本,任意 F 也未必包含错误通道。应增加恰当参数或换接口,而不是通过强制转换窥探具体容器。

泛型抽象的强项是限制实现能依赖什么。只拿到 Functor[F],就无法随意调用 List 专属索引或 Option.get。这个限制帮助算法保持通用,但不自动优化性能。字典对象、闭包和具体容器的分配仍需要按实际路径观察。

对生产代码,常见库可能已经提供成熟接口与规律测试。本文手写是为了看清签名和推导,不建议凭这个小示例建立完整效果框架。引入第三方抽象前,应先明确需要的能力、版本与迁移成本,避免把教学模型当成完整工程替代品。

定律失败要保留最小反例

如果随机输入发现组合结果不同,应保存具体输入、函数和相等判断,而不只记录“规律测试失败”。一个最小的双元素列表足以揭露反转问题,反而比庞大订单更容易解释。随后修复实例,并重跑此前通过与失败的两类输入,确认没有用特殊分支只掩盖单个反例。

测试函数本身也值得审阅。若两边都误用了同一段错误实现,断言可能比较两个相同错误结果;因此 identity 的一边直接使用原输入,左单位的一边直接调用原函数,能够提供独立对照。有限测试的可信度来自能否区分具体坏实现,而不是打印了多少次“通过”。

手算与修改练习

手算:List 的 pure 若返回 List(a,a),哪条规律最容易用一个值揭露?左单位就会失败:再 flatMap 到单元素结果函数,会得到重复结果;右单位通常也会把原列表元素复制。类型仍然正确,但组合行为不符合承诺。

修改练习:增加一个错误的 List Functor,每次 map 后 reverse,再用两个不同元素运行 identity 断言。测试必须捕获规律失败,而不能只检查编译。随后恢复标准 map,再增加长度三的输入和改变元素类型的函数,确认形状关系仍成立。

第二项修改是为 Either[String,*] 实现本章 Monad,验证 Right 与 Left 两种输入上的五条规律。成功回调包含一条可能返回 Left 的分支,确保结合规律覆盖错误传播。通过这些有限样例仍不是形式证明,应在报告里保留输入集合与函数定义。

实验记录与依据

源码 examples/scala-lab/snippets/20/Chapter20.scala,入口 scalaexamples.Chapter20。实际命令、规律断言结果和种类错误诊断见 RUN.md。

前置阅读:Type class 与组合实例。

顺序导航:系列入口:00 · 上一篇:19 · 下一篇:21。