为字段新增编码逻辑,哪些工作可以自动完成

订单行包含商品名称和数量。手写一个文本编码器很直接:

1
2
3
final case class Item(name: String, quantity: Int)
val item = Item("book", 2)
val handwritten = s"Item(${item.name},${item.quantity})"

增加一个币种字段后,要同步修改编码逻辑;把订单行放进购物篮,外层又需要组合编码器;再遇到“叶子或分支”的递归树,手写模式匹配还要覆盖每个分支。重复部分确实与类型结构有关,但哪些步骤能够派生,哪些仍然需要业务定义,必须先区分。

本章实现一个教学用 Encoder[A],输出类似 Basket(Item(book,2)) 的文本。它不转义字符串,不生成 JSON,也没有解码器,因此不是可直接用于持久化协议的序列化库。实验目的是解释结构信息怎样组合现有行为,以及递归在哪些阶段可能失败。

环境固定为 Scala 3.3.7、Scala CLI 1.9.1、JDK 21.0.11。完整程序位于 examples/scala-lab/snippets/26/Chapter26.scala。空产品、嵌套产品、enum、有限递归、新增字段、循环对象和初始化失败分别有真实断言。编译反例独立存放,不与正常源码共同编译。

derives 需要一个真实的派生入口

类型类只规定行为接口:

1
2
trait Encoder[A]:
def encode(value: A): String

case class Item(...) derives Encoder 并不是编译器天然理解“编码”的含义。它需要 Encoder 的伴生对象提供适当的 derived 方法。这个入口返回 Encoder[Item],其实现决定字段怎么组合、字符串怎么处理、错误怎么报告。

本例先提供整数和字符串实例。字符串原样输出,整数使用十进制表示。这两项是语义选择,不由 Mirror 决定。Mirror 能告诉派生逻辑“这个字段是字符串”,不能决定文本中逗号、括号、换行应如何转义。把结构发现与格式策略分开,才能看清哪些行为已经被验证。

derives 会在相应位置生成给定实例定义,使调用者可以通过 summon[Encoder[Item]] 获得编码器。若某个类型类没有支持派生的入口,单独加上关键字不能使它自动具备实现。即使入口存在,也可能不支持该类型的结构或字段。

这意味着派生不是取消了接口设计,而是把重复实现集中到类型类作者维护的一处。派生入口的错误会影响所有使用方,所以它需要比单个手写实例更明确的边界测试。一个非递归 case class 能运行,只能证明很小的一部分路径。

Mirror 提供结构,不提供业务算法

Mirror.ProductOf[A] 描述产品类型。对 Item 而言,元素类型顺序对应 (String, Int);对空 case class,元素类型是 EmptyTuple。Mirror.SumOf[A] 描述求和类型,例如 enum 的各分支,并提供运行时分支编号 ordinal。

产品与求和的区别可以从值构造理解。一个产品值同时拥有所有字段,因此编码需要按顺序消费每个字段。一个求和值在某次运行中属于某个分支,因此先确定当前分支,再使用该分支的编码器。把二者都概括为“遍历所有子类型”,会错误地让求和值编码所有分支。

字段类型、字段标签和类型标签主要通过 Mirror 的关联类型提供。它们可以被 inline 逻辑读取;必要时,标签通过 constValue 转成运行时字符串。Mirror 并不要求把整套类型元信息作为一个普通反射列表存放在每个业务对象中。哪些信息实际进入产物,由派生实现使用什么决定。

版本细节也需要精确。官网文档为了说明概念展示了一份 Mirror 结构;Scala 3.3.7 的实际库源码把若干关联成员放进 Of、ProductOf、SumOf 的细化别名中。阅读概念图可以理解职责,引用具体声明则应核对冻结源码。

编译器能为受支持的产品、枚举以及满足条件的封闭层次合成 Mirror,但不是所有任意类型都有镜像。本章对开放 trait Open 请求 Mirror.Of[Open],实际诊断说明它既不是支持的产品,也不是支持的封闭求和类型。缺少 Mirror 时应调整派生范围或提供手写实例,不能把结构未知解释成某个空结构。

先沿类型 tuple 生成字段编码器

派生实现的第一条路径发生在编译期:按字段类型 tuple 选择每个字段所需的编码器。

1
2
3
4
5
6
inline def elements[Ts <: Tuple]: List[() => Encoder[Any]] =
inline erasedValue[Ts] match
case _: EmptyTuple => Nil
case _: (h *: t) =>
(() => element[h].asInstanceOf[Encoder[Any]]) ::
elements[t]

EmptyTuple 是终止情况;非空 tuple 拆出头类型 h,并继续处理尾部 t。这段递归按类型结构前进,不取业务对象中的字段值。每个头类型对应一个延迟取得编码器的函数,真正编码时才调用它。

element[A] 先尝试搜索已有 Encoder[A]。如果没有已有实例,但能够获得 Mirror.Of[A],才递归派生。这个先后顺序使业务手写实例能够覆盖自动结构行为,也让递归数据中的根实例有机会被重新使用,而不是每次遇到自身类型都无限展开。

1
2
3
inline def element[A]: Encoder[A] = summonFrom:
case e: Encoder[A] => e
case m: Mirror.Of[A] => derived[A](using m)

这里有一个局部转换到 Encoder[Any]。它用于把异构字段编码器放入同一列表,不能作为通用“类型已安全”的证明。实现依赖两个顺序完全一致:Mirror 元素类型的定义顺序,以及运行时 Product.productIterator 的字段顺序。只有对应位置的值交给对应编码器,这个内部擦除表示才符合设计。

因此本章不把“编译成功”当成转换正确性的全部证据。Item、Basket、空产品和新增字段分别验证不同结构;若以后改变遍历方式或重新排序字段,必须重新检查这些不变量。对生产库,还需要更多生成测试以及格式语义测试。

字段编码器的集合在本例中保存为 lazy val,列表元素又是延迟函数。前者推迟构造列表,后者推迟取出关联实例。它们解决的时点问题不同,不能因为二者都“延迟”就随意删掉其中一个。若要优化实例缓存,必须先证明递归初始化仍然成立,再测量性能。

产品编码读取实际值,空产品自然结束

完成结构选择后,运行时产品编码器取得字段值,再与对应编码器逐一组合:

1
2
3
4
5
def encode(value: A): String =
val values = value.asInstanceOf[Product].productIterator
fields.iterator.zip(values)
.map((e, v) => e().encode(v))
.mkString(label + "(", ",", ")")

对 Item("book", 2),字段类型路径生成字符串与整数编码器;运行值路径产生 "book" 与 2;按位置结合后生成 Item(book,2)。手写输出与派生输出的断言相等,证明在这个数据形状下组合没有遗漏或交换字段。

空产品的字段 tuple 与运行时迭代器都为空,最终输出 Empty()。这是一个必要边界:如果实现假设至少存在一个头字段,就会在空产品上失败。空结构不需要一套完全独立的算法,终止分支与拼接格式共同给出正确结果。

Basket(Item("book", 2)) 验证嵌套组合。外层只有一个字段,编码时调用 Encoder[Item],输出嵌套文本。它不会把 Item 的字段直接摊平,因为当前策略保留每一层产品的类型标签。若业务希望摊平结构,那属于另一套格式规则。

新增 ItemV2(name, quantity, currency) 后,字段 tuple 多出一个字符串位置,实际输出为 ItemV2(book,2,CNY)。正例包含这项断言。预测变化时,先看类型标签是否变化,再看字段定义顺序;不要只说“派生会自动更新”,因为重排字段也会改变输出协议。

本例没有输出字段名称,而是按位置编码。Mirror 虽然提供元素标签类型,但实现没有使用它们。由此可以推导一个明确限制:字段重命名可能不改变这份位置文本,字段重排却会改变。派生提供多少元信息,与最终格式使用多少元信息,是两个不同层次。

求和编码只选择当前分支

递归树定义为:

1
2
3
enum Tree derives Encoder:
case Leaf(value: Int)
case Branch(left: Tree, right: Tree)

Mirror.SumOf[Tree] 的元素类型按照分支定义顺序列出叶子和分支。运行时编码器取得 ordinal(value),选择对应位置的分支编码器,然后编码这个实际值。叶子使用产品编码处理整数,分支使用产品编码处理两个子树。

正例的树为 Branch(Leaf(1), Branch(Leaf(2), Leaf(3))),输出也保留相同结构。这个输入比只编码一个叶子更有辨别力:它同时经过求和选择、产品字段处理、递归根实例复用和终止叶子。任何一个环节选错,都可能改变结果或导致异常。

分支编号是当前 Mirror 布局中的选择依据,不应直接当成永久存储协议编号。调整 enum 分支顺序会改变关联顺序;如果业务把编号写入文件或网络,就需要独立、稳定的协议设计。编译器内部顺序与对外兼容契约不能混为一谈。

同样,自动拥有每个分支的编码器,不等于编码器格式具有业务可逆性。本例没有解析器,字符串又没有转义,因此存在文本歧义。可以用它学习派生机制,不能把“所有 case 都处理了”解释为“编码格式已经安全用于外部输入”。

递归的第一处风险:编译展开永远不停止

如果 Branch 字段是 Tree,派生时再次遇到 Tree 很正常。正确策略需要复用已经定义的根 Encoder[Tree]。若每次都无条件调用 derived[Tree],编译期会再次展开求和结构,再次看到分支和树字段,形成无限展开。

隔离反例 negative/26/recursive-expansion 用真实 Mirror 结构缩小这个问题:Loop(next: Loop) 的派生逻辑无条件为首字段递归派生。字段类型还是 Loop,所以没有结构进展,也没有已有实例复用。冻结编译器最终报出 Maximal number of successive inlines,不是运行时抛出栈溢出。

这个反例的作用是定位编译阶段风险,并不试图提供完整产品派生器。只保留一字段就足以证明递归展开没有终止。把 inline 上限提高只能延后拒绝;修复应在实例搜索与派生策略上停止重复展开,而不是把资源限制当成根因。

缺失字段实例是另一类编译错误。一个产品字段为 java.time.Instant,却没有对应编码实例,最小派生入口请求该实例时会报 No given instance。该错误与无限展开不同:前者缺少语义实现,后者存在循环推导。诊断应指导调用者补实例还是修改派生算法,不能都转换成含糊的“派生失败”。

递归的第二处风险:实例存在但还没初始化

编译器能够找到根实例,不代表运行时取用它的时点一定正确。若初始化某个编码器时立即读取另一个尚未赋值的字段,就可能读到 JVM 默认值。正例用一个局部 Eager 类单独演示这个问题:

1
2
3
final class Eager:
val first: String = second.encode(1)
val second: Encoder[Int] = summon[Encoder[Int]]

构造时先求 first,它访问的 second 还未完成初始化,实验捕获到 NullPointerException。这是一个普通字段初始化反例,用于隔离时点问题;它不声称编译器生成的所有 derives 实例都会发生该错误。

派生中的 lazy val fields 和取实例函数,使某些关联在真正编码时才被读取。递归根实例有机会先完成构造,再参与后续值遍历。标准库 Option[A] 编码实例也使用按名上下文参数接收 Encoder[A],避免在构造递归实例链时过早强取它。

延迟不是无条件修复。如果延迟值第一次求值时仍立即调用自身,初始化可能继续循环;如果构造过程中直接开始编码一个递归值,也可能提前触发原本打算推迟的读取。正确性需要画出“创建实例、创建字段表、取出字段实例、编码值”的时间顺序。

因此本章把初始化失败与编译展开失败分开运行。前者在正常程序中捕获一个确定异常,后者放在编译反例目录中要求非零退出。二者都叫递归相关问题,却由不同输入和不同阶段触发,修复方法也不同。

递归的第三处风险:数据本身有环

Node(var next: Option[Node]) 可以构成有限链,也可以构成环。正例先编码有限结构 Node(Some(Node(None))),预期得到对应嵌套文本;随后让一个节点的 next 指回自己,再调用同一个已经成功创建的编码器。

循环数据会使运行时遍历反复编码同一个节点。当前实现没有已访问集合、深度上限或环引用协议,实验最终捕获 StackOverflowError。这证明编译成功、实例初始化成功、有限递归成功,都不足以推出任意对象图编码可以终止。

该失败仅用于受控本地实验,程序捕获之后输出稳定标签,不依赖具体递归深度或异常堆栈长度。生产编码器不应把耗尽调用栈作为正常检测机制。应根据协议选择拒绝环、记录对象标识或限制深度,并在资源耗尽之前报告明确错误。

不可变树通常可以通过构造约束避免这类可变回边,但“不使用 var”也不自动证明所有数据来源都无环。反序列化、Java 对象和共享图结构有各自边界。编码器作者应明确支持树、无环图还是任意图,而非用递归类型声明替代数据结构契约。

内部转换的证明责任在哪里

泛型派生器通常要把“每个字段有自己的类型”暂时放进统一运行时容器。本例使用 Encoder[Any] 加 Product 迭代,这样写代码简短,但证明责任集中在派生器内部。公开接口依然是 Encoder[A],调用者不应该拿到那份擦除后的字段编码器列表。

首先,元素数量必须一致。zip 在其中一个迭代器结束时就结束,不会自动报告另一个迭代器还有剩余。当前 Mirror 与正常 case class 产品迭代来自同一个结构,字段数量应一致;若未来允许用户提供任意 Mirror 或自定义 Product,就不能依赖这个假设而不检查。测试空产品和多字段产品能发现部分问题,却不是对所有自定义结构的证明。

其次,元素顺序必须一致。把两个不同类型字段交换后交给错误编码器,可能直接在转换处抛异常,也可能在底层表示相容时产生错误文本。测试应选容易区分的字段值,不能让所有字段都使用同一个数字或同一个字符串,否则顺序错误可能被掩盖。

第三,字段实例的业务含义必须明确。两个字符串字段都找到 Encoder[String],只说明类型匹配,不说明业务都希望使用同一种格式。金额、用户输入和敏感标识可能需要不同策略。可以用领域包装类型建立区别,或为整个产品提供手写实例;如果所有字段都降成 String,派生器没有足够信息恢复这些语义。

第四,实例搜索的作用域会影响实际策略。element 优先使用已有实例,因此导入或伴生实例变化可能改变派生结果。应把策略放在可审查的模块边界,并避免在同一作用域随意提供多个同类型编码器。派生只是自动进行组合,不会替调用者解决策略一致性问题。

这四项约束使派生器更像一个需要验证的通用算法,而非若干语法糖拼接。编译期类型信息减少了手写字段清单,但在擦除容器中重新对应类型与值时,仍需小心维护不变量。将这些细节封装在一个入口,才能让普通业务代码继续只面对强类型接口。

延迟取实例也不等于自动缓存所有工作

lazy val fields 保证本例字段函数列表按需求初始化,而列表中的每个函数在编码时都会被调用。函数体可能返回已有实例,也可能经过自动派生路径取得一个新实例。因而“字段表被缓存”与“所有子编码器都只构造一次”并不是同一个承诺。

这点对正确性和性能都重要。若有人把字段函数改成直接保存编码器,可能减少重复工作,也可能重新引入递归初始化风险。若有人每次都重建完整字段表,可能保持正确输出,却增加分配。两类修改需要分别检查实例生命周期与真实负载,不能仅凭代码行数判断改进。

本章只验证给定形状的输出和失败边界,没有统计实例构造次数,也没有声称派生器性能最优。后续若要优化,可先为实例构造加入独立计数,再用有限递归和环反例确认语义未变,最后使用基准测试衡量成本。把这些步骤分开能避免为了少创建一个对象而破坏递归安全。

三条路径的验收不能合并

路径 主要输入 本章观测
编译期结构遍历 类型 tuple、Mirror、可搜索实例 正常派生成功;无条件递归派生超过展开限制
实例初始化 给定实例与字段求值顺序 延迟组合成功;提前读取字段出现空指针
运行时编码 具体产品、分支、对象引用 有限数据得到预期文本;有环节点发生栈溢出

这张表也说明为什么测试只写 summon[Encoder[Tree]] 不够。它最多覆盖实例能否被取得,没有调用编码方法,也就没有触发产品迭代、分支选择或数据环。反过来,只对一个手写编码器运行树数据,也不能证明泛型派生路径正确。

工程迁移应先保留手写实例作为语义基准,为空结构、嵌套和分支补齐测试,再用派生替换重复部分。每替换一个类型,都比较输出而非只比较能否编译。对递归类型额外检查实例初始化,对开放第三方类型明确提供手写策略。

若最终需要生产序列化,转义、数值范围、错误路径、版本兼容和解码往返都需要单独设计。本章有意不把这些未实现能力包装成“完整 Encoder”。结构派生减少重复代码,格式协议仍由库作者承担。

可重跑记录与练习

本章为 LAB_VERIFIED。执行:

1
2
python3 examples/scala-lab/run.py chapter 26 --run-id local-ch26
python3 examples/scala-lab/run.py negative 26 --run-id local-ch26

最终证据目录为 examples/scala-lab/evidence/20261002-ch26-r3/,保存正常入口和三个反例的命令、退出码、原始输出。正常输出末尾包含 cycle=StackOverflowError eager-init=NullPointerException,这两个标签表示预期反例已被捕获,不表示整个程序失败。正常进程退出零,编译反例分别要求非零。

手算题:将 ItemV2 的字段改为 name, currency, quantity,当前格式会怎样变化?答案为 ItemV2(book,CNY,2)。因为类型 tuple 和产品值顺序都随定义顺序改变,派生不按字段名称重新排序。若协议要求旧输出不变,应另外设计字段映射,不应依赖原位置偶然稳定。

执行练习增加一个没有字段的 enum 分支,为它编码并写断言;然后给产品增加一个缺少实例的第三方字段,保存诊断,再提供手写实例使它通过。答案需要分别说明求和的分支选择、空产品的终止情况,以及缺失语义实例的修复来源。

最后为 Node 编码增加受控深度限制,在达到限制时返回明确错误,而不是等待栈溢出。要求有限链成功、循环节点在固定深度失败,并记录遍历次数。这个修改属于运行时算法,不能通过把 fields 改成 eager 或增加 inline 上限解决。

参考

主要依据为 Scala 3.3.7 派生文档、Mirror 库声明与本章正常和拒绝实验。下一篇用 Quotes 和 splices 直接生成有类型的代码,继续检查求值次数与阶段边界。

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