函数式编程E03:Lens、Prism与嵌套数据更新
订单里的城市名称要改,订单编号、邮编和支付状态却不能一起变化。直接写两层 copy 很容易理解;同一条更新路径出现在多个地方之后,才值得把“从订单到地址,再到城市”的定位规则提取出来。Lens 的价值在于组合这个定位规则,而它是否可靠,要由读取和写回的定律决定。
对于“付款方式可能是银行卡,也可能是现金”这种数据,城市式的必有字段模型又不够用了。Prism 表达只匹配某个分支的访问与构造。两种结构可以共存,但不能把读取失败都藏成一个默认字符串。本篇先保留直接复制作为参照,再写最小实现和反例。前置背景可读Scala 的高阶类型与组合定律,系列导航见导读与能力自测。
直接复制先定义正确结果
实验模型只有三种字段:
1 | |
这段代码保留外层的 id、paid 和内层的 zip,只替换 city。这里的保留指结构相等,不承诺每个中间对象引用都与原对象相同。不可变更新允许结构共享,也允许分配新对象;如果业务依赖对象身份,需要另外定义观察标准。
把它作为参照比先引入库更有帮助。优化后的抽象必须得到与直接复制相同的结果,而且不能为隐藏额外字段更新制造空间。实验使用两张订单,分别带不同编号、城市、邮编和支付状态,并用三个新城市值比较两种写法的整个返回对象。只检查城市正确而不检查其他字段,会漏掉写回函数意外覆盖邮编的错误。
两个函数组成一个Lens
一个单态 Lens 可以写成:
1 | |
S 是完整结构,A 是其中必定存在的关注点。modify 先读取旧值,再变换,最后写回同一个结构。组合两个 Lens 时,读取依次穿过两层;写入则先从旧 S 取得 A,在 A 里替换 B,再把新 A 写回 S。遗漏最后一步就会只得到局部对象,丢失外层结构。
对 Order => Address 和 Address => String 两条路径组合,得到 Lens[Order,String]。这个类型检查能排除把城市 Lens 接到整数 Lens 后面,但无法证明 setter 保留编号。Lens 的两个函数字段完全可以被随意实现,因此还需要行为定律。
最常用的三条写作:
1 | |
第一条要求读出来原样写回没有变化;第二条要求写入后读到的就是新值;第三条要求后一次替换覆盖前一次,不积累隐蔽历史。变量 a、b 都在 A 的有效定义域内,等号采用模型的结构相等。这些条件不要求 setter 原地修改,实验恰好使用纯复制。
Monocle 3.3.0 的固定版本源码中,LensLaws包含 getReplace、replaceGet、同值替换幂等以及 modify 组合一致性。这里手写的第三条采用更一般的两次不同值替换版本;不能把源码里的同值幂等测试误述成已经覆盖全部 a、b 组合。
组合为何保留定律也可以展开检查。写回读取的 B 时,内部 Lens 将 A 恢复为原值,外部 Lens 再把这个 A 写回 S,得到 S。写入再读取时,外层 get/set 定律消去一层,内层定律给出 B。证明依赖两个组成部分都守约;把坏 setter 接到好路径之后,不会被组合操作自动修复。
看似友好的清洗为什么会破坏定律
把 setter 改成 city = a.trim,对通常的城市名字也许看不出问题。输入带空格的 " A " 时,写回后读到 "A",与写入值不相等,第二条定律失败。这不是 Scala 类型错误,而是 Lens 契约与输入规范化职责混在了一起。
可以把清洗作为 Lens 外面的普通函数:先明确调用 trim,再写入其结果。也可以把 A 建模成已经验证和规范化的城市类型,并把非法字符串拒绝在构造边界。两种方案都需要说明调用者提供的值是什么。单纯把测试数据里的空格删掉,则是缩小样本来掩盖契约冲突。
类似地,setter 每次递增版本号、记录调用次数或者随机补默认值,都会使这些等式的观察范围复杂化。有时业务确实要求每次更新产生新版本,但那应是领域命令或事件,不适合未经说明地伪装成纯定位工具。读取同一个字段不应默默触发新事务。
和类型分支需要Prism
付款方式定义为 Card(last4) 或 Cash。每个 Order 都有 Address,但不是每个 Payment 都有银行卡尾号,因而不能构造一个对所有 Payment 都合法的总函数 Lens 到 String。
1 | |
preview 尝试识别分支,review 从分支数据构造完整和类型。实验对 Card 返回 Some(last4),对 Cash 返回 None;构造则始终得到 Card。修改 Cash 时保留原值,修改 Card 时变换尾号后重新构造 Card。这里“不匹配就保持”是明确策略,不会把现金改成一张默认银行卡。
Prism 有两个往返方向:preview(review(a))=Some(a);对于匹配的 s,提取后再构造应恢复 s。把不匹配路径合并表示,就是 preview(s).fold(s)(review)=s。实验同时覆盖 Card 和 Cash,避免只对构造出来的成功分支做循环测试。
固定版本的PrismLaws有两个方向的往返测试及修改一致性。工业实现还支持更多组合及类型参数,本篇只实现足以暴露分支语义的单态版本,没有声称复刻完整 Monocle API。
如果 Card 除尾号还保存发卡行,直接用 Prism[Payment,String] 提取尾号并用固定发卡行重建,就会丢失信息。正确的 Prism 关注点应是 Card 的完整分支数据,再用 Lens 进入尾号;或者使用能够在原结构上局部修改的其他 optic。是否能够无损 review,是选择结构时必须回答的问题。
总路径、部分路径与复杂度
把必有字段与可选分支混用,通常会诱导开发者使用 .get。这会把原本显式的 None 改成运行时异常。组合结果的类型应该保留“可能没有关注点”,不能因为业务中多数订单使用银行卡就提升成总访问。
对深度 d 的不可变记录链,一次更新通常沿路径访问并重建 d 层,复杂度仍取决于原数据结构和 setter 实现。Lens 把表达式复用起来,不自动减少复制次数,不自动引入持久化树,也不保证比手写 copy 快。实验只比较结果和契约,没有性能基准,因此不提供速度结论。
抽象也有成本:调用者需要理解关注点、组合方向以及不匹配策略。如果只有一处两层 copy,直接写复制往往最清楚;如果多处复用同一稳定路径,Lens 可以减少散落的定位逻辑。若变化的是业务规则而非访问路径,则应提取领域函数,而不是只增加 optic 层级。
对于并发更新,本篇的 Lens 也不提供原子性。两个线程分别读取旧订单并修改不同字段,最后一次整体写回可能覆盖另一次结果。解决这类问题需要原子引用的更新协议、版本检查或事务;不可变对象和纯 setter 只是让每次转换更容易分析,不是并发协调机制。
定律之外仍需要字段保留规格
三条 Lens 定律表达读取与替换之间的一致性,但业务还应明确哪些字段属于关注点。某些非常规 setter 可以把额外信息编码到其他字段,并让 getter 与它配合,从而在特定观察下看似守约。订单模型里的城市关注点不应承担支付状态变换;直接复制的全对象比较把这个业务要求补了出来。
反过来,只比较一次直接复制也不够。一个 setter 可以在常见字符串上正确,在空串或含空格的输入上改变策略;一个 getter 可以在特定订单编号上读取不同字段。测试需要沿字段与输入类别选代表值,而不是只扩大相同形状的随机数量。本例选择两个支付状态和空字符串,就是为了覆盖两个明确边界。
Lens 与验证还涉及失败结果。如果写入非法邮编需要返回错误,那么 (S,A)=>S 的总 setter 类型就不合适。可以先验证生成受约束的 A,再通过 Lens 更新;也可以把整次编辑建模成 Either[Error,S]。后一种结构有自己的组合规则,不能继续假设所有 A 都可以成功写入。
同样,删除某个可选字段不应隐式解释为“写入一个特殊值”。None、空字符串和缺失字段在领域中可能含义不同。Lens 的关注类型如果是 Option[String],写入 None 是普通值替换;如果关注类型是 String,就不能凭空把 None 藏在 setter 里。类型设计应让调用者能看见状态空间。
Prism 的 review 也应承担明确的构造含义。对 Payment.Card 的尾号,示例允许任意字符串,只因为模型没有施加长度和字符规则。生产模型若限制四位数字,应把这些条件放在分支数据的构造器或精化类型中。把不合法尾号静默截断成四位会破坏提取/构造往返,和前面 trim 的问题相同。
这也解释了为什么数据迁移不能随意伪装成 optic。旧结构缺少新结构要求的字段时,迁移可能需要默认值、查询或人工决定;它不是无损的局部聚焦。若确实允许丢失信息,应明确写成转换函数并列出丢失字段。保持 optic 的窄契约,才能让后续组合依赖这些定律而不必重新猜测隐藏的迁移行为。
审查实际调用点时,还应区分替换与变换。set(s,a) 忽略旧关注值,modify(s)(f) 依赖旧值。如果两次调用都把同一旧快照传给 modify,第二次并不会自动接着第一次结果运行;必须显式使用返回的新结构。纯 API 把这种数据依赖暴露在参数里,也把遗漏接回返回值的责任留给调用者。
保存一个组合后的 Lens 通常不需要保存某张订单。它描述的是路径,订单在 get、set 或 modify 调用时传入。若 setter 闭包意外捕获创建 Lens 时的旧订单,后续更新就可能覆盖其他订单的字段;对两张不同订单复用同一 Lens 的测试,可以揭露这种错误绑定。
可复现实验与修改题
运行 node examples/functional-programming/run.mjs E03,实际输出为:
1 | |
完整输入在Main.scala,本次 Scala 3.3.7 的命令与断言结果在result.json。这组测试检查的是有限订单与字符串样本,未使用随机生成器,也没有把样本通过当作定律的普遍证明。
手工自测:给城市 setter 增加 paid=false,哪一条最容易找到反例?取已支付订单并把原城市原样写回,第一条就会失败。若 setter 把城市拼接到旧城市末尾,则连续写 a、b 不等于只写 b,第三条暴露累积行为。
修改题是把 Card 改成包含 issuer 与 last4 的完整数据类型,先写保留完整分支的 Prism,再写尾号 Lens。至少验证现金原样保留、发卡行不变、修改尾号与显式模式匹配复制相等。不要为了让旧测试编译而把 issuer 固定成常量;这项练习的验收点就是往返时不丢失尚未关注的数据。

