类型类(type class) 把"某类型具备某能力"表达为"存在一个携带该能力的 given 实例",不需要修改原类型、不建立继承关系。它是 Haskell 借给 Scala 的武器,也是 Cats、Spark Encoder 等生态的统一模式——本章所学在此收拢。
继承方案要求改类型源头,且 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 由 User 与 BigDecimal 的 Show 组合而成。第三方库(如 mirror-based derivation)能把这步自动化到一行 derives 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 编解码)全部基于这一步的自动化。
| 判断问题 | 继承 | 类型类 |
|---|---|---|
| 能力是否是类型的本质 | 是 | 否 |
| 类型源码能否修改 | 能 | 不能(或不愿) |
| 实例是否全局唯一 | 天然是 | 可多实例(如多种 Ord) |
| 运行时开销 | 虚调用 | 静态分发 |
最后一条是隐藏福利:类型类实例在编译期解析,没有反射、没有装箱热点,这也是 Spark Encoder 高性能的秘密。
第一种:实例定义在伴生对象之外又没导入——把 given 移进伴生对象或在使用处 import;第二种:类型不完全匹配,你要 Show[List[Temp]] 但只定义了 Show[Temp],需要补一层 given [A: Show] => Show[List[A]];第三种:跨模块循环依赖导致实例初始化为 null。三者共用同一条排查思路:在报错处直接 summon[目标类型] 验证证据是否可见,可见性确认后再查匹配精度。
再补一条与继承的深层差异:类型类实例也是值,可以在运行时选择与替换——比如测试时注入一个把输出固定化的 Show 实例,这种能力注入在继承体系里要靠依赖注入框架才能勉强做到。
类型类派生(derives Show 这样的子句)能把第三节的手写实例自动化,编译器按结构生成默认实现,需要覆盖时再手写覆盖,两者可共存,这正是 Scala 3 派生机制设计上的体贴之处。
[A: Show] 约束,能力与类型解耦。