每个写 Scala 的人,大概都经历过类似的场景:写完一小段代码,按下编译,然后盯着屏幕发呆。轻则十几秒,重则几分钟。尤其是那些依赖了 Shapeless 的项目,那种等待感会更明显——明明业务逻辑不复杂,编译却慢得让你怀疑人生。慢慢地,大家开始嘀咕:"是不是这个类型级编程的东西拖慢了编译?"
一、故事要从一次编译等待说起
有一回我接手了一个数据处理的小服务。代码量不大,总共不到一万行,可每次改动一个公共文件,再跑编译,都要干等两三分钟。项目里大量使用了 Shapeless 来写通用的序列化和反序列化逻辑,思路很优雅,数据类的定义也干净。但团队里的人渐渐都养成了一个习惯:改完代码先去泡杯咖啡,因为知道编译没那么快。
后来我把其中一部分通用逻辑换成普通的 case class 写法,编译时间肉眼可见地降了下来。更有意思的是,业务功能一点都没变。这让我不得不重新思考一个问题:那些看起来很高级的类型级编程技巧,到底值不值得用在所有地方?
二、什么是类型级编程
2.1 编译器在替你"跑程序"
先举个简单的例子。普通编程是在运行期计算,比如你写一个函数处理列表,程序跑起来之后才真正执行。类型级编程则是把一部分"计算"搬到编译期,让类型系统帮你推导出结果。比如你写了一个类型表达式,编译器通过隐式解析去推导出正确的类型,这种推导过程在代码量小的时候没什么感觉,一旦类型复杂、使用频繁,编译器的工作量就成倍增加。
2.2 认识 Shapeless 里的两个老朋友
Shapeless 是 Scala 生态里做类型级编程最出名的库,它提供了一堆工具,其中被用得最多的就是 HList 和 LabelledGeneric。HList 可以理解成一个"类型敏感的链表",每个元素的类型都记录在类型里,编译器在编译期就知道整个链表的完整结构。LabelledGeneric 则是把普通的 case class 和 HList 之间架起一座桥,让编译器能够在编译期把 case class 的字段转换成类型级别的结构。有了这两个东西,你就能写出"不管 case class 长什么样,都能自动处理"的通用代码。
2.3 一个最简单的 Shapeless 实验
我们直接看代码,感受一下它的用法:
// 技术栈:Scala 2.13 + Shapeless 2.3.x
import shapeless._
// 定义一个普通的 case class
case class Book(title: String, pages: Int)
// LabelledGeneric 会把 Book 自动转换成带字段名的 HList
val bookGen = LabelledGeneric[Book]
// 转过去
val hlist = bookGen.to(Book("Scala 入门", 320))
println(hlist)
// 输出: Scala 入门 :: 320 :: HNil
// 再转回来
val back = bookGen.from(hlist)
println(back)
// 输出: Book(Scala 入门,320)
看起来很简单对吧?但是注意,LabelledGeneric[Book] 这个调用,编译器需要在编译期生成一个完整的隐式证据,它要分析 Book 的每个字段、字段顺序、字段类型,然后构造出对应的类型结构。这在底层会展开非常多层的类型计算。一个 Book 有两个字段还轻松,要是某个 case class 有十几个字段,又或者代码里到处都在做这种转换,编译器的工作量就不是一点半点了。
三、编译时间膨胀是怎么发生的
3.1 类型推导的"雪球效应"
问题在于类型推导是会"传染"的。假设你写了一个通用的打印函数,接受任意 case class,内部用 Shapeless 把它的所有字段拎出来。只要有一个地方调用这个函数,编译器就要对传入的类型做完整的类型级别展开。如果这个函数又被别的泛型函数调用,那编译器就得在这条调用链的每一个环节都做一次推导。就像一个雪球滚下坡,代码看起来只有几行,编译器背后的计算量却是指数级的。
让我们看一个更具代表性的场景:用 Shapeless 写一个通用的 CSV 导出工具。
// 技术栈:Scala 2.13 + Shapeless 2.3.x
import shapeless._
import shapeless.ops.hlist._
// 这个对象负责把任意 case class 转成 CSV 行
object CsvExporter {
// 核心方法:利用 HList 的 Mapper 将每个字段转成字符串
// 整个转换过程在编译期展开,运行期没有任何反射
def toCsvRow[A, L <: HList](value: A)(implicit
gen: LabelledGeneric.Aux[A, L],
mapper: Mapper[FieldToString.type, L]
): String = {
val hlist = gen.to(value) // 把 case class 转成 HList
val mapped = hlist.map(FieldToString) // 逐个字段转成字符串
mapped.mkString(",") // 用逗号拼接成一行
}
// 定义字段到字符串的转换策略
object FieldToString extends Poly1 {
implicit def anyToString[T]: Case.Aux[T, String] =
at[T](t => String.valueOf(t))
}
}
// 定义两个普通的 case class
case class Order(id: Long, amount: BigDecimal)
case class Customer(name: String, level: Int)
object Main extends App {
// 每次调用都会触发一次完整的编译期推导
println(CsvExporter.toCsvRow(Order(1001L, BigDecimal("99.50"))))
println(CsvExporter.toCsvRow(Customer("王小明", 3)))
}
这段代码看起来很优雅,运行时完全不需要反射,性能也很好。但代价就是:编译器在编译时,要对 Mapper 做完整的类型级递归展开,把一个 HList 里的每个元素的类型逐一映射成 String。Order 和 Customer 还好,字段少,想象一下如果有一个 20 个字段的实体类呢?
3.2 实际感受一下差距
为了更直观,我们写一个普通 case class 的版本,不用任何 Shapeless:
// 技术栈:Scala 2.13(普通写法)
case class Order(id: Long, amount: BigDecimal)
object PlainCsv {
// 普通的写法,直接拼接字段
def toCsvRow(order: Order): String = {
List(order.id.toString, order.amount.toString).mkString(",")
}
}
object Main extends App {
println(PlainCsv.toCsvRow(Order(1001L, BigDecimal("99.50"))))
}
这两种写法,功能几乎一样——把一个 case class 的输出格式化成 CSV。普通版本编译器只需要检查两个字段的类型,然后生成一个简单的列表拼接。Shapeless 版本编译器要做的事情,至少多了几十倍的推导步骤。在只有一个字段少类型简单的时候差别不大,一旦项目里有几十处这种调用,编译时间的天壤之别就出来了。
四、什么时候该用 Shapeless
4.1 适合用 Shapeless 的场景
Shapeless 真正的用武之地,是那些"类型可以千变万化"的底层框架。比如你写一个 ORM 框架,要支持用户传入任意 case class 并自动生成数据库表结构;或者是写一个通用的 JSON 序列化工具,让每个 case class 都自动拥有序列化能力;再比如写事件溯源框架,需要把不同事件类型统一处理。这些场合,类型不固定,开发者本人也不知道用户会传入什么类型,用普通写法根本没法实现,Shapeless 几乎是唯一方便的选择。
举一个适合用它的例子:一个通用的 diff 工具,比较两个同类型对象的字段差异,而且是通用的。
// 技术栈:Scala 2.13 + Shapeless 2.3.x
import shapeless._
// 使用类型类递归的方式,在编译期逐个对比字段
object DiffTool {
// 定义一个类型类,表示"能够对比两个 A 的所有字段"
trait FieldDiff[A] {
def diff(a: A, b: A): List[String]
}
// 为 HList 提供递归的实例:空链表的情况
implicit val hnilDiff: FieldDiff[HNil] = new FieldDiff[HNil] {
def diff(a: HNil, b: HNil): List[String] = Nil
}
// 为 HList 提供递归的实例:有头有尾的情况
implicit def hconsDiff[H, T <: HList](
implicit tailDiff: FieldDiff[T]
): FieldDiff[H :: T] = new FieldDiff[H :: T] {
def diff(a: H :: T, b: H :: T): List[String] = {
// 比较当前头部的字段值
val headDiff = if (a.head != b.head) {
List(s"字段值不同: ${a.head} => ${b.head}")
} else Nil
// 递归比较剩余的尾部队列
headDiff ++ tailDiff.diff(a.tail, b.tail)
}
}
// 入口:把 case class 转成 HList 后交给 FieldDiff 处理
def compare[A, L <: HList](a: A, b: A)(implicit
gen: LabelledGeneric.Aux[A, L],
diff: FieldDiff[L]
): List[String] = diff.diff(gen.to(a), gen.to(b))
}
// 使用的时候,任何 case class 都可以直接传
case class Person(name: String, age: Int, city: String)
object Main extends App {
val p1 = Person("小明", 18, "上海")
val p2 = Person("小明", 20, "北京")
DiffTool.compare(p1, p2).foreach(println)
// 输出:
// 字段值不同: 18 => 20
// 字段值不同: 上海 => 北京
}
像这种通用能力,用普通 case class 是做不到的。因为普通写法必须提前知道类的具体结构。所以这里 Shapeless 的价值是无可替代的。
4.2 不适合用 Shapeless 的场景
反过来,如果你只是在业务代码里处理几个固定的 case class,比如订单、用户、商品,字段都写死了,那么 Shapeless 就是典型的"大炮打蚊子"。你完全可以手动写一个转换函数,代码也就多写几行,编译时间却快了几十倍。更别提 Shapeless 编译错误信息极其难读,一旦类型推导哪里出问题,那报错信息简直是一堵墙,很多初学者看到那个就头疼。维护这样的代码,对团队里不熟悉类型级编程的人来说,也是一种负担。
五、如何平衡
5.1 先想清楚"通用边界"在哪里
平衡的关键在于搞清楚一个问题:你的代码真的需要处理未知类型吗?如果答案是"需要",比如你在写框架,那就放心用 Shapeless;如果答案是"我只需要处理这三个固定的类",那就老老实实写普通 case class 的方法。很多项目的问题就是把 Shapeless 当成了"万能便利贴",什么场景都往上贴,结果编译越来越慢。
一个很实用的判断标准:如果一个转换逻辑在未来半年内只可能服务于不超过五个已知类型,那就用普通写法。如果未来可能需要服务未知的类型,才考虑 Shapeless。
5.2 逐步替换的实战策略
假设你的项目里已经有大量 Shapeless 代码了,该怎么办?别急着一次性推翻重写,那样风险太大。建议按照"收益最大、改动最小"的顺序逐步替换。先统计一下哪些 Shapeless 工具被用到的频率最高,然后找出那些调用点集中、类型已知的地方,优先替换成普通写法。比如上面那个 CSV 导出的例子,如果项目里的实体类基本固定,直接改成手动拼接。
具体操作看这个例子,把 Shapeless 的通用 CSV 转换改成只针对固定类型的版本:
// 技术栈:Scala 2.13(普通写法)
case class Order(id: Long, amount: BigDecimal)
object CsvExporter {
// 注意:这个版本只处理 Order 类型,但编译速度飞快
def toCsvRow(order: Order): String = {
List(order.id.toString, order.amount.toString).mkString(",")
}
}
如果确实需要支持多种类型,也可以用重载的方式,为每个已知类型写一个方法,编译器轻松,调用方也清楚:
// 技术栈:Scala 2.13(重载方案)
case class Order(id: Long, amount: BigDecimal)
case class Customer(name: String, level: Int)
object CsvExporter {
// 为 Order 单独写一个方法
def toCsvRow(order: Order): String = {
List(order.id.toString, order.amount.toString).mkString(",")
}
// 为 Customer 单独写一个方法
def toCsvRow(customer: Customer): String = {
List(customer.name, customer.level.toString).mkString(",")
}
}
5.3 如果决定保留 Shapeless,如何减小编译负担
如果某些通用场景确实离不开 Shapeless,也有一些小技巧可以改善编译体验。比如尽量把隐式证据放到伴生对象里,让编译器复用已有的隐式实例,而不是每次都重新推导。
// 技术栈:Scala 2.13 + Shapeless 2.3.x
import shapeless._
// 通过把 LabelledGeneric 放在伴生对象中,编译器可以缓存并复用
case class Order(id: Long, amount: BigDecimal)
object Order {
// 伴生对象里定义,编译器会自动填充到隐式作用域
implicit val gen: LabelledGeneric[Order] = LabelledGeneric[Order]
}
另外,把大的通用函数拆分成小的块,隔离类型推导范围,也能明显减少编译器的压力。就好比你请人帮忙搬家,一次性把所有家具搬上楼,人会累垮,分几次搬反而更稳。类型推导也是同理,把推导范围缩小到单个函数,编译器不需要在一个巨大的上下文里去推算,负担自然小很多。
5.4 别忘了编译缓存的辅助
还有一个常常被忽略的角度:编译器的增量编译。在开发环境里,尽量保持代码的可增量编译特性。类型级程序往往牵一发动全身,改动一个类型,依赖它的所有下游都失效。这种情况下,编译缓存的作用就很有限。所以,把 Shapeless 代码集中收敛在独立的模块里,让业务模块只依赖它们的编译结果,而不是每次改动都重新推导一遍,效果会很明显。
六、注意事项
第一,不要迷信类型级编程。它很强,但不便宜。每一份优雅的代价都是编译时间的增加,使用前先问自己值不值。
第二,谨慎处理编译错误。Shapeless 的报错信息非常难懂,一旦推导失败,错误信息可能长达几十行。除非你非常熟练,否则排查问题的时间成本很高。建议把 Shapeless 相关代码隔离在专门的工具类里,尽量减少它在业务代码中的分布面。
第三,注意 Scala 版本兼容问题。Shapeless 的版本跟进往往滞后于 Scala 主版本。比如升级到 Scala 3 之后,可以用 Shapeless 3,它的写法跟 Shapeless 2 差别不小。如果不打算深入研究,最好在项目初期就明确要不要引入它。
第四,团队协作的角度也要考虑。类型级编程的上手门槛比普通编程高不少,如果团队里大多数人没有相关基础,维护成本会很高。新同事接手时,看到满屏的隐式参数和 Poly1,大概率是懵圈的。
七、文章总结
通篇看下来,核心矛盾其实就一句话:类型级编程把运行期的工作提前到了编译期,而编译器在编译期做的每一点计算,最终都会反映在你的等待时间上。Shapeless 给了我们强大的通用抽象能力,让我们能写出适用范围极广的代码,但这并不意味着每个场景都需要这种能力。
当你面对的是一组固定类型时,普通 case class 的写法往往更务实——编译快、易读、好维护。当你需要应对类型不确定的框架级代码时,Shapeless 几乎无可替代。好的工程师不会盲目追求最炫的技术,而是会在开发效率、运行性能、编译体验之间找到那个适合自己的平衡点。
希望读到这里,你也能对自己项目里的 Shapeless 代码做出更明智的判断。下次再遇到编译等待,别急着怪电脑,想想是不是某个隐式推导在那儿偷偷加班呢。
评论
围绕“类型级编程导致的编译时间膨胀:在Shapeless与普通case class间找平衡”参与讨论