Scala Macros — 让 Scala 在编译期把方法调用替换成任意代码
是什么
Scala Macros 是 Scala 2.10 引入的编译期元编程系统:你声明一个普通方法但加上 macro 关键字,编译器看到调用时不会真的去调你写的方法体,而是把调用现场的整段代码当数据交给宏,宏返回一段新代码,编译器把它原地替换。
日常类比:像点外卖时备注栏写”按我的口味改”。柜员(编译器)看到这条订单,不直接做菜,而是把订单纸条递给厨师(宏),厨师改完递回来一张新订单,柜员才照着新订单做。
def assert(cond: Boolean, msg: String): Unit = macro Macros.assertImpl// 调用方写:assert(x > 0, "x must positive")// 编译期被宏改写成:if (!(x > 0)) throw new AssertionError(s"x must positive, got x=$x")宏看得到 cond 的 AST(不是 cond 的值),所以可以把 x > 0 这段表达式打印进错误信息——纯运行期函数做不到,因为运行期只看到一个 true/false,源码长什么样早被 javac 丢了。
Scala 宏的能力比 C++ 模板强一个量级,比 Lisp macro 多了静态类型保障,比 Template Haskell(Haskell 的对应物)多了”类型驱动 implicit 派生”——这是它能撑起 Slick / Shapeless / Spark Catalyst 这类工业项目的关键。
为什么重要
不理解 Scala 宏,下面这些事都没法解释:
- 为什么 Slick 写
users.filter(_.age > 18)会变成WHERE age > 18的 SQL,而不是把所有 user 拉到内存过滤 - 为什么 circe / Magnolia 给 case class 自动生成 JSON encoder,一行手写也不用
- 为什么 Spark Catalyst 跑 SQL 比解释器循环快 10 倍——它在编译期生成特化的 JVM 字节码
- 为什么 Scala 2.10/2.11/2.13/3.x 之间宏代码经常整个重写——绑死了编译器内部 API 的代价
核心要点
Scala 宏的设计可以拆成 三块拼图:
-
def macro = 编译期函数:和普通方法签名一样,只是实现写在另一个方法里、接收
Tree返回Tree。类比”把代码当数据传给一个函数,函数返回新代码”——这是 Lisp 1960 年就有的想法,Scala 给它套了静态类型。 -
quasiquote
q"..."= 写代码而不是拼 AST:早期写宏要Apply(Select(Ident("x"), TermName("+")), List(Literal(Constant(1)))),意思是x + 1。quasiquote 让你直接写q"x + 1",编译器自动拆成 AST。$插值塞变量,像 JS 模板字符串一样。 -
type-driven 派生(materializer):implicit 缺一个
Encoder[User],编译器找不到时调用宏,宏在类型层把User拆成字段列表,逐字段生成 encoder 拼起来。Shapeless / circe / Magnolia 都靠这一招。
三块拼上 = 既能改写代码、又能读类型、写起来还像写 Scala。
实践案例
案例 1:Slick 把 lambda 翻译成 SQL
val q = users.filter(_.age > 18).map(_.name)// 编译期看到 lambda AST:(u: User) => u.age > 18// 宏把 AST 翻译成 SQL:SELECT name FROM users WHERE age > 18关键:宏拿到的是 _.age > 18 的语法树,不是函数值。它能识别 Select(u, age) 是列引用、> 是比较谓词,逐节点翻成 SQL。这就是 LINQ 风格 query 在 JVM 上能跑的核心机制。
如果不用宏,要么写 users.filter(_.age > 18).run 把所有 user 拉到内存再过滤(慢且贵),要么自己拼字符串 "WHERE age > 18"(拼错就 SQL 注入)。宏让 Scala 既保留语法的安全感、又把执行下推到数据库。
案例 2:circe 自动派生 JSON encoder
case class User(name: String, age: Int)val json = User("alice", 30).asJson // 编译通过,没手写 encoderasJson 需要一个 implicit Encoder[User],没人写。编译器调 circe 的 materializer 宏,宏看 User 的类型签名 → 拆出 (String, Int) → 生成 Encoder.forProduct2("name", "age")(User.unapply) → 塞回 implicit scope。全程编译期完成,运行期零反射开销。
对比 Java 生态的 Jackson:Jackson 用运行期反射,每次 encode 都要走 Field/Method 反射查找;circe 用宏在编译期把这些查找展平成直接字段访问,性能差 3-5 倍。代价是编译时间——大型项目 case class 几百个,编译能从 30 秒涨到 3 分钟。
案例 3:Spark Catalyst 用 quasiquote 编译查询
// 物理计划里某个 Project 节点val code = q""" val row = input.next() output.write(row.getInt($idx) + 1)"""// 编译成字节码 → 装进 ClassLoader → 直接跑Catalyst 把 SQL 物理计划用 quasiquote 拼成 Scala 源码片段,调 toolbox 编译成字节码。避免解释器循环——每行不再走”读节点 → 分发 → 计算”的虚函数表,而是直接 JIT 友好的内联代码。Spark 2.0 引入 whole-stage codegen 后,TPC-DS 部分查询提速 5-10 倍,这是 Spark 在大数据社区压过 Hive 的关键技术之一。
踩过的坑
- 绑死编译器内部 API:
c.universe.Tree是 nsc 内部表示,2.10 → 2.11 → 2.13 → 3.x 多次破坏式改版,Scala 3 干脆推翻成 inline + quoted 重写。维护一个 macro library 等于追编译器版本。 - 编译时间爆炸:Shapeless / circe 大量 implicit + materializer 让单次编译从几秒到几分钟,IDE 高亮卡住。Magnolia 出现就是为了减少 implicit search 的代价。
- whitebox macro 错误信息几乎不可读:宏返回类型比签名更精确(whitebox),用户看到 “inferred type T does not match expected type S”,根因藏在宏内部
c.typecheck里。 - macro annotation 长期实验:
@deriving(...)能改写 class 定义太强,编译器的增量编译模型扛不住,Scala 3 直接砍掉,改用derives+Mirror。
适用 vs 不适用场景
适用:
- 类型类自动派生(JSON / Protobuf / DB schema)—— circe / Magnolia / Shapeless
- 内嵌 DSL 翻译成另一种执行(Slick → SQL,Spark → 字节码)
- 编译期断言 / 字符串插值检查(
sql"SELECT ..."编译期校验语法) - 性能敏感场景的代码生成(Catalyst whole-stage codegen)
不适用:
- 跨编译器版本要长期稳定的库 → 用普通 Scala 或运行期反射更合适
- 调试需求高的场景 → macro 生成的代码栈帧错乱,断点跳不到源码
- 团队里没人懂宏的项目 → 谁踩坑谁修两周
- Scala 3 项目 → 不能再用旧 def macro,要学 inline + quoted(PCP 演算)
历史小故事(可跳过)
- 2002 年:Sheard & Peyton Jones 发表 Template Haskell(template-haskell),用
[| ... |]和$( ... )给 Haskell 装上编译期元编程。 - 2010 年:Scala 2.8/2.9 只有运行期反射 manifest,元编程要么走 toolbox 要么走外部代码生成器。
- 2012 年:Burmako 在 EPFL 跟 Odersky 做博士,把 def macro + quasiquote 实现到 Scala 2.10 nightly。
- 2013 年:Scala Workshop 论文发表,把这套系统讲清楚;同年 Slick 1.0 / Shapeless 2.0 大规模采用。
- 2021 年:Scala 3(dotty)整体重写宏成 inline + quoted DSL,理论基础是 Stucki/Biboudis 的 PCP(principle of phase consistency)。旧 def macro API 不再可用——Scala 历史最大兼容性断点之一。
- 2024 年:Burmako 的 scalameta 项目(脱离编译器内部 API 的独立 AST 库)成为 Scala 元编程事实标准;ZIO / cats 等生态库的派生路径都迁移到 Magnolia + Mirror。
学到什么
- 元编程的关键是”把代码当数据”——Lisp 1960 年就懂,Scala 用静态类型把它工业化,让 Java 生态也能享受
- quasiquote 把”写宏”从拼 AST 降到了”写 Scala”的认知成本——这是工业落地的临界点
- **类型驱动派生(implicit + macro)**让”自动生成模板代码”从代码生成器(外部)变成编译器内置能力
- 绑死内部 API 的代价:能力换来兼容性债务,Scala 3 不得不推翻重来。这是所有”开放编译器”系统都要做的取舍
- 同代不同路径:Template Haskell 走 quote/splice + IO Monad,Scala 走 def macro + implicit 派生,最后两边都被新一代(quoted DSL / typed quasiquote)取代——但工业项目的真实经验沉淀都来自 2013 这一代
- macro 不是免费午餐:每加一行 def macro,库的可调试性和向前兼容性都打折扣,决定要不要用前先看团队能否支付维护成本
延伸阅读
- Burmako 2013 PDF(原论文 8 页,可作起步)
- Eugene Burmako 博士论文 2017(150 页,scalameta 起源,比 2013 论文深得多)
- Scala 3 Macros 官方教程(学新语法用,inline + quoted)
- scalameta 项目(Burmako 在 2013 论文之后做的下一代 macro 框架)
- template-haskell —— Scala 宏的精神祖先,比较两者实现细节
- metaml-multi-stage —— 多阶段编程的理论根,理解 quote / splice 从哪来
- Shapeless 教程 The Type Astronaut’s Guide(看类型驱动派生怎么用宏实现)
关联
- template-haskell —— 同时代的 Haskell 元编程系统,Scala 宏在它基础上加了类型驱动派生
- metaml-multi-stage —— quote/splice 的理论起源,Burmako quasiquote 的祖父
- partial-evaluation-jones —— 编译期专精化思想,宏可以看作受限的 partial evaluation
- gadt-pjones —— GADT 让宏在类型层做更精确的 case 分析(circe / shapeless 用)
- reynolds-definitional-interpreters —— 把高级语言映射到目标语言,宏做的就是这件事
- graalvm-truffle —— 另一种”在运行期把高级 AST 编译成机器码”的路径,对照宏的编译期路线
- hindley-milner —— 宏要看类型,类型从 HM 推出来;两个系统在编译器里串联
- system-f-reynolds-1974 —— Scala 类型系统是 System F 的扩展,宏在类型层操作时面对的就是 F 风格量词
- trees-that-grow —— 可扩展 AST 设计,思路类似宏要面对的”如何让 Tree 表示能演化”
反向链接
- gadt-pjones —— GADT — 让构造子告诉编译器”我返回的是更精确的类型”
- graalvm-truffle —— GraalVM Truffle — 写一棵会自我特化的语法树就能自动得到 JIT
- hindley-milner —— Hindley-Milner — 编译器自己猜变量类型
- lean-prover —— Lean 4 — 用 Lean 重写的 Lean,让数学家和程序员共用一种语言
- lean-tactics —— Lean Tactics — 让证明助手把”写证明”当成写程序
- metaml-multi-stage —— MetaML — 让你显式地写”先生成代码、再跑代码”
- partial-evaluation-jones —— Jones-Gomard-Sestoft 1993 — Partial Evaluation 与自动程序生成
- reynolds-definitional-interpreters —— Reynolds Definitional Interpreters — 用一种语言去定义另一种语言
- system-f-reynolds-1974 —— System F — 让类型也能像参数一样被传递
- template-haskell —— Template Haskell — 让 Haskell 在编译期把代码当数据玩
- trees-that-grow —— Trees that Grow — 可扩展的语法树设计