Scala 19:Type class,不修改业务类型也能添加行为
Order 没有 encode 方法,怎样进入通用编码流程
订单类型可能来自另一个模块,不能为了每个输出格式都修改它。若把编码方法直接放进 Order,又会遇到同一种订单需要简洁文本、审计文本和外部协议三种策略的问题。编码行为与订单数据可以分离,由一个独立对象说明某种类型怎样被编码。
这种组织方式通常称为类型类。Scala 中不需要特殊的 typeclass 关键字:定义带类型参数的行为接口,为具体类型提供实现,再让通用算法通过参数接收该实现。given 与 using 可以减少重复传递,但不是类型类成立的唯一条件,显式传普通对象同样能表达核心设计。
本文使用 Scala 3.3.7、标准库 2.13.16 和 JDK 21,定义最小 Encoder[A],组合嵌套列表实例,并显式选择两种 Order 编码。输出只是教学文本,不是完整 JSON 或稳定外部协议。实验不引入第三方库,也不把实例搜索成功当作编码语义正确的证明。
行为接口接收数据,而不是要求数据继承它
1 | |
Encoder[A] 是能编码 A 的对象类型,A 本身不需要继承 Encoder。render 同时获得业务值与对应行为,两者通过类型参数 A 联系起来。传入 Order 时需要 Encoder[Order],不能拿 Encoder[Int] 冒充。
这种关系与 trait Encodable { def encode: String } 不同。后者要求数据对象自己实现行为;前者允许同一种数据配不同实例,也允许为无法修改的外部类型添加行为。选择取决于所有权与策略数量,而不是哪种写法更高级。
行为对象在运行时仍然是普通值。它可以有字段、委托其他实例、捕获配置,方法调用仍然执行普通代码。类型系统能保证签名匹配,却不会自动保证输出稳定、转义正确或没有副作用。实例的实现质量需要断言和协议测试。
也不能把 Encoder[A] 读成 A 的子类型限制。类型参数的上下界要求 A 与某个类型存在继承关系;上下文参数则要求调用点提供一个针对 A 的独立能力对象。后续错误信息若提示缺少实例,应检查能力来源,而不是给 Order 随意增加继承层次。
简单实例建立递归组合的终点
1 | |
这份实例把 Int 编码成十进制文本。放在 Encoder 伴生对象里,相关搜索可以找到它。它是组合编码的基础:编码整数列表时需要整数编码,编码嵌套列表时还需要由内向外构造列表编码。
将标准实例放在伴生对象能够提供清楚默认,但并不意味着每种业务格式都该成为全局默认。数字编码较容易形成统一约定,订单审计格式则可能属于具体应用模块。公开库若默认选择某种业务协议,调用者可能在不知情时采用错误输出。
命名实例也有助于显式测试。即使生产代码让编译器补全,测试可以直接调用 intEncoder.encode,确认基础行为,再测试组合层是否正确委托。把所有失败都交给一条最外层断言,会让实例搜索错误和实现错误混在一起。
这里整数转字符串不会处理货币单位、区域格式或小数舍入。若对外协议有这些要求,应定义专门数据类型和编码规则,不能直接复用裸整数实例并期待业务含义自动保留。类型类分离行为,仍需要正确选择被编码的类型。
列表实例依赖元素实例
1 | |
这不是为所有 A 凭空产生编码能力。它声明:如果已有 Encoder[A],就能构造 Encoder[List[A]]。列表结构知道怎样遍历和连接,元素内容的表示交给 element。职责边界写在 using 参数里,组合不需要检查 A 的运行时类型。
对 List[List[Int]],依赖链可以逐层展开。最外层需要 Encoder[List[List[Int]]],列表规则把问题化成 Encoder[List[Int]],再次使用列表规则需要 Encoder[Int],最终找到整数实例。运行时则按数据嵌套调用对应编码方法,编译期查找与运行期遍历不是同一条执行过程。
正常程序对 List(List(1,2),Nil) 得到 [[1,2],[]]。内层空列表仍有完整括号,因为空输入不调用元素编码,但列表本身的格式规则仍然执行。这个例子同时检查嵌套结构与空容器,不只是展示一个单值成功。
元素实例可能捕获策略。若先用某个订单编码实例构造列表编码器,之后再在别处引入另一份 given,不会自动重写已经保存的行为对象。创建时传入的依赖与稍后另一次搜索应分开分析,这与普通对象保存构造参数没有本质不同。
上下文边界是参数写法的缩写
1 | |
在本章冻结版本中,这种上下文边界表示方法需要 Encoder[A] 上下文参数。summon 用于取得对应实例。它不是创建一个神奇的静态 encode 方法,也不是让 A 自动继承 Encoder。
需要给实例起局部名字,或者方法同时接收多个业务策略时,直接写 using encoder: Encoder[A] 往往更清楚。上下文边界适合简洁表达常见能力约束,显式 using 适合说明依赖角色。两者应按可读性选择,不应为了统一外观强行采用一种写法。
边界语法在不同 Scala 版本中有新增形式,本文只使用冻结版本支持的基本冒号写法,不套用滚动文档中的新语法。复制较新示例时应先确认编译模式,而不是把语法报错误认为类型类设计本身不可行。
上下文边界也可以与子类型边界共存,因为它们检查不同关系。一个参数可能既必须属于某个业务层次,又需要一个编码实例。检查签名时应分别解释每个条件,避免把所有冒号相关语法都理解成继承。
同一 Order 的多种策略显式命名
1 | |
本章没有把二者同时声明为自动候选,而是在调用处写 using compact 或 using verbose。Order(7) 分别得到 order:7 与 Order(id=7)。这个设计让业务选择出现在使用位置,而不是依赖某个导入恰好使一个实例更容易被找到。
这也是一致性问题的具体形式:同一类型、同一行为名称,在不同范围可能选到不同实例,从而产生不同结果。Scala 允许多个策略,不会强制整个程序只有一份 Encoder[Order]。需要全局稳定协议时,应约束公开实例入口;需要多策略时,应让选择显式。
如果两个同类型实例同时作为 given 进入同一搜索范围,可能出现歧义。应像上一章那样缩小范围或显式传入,不要通过无关继承层次制造难以理解的优先级。业务的“审计格式优先”应由业务代码表达,而不是隐藏在类型特化规则里。
对于持久化或签名协议,格式变化会产生兼容影响。类型类让替换行为更容易,不代表替换就是无风险的。输出字节、字段顺序、转义和版本号仍应有固定样例测试。当前教学文本不包含任意字符串,因此没有声称覆盖完整编码安全。
扩展方法可以提供语法,但不承担实例实现
可以增加一个扩展,让 value.render 看起来像对象方法,内部仍然调用 Encoder[A]。这个语法层没有改变数据继承关系,也没有删除能力参数;只是把显式函数形式组织成更方便的调用。
排错时可以依次去掉语法糖:先把扩展调用还原成 render(value),再把上下文边界还原成 using encoder,最后显式传入具体实例。这样能判断错误发生在扩展可见性、实例搜索还是编码方法体,而不是把所有机制混成一个黑箱。
继承、扩展和类型类可以一起使用,但职责不同。继承描述数据对象的子类型关系,扩展提供调用形式,类型类实例把外部行为与数据类型连接。使用多种机制之前,应能说清楚每一种解决的具体问题,避免为一个简单格式函数引入不必要结构。
本章保留普通方法形式,是为了让字典传递清楚可见。这里的“字典”只是行为方法集合的概念模型,并不意味着实现必须使用 Map 查找字符串键。实际实例是一个普通对象,调用具有静态类型的方法。
缺少实例是一条明确的边界
隔离反例定义 Encoder 和 Order,却没有提供 Encoder[Order],然后调用 render(Order(7))。编译器应报告缺少对应给定实例。这个错误说明通用算法的能力需求确实进入了签名,而不是在运行时才根据类型名查找处理器。
修复可以显式传一份实例,或把合适默认实例放到受控范围。不能把 render 改成调用 value.toString 就称为同等修复,因为这改变了接口契约:原来要求专门编码,现在接受任意默认调试表示,输出稳定性可能完全不同。
递归实例也可能产生搜索或初始化问题。列表的递归依赖沿类型结构缩小,当前嵌套案例最终到达 Int;用户自定义递归模型未必如此。本章没有用一个嵌套列表成功来证明所有递归派生安全,自动派生和递归实例留给后续专门实验。
测试还应区分实例存在与实例正确。一个 Encoder[Order] 总返回空串也能满足类型接口。需要协议规律时,例如编码再解码回原值,应另外定义解码器和相等关系,再写往返测试;当前只验证约定文本输出,不虚构不存在的通用规律。
实例依赖也需要生命周期边界
如果编码实例捕获了可关闭的流或可变配置,保存这份实例就同时保存了相关引用。调用方能够找到 Encoder,并不说明流仍然打开,也不说明配置在两次调用之间没有变化。稳定协议的实例应尽量只依赖稳定数据;需要外部资源时,应让资源使用范围与编码执行范围明确对齐。
这种问题不会因把实例写成 given 而消失。given 解决传递和搜索,资源管理解决获取、使用与关闭,纯函数约束解决可重放性。三个机制可以协作,但需要分别留下测试证据,不能把一个成功的 summon 当成整个运行环境已经有效。
手算与修改练习
手算:编码 List(List(1), Nil) 时,需要哪些实例,结果是什么?需要整数实例、整数列表实例和整数列表的列表实例,结果 [[1],[]]。空内层不调用整数编码,但仍使用列表格式规则。
修改练习:增加 Encoder[Option[A]],Some 使用元素实例,None 输出固定缺失标记;验证 List(Some(1),None) 的组合输出。先明确缺失标记与正常元素文本是否会混淆,再决定格式。参考实现应只在 Some 分支调用元素编码,新增计数断言验证 None 不触发它。
第二项修改是为 Order 增加商品数量字段,同时更新 compact 与 verbose 的预期输出。两个策略可以保留不同展示,但都必须明确是否包含新增字段。编译器不会因为 case class 增加字段就自动要求所有手写编码器输出它,因此协议测试仍是必要证据。
实验记录与依据
源码 examples/scala-lab/snippets/19/Chapter19.scala,入口 scalaexamples.Chapter19;正常与缺少实例反例见 RUN.md。
- 冻结类型类说明:核对参数化行为接口与组合实例。
- 冻结上下文边界:核对基本语法如何形成上下文参数。
- 冻结 using 规则:核对显式实例传递。编码格式与一致性策略由本章独立定义。
前置阅读:given 与 using。
