检查固定数量,同时保留调用者的求值次数

数量配置必须是编译时常量,并处于一到一百之间。上一章用 inline 条件能处理简单约束;当错误需要定位到具体参数,或生成逻辑需要引入局部变量时,Quotes 宏提供了更直接的有类型代码构造方式。

1
2
inline def quantity(inline n: Int): Int =
${ quantityImpl('n) }

调用 quantity(10) 会在编译阶段执行实现中的检查,并把合法结果放进生成程序。调用 quantity(101) 被拒绝。传入一个普通运行时方法参数,也被明确拒绝,而不会自动先运行应用再取回它的值。

第二个需求是生成一个二元组,两项都使用同一次输入计算结果。若简单把输入代码放两次,副作用就可能执行两次。宏必须生成局部绑定,保存一次求值结果,再读取两次。这个需求把类型正确、阶段正确和运行语义正确放在同一个可观察例子里。

实验冻结 Scala 3.3.7、Scala CLI 1.9.1、JDK 21.0.11。宏定义在 snippets/27/Checked.scala,调用在 snippets/27/Chapter27.scala。文件分离让宏实现能够先编译供调用点展开;整个例子仅依赖标准库,不需要运行时编译框架。

Expr[T] 表示一段产生 T 的代码

宏实现接收 Expr[Int],而不是普通 Int。它代表一段具有整数类型的代码。可以把这个关系读作“生成后求值结果是整数的表达式”,不能读成“宏现在已经拥有该整数”。

1
2
3
4
5
6
7
8
9
10
11
12
13
import scala.quoted.*

def quantityImpl(n: Expr[Int])(using Quotes): Expr[Int] =
n.value match
case Some(v) if v >= 1 && v <= 100 => Expr(v)
case Some(_) =>
quotes.reflect.report.errorAndAbort(
"quantity outside 1..100", n
)
case None =>
quotes.reflect.report.errorAndAbort(
"quantity must be a compile-time constant", n
)

n.value 尝试从表达式中提取可识别常量。成功时得到普通整数,宏实现可以在编译阶段检查范围,再用 Expr(v) 把值提升成一段常量代码。失败时得到 None,本例选择报错。这个选择属于宏的 API 契约;另一个宏也可以选择生成运行时检查,但需要明确实现。

Expr[Int] 并不保证其中一定是字面量。普通变量引用、方法调用或其他产生整数的表达式,都可能具有相同类型。类型信息只约束表达式结果,不保证常量可提取,也不保证表达式无副作用。把 Expr[T] 当成某种已计算值,会同时混淆这几项性质。

表达式包装也不等于保存原始源码字符串。编译器已经分析了结构和类型,宏通过受控 API 操作代码表示。使用 quotes 构造代码时,Scala 继续检查结果类型;不需要手工拼接括号、变量名或泛型参数文本。这减少了字符串模板容易造成的一类错误,但不自动保证生成程序符合业务语义。

本例的输出仍声明为 Expr[Int],意味着生成结果需要是整数表达式。若返回字符串表达式,宏实现本身就应类型不匹配。类型系统检查生成代码的形状,范围判断则由 n.value 与条件负责,二者分工明确。

引号和拼接分别跨越哪条阶段边界

引号 '{ ... } 把代码作为下一阶段的表达式来构造;拼接 ${ ... } 执行生成逻辑,并把得到的代码插入相应位置。源码里它们经常嵌套出现,所以理解时最好先为每段表达式标出“现在执行”还是“留给生成程序执行”。

在宏入口 ${ quantityImpl('n) } 中,quantityImpl 在编译调用点时执行;'n 表示把调用者参数代码交给它。合法检查得到的 Expr(v) 被拼回调用点。编译完成后,普通应用运行生成结果,不需要再执行一次范围宏实现。

这与普通函数调用不同。应用调用普通函数时传入当前值,函数体在运行阶段处理该值;宏实现处理的是调用点代码表示,并在编译阶段生成替代代码。因此宏内部的调试输出、文件读取或其他动作发生时点也不同。生产宏应避免依赖不稳定外部状态,否则相同源码可能在不同构建环境产生不同结果。

本章没有让宏读取网络、环境变量或当前时间。输入是可提取常量,输出是固定常量或确定的绑定结构,所以复现边界清晰。真正需要外部配置时,应把它作为构建输入明确记录,而不是暗中让编译器运行一个不可审计的小程序。

Quotes 是构造和检查 quoted 代码所需的上下文。它与当前编译器阶段及相关代码表示关联,不是普通应用服务。宏实现通过 (using Quotes) 接收它;拼接环境负责提供相应上下文。不能把某次获得的上下文当作可任意保存到全局、供其他编译阶段复用的对象。

泛型代码为什么还需要 Type[A]

生成一个泛型二元组的宏同时涉及值表达式与类型:

1
2
3
4
5
6
7
8
9
inline def once[A](inline value: A): (A, A) =
${ onceImpl[A]('value) }

def onceImpl[A: Type](value: Expr[A])(using Quotes)
: Expr[(A, A)] =
'{
val saved = $value
(saved, saved)
}

Type[A] 使抽象类型 A 能够在生成代码的阶段被引用。Expr[A] 描述一个结果为 A 的表达式,Type[A] 提供该类型的阶段信息,Quotes 提供构造与检查代码的上下文。把三者都叫“反射对象”会抹掉它们各自的职责。

生成的 saved 在引号内部声明,初始化表达式由 $value 拼接进去。之后二元组使用两次同一个局部值。宏编译时并不会先执行调用者表达式来求 saved;它生成一段程序,让应用运行时执行一次初始化,再构造二元组。

这段代码的类型检查只能保证两项都属于 A。单次求值性质则来自生成结构:先绑定,再复用。若改成 '{ ($value, $value) },类型仍然正确,却可能重复副作用。这是宏正确性中必须单独验证的部分。

正例使用计数器验证:

1
2
3
4
5
6
var calls = 0
val pair = Checked27.once {
calls += 1
calls
}
assert(pair == (1, 1) && calls == 1)

输出二元组和计数同时约束生成行为。只检查二元组长度不能发现重复执行;只检查程序没有异常也不足以证明次数正确。选用有可观察副作用的最小输入,可以直接区分两种生成结构。

跨阶段引用为何被拒绝

以下代码试图让生成程序直接使用宏执行阶段的局部变量:

1
2
3
def wrong(using Quotes): Expr[Int] =
val local = 1
'{ local }

隔离反例在冻结编译器中被拒绝,诊断包含 access to value local from wrong staging level。local 是当前生成逻辑里的局部变量。引号内部属于后续阶段,不能直接使用当前栈上的这个绑定。

正确修复要看意图。如果希望把当前已经算出的整数放进生成代码,可以使用 Expr(local) 提升这个值;如果希望生成程序自己声明局部变量,则把声明放到引号内部。两种修复的结果在这个常量上可能相同,但计算发生的阶段不同。

这种限制并非所有跨阶段名称都绝对禁止。静态可访问的定义、类型阶段证据和受支持的提升机制有相应规则。反例针对的是不合法的局部变量捕获,不能扩大成“宏不能调用任何外部方法”。文章只根据实际案例解释其缺失的阶段连接。

也不应通过保存底层反射树或强制转换绕过报错。代码表示携带的所有者与作用域关系需要一致;一个树节点在某处类型正确,搬到另一处未必仍然合法。高层 quotes 能处理很多绑定细节,降到 reflection API 后需要承担更多证明责任。

所以宏实现应尽量停留在 Expr、引号、拼接和有限的检查 API 上。只有确实需要分析无法通过高层操作表达的结构时,再进入反射层,并使用编译器提供的检查选项与更完整的负例验证。API 更底层并不等于实现更可靠。

提升值与引用代码不能互换

Expr(1 + 1) 先在宏执行阶段计算整数二,再生成常量表达式;'{ 1 + 1 } 则构造包含这段计算的代码,后续编译还可能进一步优化。即使两者最终都返回二,描述的阶段动作也不同。

这种差异在纯常量上不显眼,在具有副作用或外部依赖时却重要。若表达式里读取时间,先计算再提升会把某个构建时刻固化进产物;放进引号则生成运行时读取。需要哪个行为应由需求决定,不能仅为了让类型报错消失随意选择。

对本章 quantity,提取成功之后使用 Expr(v) 是合理的,因为契约本来要求编译期已知常量。对 once,调用者可能传任意运行时表达式,所以必须保留 Expr[A] 并把它拼进生成程序。尝试先提取成普通 A 再构造二元组,会把接口错误地限制为可提升常量。

提升还依赖类型的支持。并不是任意对象都可以自动变成未来阶段的等价构造代码。用户类型需要合适的 ToExpr 等机制表达如何重建值;对象身份、打开的文件句柄或线程池一般也不适合这样跨阶段传递。宏不能把运行时资源直接搬进未来的应用进程。

自定义错误要区分非法常量与未知输入

范围越界和不是常量是两种不同失败。quantity(101) 提供了足够静态信息,但违反业务范围;def f(n: Int) = quantity(n) 没有提供所需静态信息,无法进行范围判定。使用两种错误文本有助于调用者选择修复方式。

前者应该修改固定配置,或者重新考虑允许的范围;后者应该改走运行时校验接口,除非调用点本来就能提供常量。把两者都报告成“无效参数”,会诱导用户不断添加类型注解,而实际缺失的是常量信息或输入合法性。

report.errorAndAbort 在本例中使用参数表达式定位错误。它终止这次宏展开,编译失败,不生成一个应用级错误返回值。若用户希望程序启动后展示友好错误,就应提供对应动态接口。编译期拒绝与运行期错误处理服务于不同使用者。

拒绝测试必须隔离。越界、普通输入和跨阶段引用各有自己的目录,运行脚本要求编译非零并匹配对应语义片段。若把三段放进一个文件,编译器可能在第一处错误后停止或改变后续诊断,无法判断每项契约是否单独成立。

测试错误文本时,也应避免匹配完整行号或绝对路径。源码增删会改变位置,但“越界”“需要常量”“阶段不匹配”这些核心语义应稳定。当前 case.json 使用少量诊断正则,并同时检查退出码;出现别的任意错误不会被当成成功。

编译器检查宏,不代表宏业务行为已经正确

官方宏文档建议开发时启用 -Xcheck-macros。本章在普通运行之外,另以该选项编译并运行同一正例,检查进程退出零并保留输出。它增加对宏生成代码的内部一致性检查,不会自动证明范围上界、输出次数或业务规则符合预期。

宏依旧需要普通软件测试。常量一与一百应接受,零与一百零一应拒绝;重复使用参数时应检查求值次数;返回值类型应由显式赋值或泛型约束验证。对于实际库,还需测试不同调用作用域、泛型上下文和依赖模块中的展开,不应仅在定义旁边写一个演示调用。

官方历史问题也说明底层树操作可能涉及所有者和作用域细节。不能看到一个示例能编译,就断言所有反射变换都安全。本文没有重写任意语法树,也没有实现模式优化器,因此不对这些更大范围做已验证声明。

对运行用户而言,宏产物仍是普通程序。生成代码可以包含循环、分配、异常和资源操作,它们需要正常运行测试。编译阶段把某个模板展开出来,只改变代码产生方式,不会自动让后续算法终止或让资源关闭。

观察宏执行时,先排除构建缓存的干扰

宏实现只在需要展开相应调用点时执行。增量构建若复用已有产物,某次运行没有重新输出宏调试信息,并不表示宏失效;它可能根本没有重新编译该调用点。反过来,一次构建出现多条调试信息,也不等于应用参数在运行时求值多次。

因此实验记录要分开保存编译命令与应用输出。如果研究宏执行次数,应控制是否清理产物、修改哪个输入、编译哪个模块,并把阶段输出标记清楚。本章没有用宏里的打印次数推导运行行为,而是让生成程序自己通过计数断言观察参数求值。

once 的计数器属于调用者程序,初始化、递增和断言都在普通运行中发生。宏只产生局部绑定结构,不在编译时读取这个计数器。把计数器放进宏实现会得到完全不同的观测,不能用来证明调用者表达式只执行一次。

保存原始命令也让重复验证更可靠。若某次运行增加了 -Xcheck-macros,证据必须显示该参数,而不是只在文章中说“启用了检查”。如果改用了别的 Scala 版本,输出相同仍然只能证明新环境的结果,不能替代冻结版本记录。

类型阶段证据不是运行时类型令牌

泛型宏中的 Type[A] 用于把类型带入代码构造阶段。它与 JVM 运行时使用的 ClassTag[A] 不同,不能因为两者名称都涉及类型就互换。前者服务于编译期代码生成,后者服务于某些运行时表示需求,例如数组元素类信息。

同样,宏在编译时知道 List[String],不意味着生成程序里的普通 JVM 类型测试就能完整区分 List[String] 和 List[Int]。宏可以利用静态信息生成专门逻辑,但运行时若要验证一个未知列表的每个元素,仍必须执行对应检查。编译阶段的知识不会自动改变 JVM 泛型擦除规则。

这一区别对解析器和序列化器尤其重要。宏能按目标类型生成字段解码代码,却不能证明外部字节一定符合目标类型。生成逻辑必须保留失败路径,并由运行输入触发检查。把宏生成出来的转换当成外部数据可信证明,会绕过真正需要校验的边界。

所以阅读元编程 API 时应给每份信息标注使用阶段:编译时类型、编译时常量、运行时值、运行时类令牌。只有在明确的转换机制处才能跨越边界,例如提取常量、提升值、生成代码或运行实际检查。阶段分类清楚后,很多看似相似的 API 就不再混淆。

从普通函数开始选择最小工具

如果需求只是把两个整数相加,普通函数足够;如果需要按静态类型选择小分支,inline 与 compiletime 可能已经清楚;如果需要有类型地生成绑定、检查调用点表达式或定制位置诊断,再考虑 Quotes 宏。选择工具的依据是缺失哪种能力,而不是层次越底越好。

迁移时先固定普通实现的业务语义。对 once,核心契约是输入求值一次、两项共享结果;对 quantity,核心契约是常量范围与失败阶段。再用宏替代实现,并保留能区分错误版本的测试。宏测试若只检查“成功调用返回预期常量”,容易错过副作用复制。

公共宏也需要清楚说明依赖边界。调用者编译时要能够加载宏实现;宏与调用放在同一源文件可能受到阶段编译限制;发布时要保留所需产物和兼容版本。文件拆分是本章最小工程安排,更复杂的跨模块发布将在 sbt 篇讨论。

宏实现发生变化后,使用方可能需要重新编译才能获得新生成代码。替换运行时 jar 并不能保证已展开的调用点同步变化。构建系统应把相关依赖和输入纳入增量判定,不能把代码生成隐藏成一个与编译无关的辅助过程。

最后,应把未覆盖范围写清楚。本章没有检查编译器全部宏阶段,没有证明生成代码性能更好,也没有验证外部服务或构建插件集成。已验证的是固定版本中的常量检查、局部绑定生成、单次求值和三个明确拒绝条件。

实验记录与练习

本章为 LAB_VERIFIED。设置 JDK 21 后执行:

1
2
python3 examples/scala-lab/run.py chapter 27 --run-id local-ch27
python3 examples/scala-lab/run.py negative 27 --run-id local-ch27

正常运行输出 27 checked-quantity=10 generated-binding-evaluations=1。examples/scala-lab/evidence/20261002-ch27/ 保存 27.json/.log,以及启用 -Xcheck-macros 的 checked-macros.json/.log;最终三个拒绝案例在 20261002-ch27-r2/。每份 JSON 记录精确命令、工作目录和退出码,源码与复跑说明见 RUN.md。

手算题:把生成代码中的局部绑定删除,改为 '{ ($value, $value) },传入自增计数表达式会怎样?答案是调用者表达式可能执行两次,得到 (1, 2),尽管结果类型仍然是 (Int, Int)。这说明类型正确不能替代求值语义证明。

阶段判断题:val n = 2; Expr(n) 与 '{ val n = 2; n } 哪一处的变量属于生成程序?前者的局部变量在宏执行阶段存在,提升后的代码包含其值;后者的变量声明在引号内部,属于生成程序。应先标出阶段,再讨论两个结果是否碰巧相等。

执行练习把 quantity 扩展为同时接收常量下界和上界,先拒绝下界大于上界的配置,再验证数量范围。需要为合法边界、倒置边界、越界和动态输入分别保存案例。答案的关键是诊断顺序与错误位置:配置本身非法时,不应先报告数量不合法。

再为 once 增加一个返回可变对象的输入表达式,断言对象只创建一次,并检查二元组两项是否引用同一对象。若需求反而要求两个独立对象,就应重新命名并定义接口,不能在保持旧契约的同时默默复制表达式。

参考

阶段、提升与类型证据以 Scala 3.3.7 宏文档为版本依据;当前官网的宏说明可作后续阅读。下一篇回到 JVM 产物,检查泛型擦除、装箱、桥接方法与闭包捕获如何出现在真实 class 文件中。

顺序导航:系列入口:00 · 上一篇:26 · 下一篇:28。