5.4 类型类:无继承的扩展


5.4 类型类:无继承的扩展

类型类(type class) 把"某类型具备某能力"表达为"存在一个携带该能力的 given 实例",不需要修改原类型、不建立继承关系。它是 Haskell 借给 Scala 的武器,也是 Cats、Spark Encoder 等生态的统一模式——本章所学在此收拢。

问题:给 String 加"可渲染"能力

继承方案要求改类型源头,且 String 是 final 的,路走不通。类型类三步走:

// 1. 定义能力 trait(类型类本身) trait Show[A]: extension (a: A) def show: String // 2. 为需要的类型提供实例 given Show[Int] with extension (a: Int) def show: String = a.toString given Show[User] with extension (u: User) def show: String = s"${u.id}:${u.name}" // 3. 能力约束在类型参数上 def render[A: Show](items: List[A]): String = items.map(a => summon[Show[A]].show(a)).mkString(", ")

render 只依赖"存在 Show 实例"这个事实,能力与类型彻底解耦。给自己的类型、给第三方类型、甚至给 Int 这种系统类型挂能力,三种情形写法完全一致——这是继承做不到的。

与继承的对照实验

需求:日志系统要求所有可记录对象提供 logLine。两种实现的关键差异:

维度 继承方案 类型类方案
能否给第三方类型加能力 不能
能力可否按上下文切换 不能,实现烧死在类里 能,局部 given 覆盖
运行时分派 虚方法 编译期静态(无反射开销)
约束表达 父类型约束 [A: Show] 上下文边界

类型类不是取代继承——is-a 关系用继承,can-do 关系用类型类

类型类的组装位置

类型类的组装位置

派生实例:能力的自动组装

复杂类型的能力可以从成员能力"拼装"出来:

given Show[Order] with extension (o: Order) def show: String = s"Order(${o.user.show}, ${o.amount.show})"

Order 的 Show 由 UserBigDecimal 的 Show 组合而成。第三方库(如 mirror-based derivation)能把这步自动化到一行 derives Show组合性是类型类相对继承的决定性优势:能力像乐高一样从零件长成,而继承体系每加一层都得重新设计。

图 5-3 类型类的能力注入模型

图 5-3 类型类的能力注入模型

完整案例:从零手写 Show 类型类

背景:日志系统要求任意类型打印成可读文本,但不能侵入业务类型。三步走完类型类的完整闭环:

trait Show[A]: // 第一步:声明能力 extension (a: A) def show: String given Show[Int] with // 第二步:为既有类型提供实例 extension (a: Int) def show = a.toString given Show[Boolean] with extension (a: Boolean) def show = if a then "yes" else "no" case class Temp(deg: Double) given Show[Temp] with extension (t: Temp) def show = f"${t.deg}%.1f°C" def log[A: Show](a: A): Unit = // 第三步:以上下文边界消费 println(summon[Show[A]].show(a)) log(true) // yes log(Temp(36.6)) // 36.6°C

结果:Int、Boolean 与自家 Temp 都获得了 show 能力,而它们的定义处没有一行改动。解读:继承是"我是什么",类型类是"我能被怎样对待"——能力定义、实例提供、能力消费三者彻底解耦,第三方可以给你的类型补实例,也可以给别人的类型配你的能力。变式:为 Option[A] 写跨类型实例 given [A: Show] => Show[Option[A]],一层递归就能让整棵数据树可打印——库派生(如 Circe 的 JSON 编解码)全部基于这一步的自动化。

类型类 vs 继承的判据

判断问题 继承 类型类
能力是否是类型的本质
类型源码能否修改 不能(或不愿)
实例是否全局唯一 天然是 可多实例(如多种 Ord)
运行时开销 虚调用 静态分发

最后一条是隐藏福利:类型类实例在编译期解析,没有反射、没有装箱热点,这也是 Spark Encoder 高性能的秘密。

排错实录:找不到实例的三种典型原因

第一种:实例定义在伴生对象之外又没导入——把 given 移进伴生对象或在使用处 import;第二种:类型不完全匹配,你要 Show[List[Temp]] 但只定义了 Show[Temp],需要补一层 given [A: Show] => Show[List[A]];第三种:跨模块循环依赖导致实例初始化为 null。三者共用同一条排查思路:在报错处直接 summon[目标类型] 验证证据是否可见,可见性确认后再查匹配精度。

再补一条与继承的深层差异:类型类实例也是值,可以在运行时选择与替换——比如测试时注入一个把输出固定化的 Show 实例,这种能力注入在继承体系里要靠依赖注入框架才能勉强做到。

类型类派生(derives Show 这样的子句)能把第三节的手写实例自动化,编译器按结构生成默认实现,需要覆盖时再手写覆盖,两者可共存,这正是 Scala 3 派生机制设计上的体贴之处。

本节要点回顾

  • 三步:能力 trait + given 实例 + [A: Show] 约束,能力与类型解耦。
  • is-a 用继承,can-do 用类型类;第三方类型扩展只有后者可行。
  • 实例可按作用域覆盖,静态分派无反射开销。
  • 派生与组合让能力自底向上生长,是整个 Scala 生态的模式底座。

作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U