Scala 25:inline 与 compiletime
同样传入整数,为什么一个调用必须在编译时拒绝
配置代码写下固定批量大小 0,应该尽早报错;用户从命令行输入一个整数,程序则必须在运行时验证。这两种入口都处理 Int,但可获得的信息不同。编译器可以检查源码中的常量,却不能预先知道下一次用户输入。
1 | |
本章正例中 positive(3) 成功展开,positiveRuntime(-1) 返回 Left。隔离反例 positive(0) 在编译期报出指定错误;另一个反例把普通方法参数传给 positive,编译器报告无法归约 inline if。它不会自动把这个分支改成运行时检查。
实验固定 Scala 3.3.7、Scala CLI 1.9.1、JDK 21.0.11。完整源码位于 examples/scala-lab/snippets/25/Chapter25.scala。本篇区分三类证据:编译成功或拒绝说明静态契约;运行断言说明实际求值;javap 展示生成的 JVM 指令。它们分别回答问题,不能互相代替。
inline 先改变展开方式,不自动改变所有求值语义
inline def 要求编译器在适用调用点展开方法体。它不同于一个给优化器参考的普通内联提示,也不同于 JVM 在程序运行后根据热点信息做的方法内联。前者属于 Scala 编译过程,后者属于 JVM 执行策略。
但方法被展开,不代表普通参数突然变成按名求值。比较两种写法:
1 | |
第一个参数仍保持普通按值语义。调用者传入带副作用的表达式时,应先求值得到一个结果,再在展开后的方法体里使用这个结果两次。第二个参数声明为 inline,参数表达式可以被代入使用位置,因而可能执行两次。
正例用计数器明确观察差异:
1 | |
第一次 next() 返回 1,同一值被相加得到 2。第二次展开相当于两个 next() 调用,依次得到 1 和 2,相加得到 3。两个断言同时检查结果和次数,避免只看一个最终数值就误判求值策略。
如果把参数换成无副作用的字面量 1,两种方法都会返回 2,这个输入无法区分语义。设计实验需要选择能够暴露差异的输入;并非运行次数越多,证据就越强。一个有明确事件计数的最小输入,比循环打印上千次更能解释规则。
这种差异会影响日志、随机数、时钟读取和外部调用。增加 inline 参数不能只按“可能更快”审查,还要检查原来承诺的求值次数是否变化。即使 JVM 最后优化出类似指令,语言语义也必须先成立。性能章节会单独测量成本,本章不从展开形状推导速度排名。
inline if 需要展开时就能选择分支
普通 if 把条件测试留给运行阶段。inline if 则要求在展开时决定走哪条分支。调用 positive(3) 后,条件能化成常量真,于是只保留合法结果。调用 positive(0) 时条件为假,展开到 compiletime.error,生成编译错误。
这里有两个关键约束。n 本身是 inline 参数,调用处表达式能参与展开;条件还必须能够化成编译阶段支持的常量结果。并不是所有源码里写得出的纯函数调用都会被编译器执行。编译期归约不是一个可以任意运行应用程序的求值服务。
反例把它放进普通方法:
1 | |
对于方法定义中的任意运行时 n,编译器没有依据选择真假分支。诊断 Cannot reduce inline if 正是预期拒绝。修复取决于 API 需求:如果参数必须来自编译期固定配置,保留静态入口并要求调用者提供常量;如果参数来自网络、文件或命令行,就使用 positiveRuntime。
不要把编译期错误接口用于正常动态输入,再建议调用者添加转换或关闭检查。这种错误属于接口选择不合适。静态检查和运行检查可以共享业务条件,但它们的失败时点、返回形式和适用输入应在名称与文档中分开说明。
本例的错误文本由 error 提供。调用失败时,程序没有生成一个 Left 交给调用者,也没有在启动时抛异常;编译步骤直接失败,后续运行不应发生。把这些行为都称为“校验失败”会掩盖工具链应如何处理。构建系统需要非零退出码,应用用户需要可处理的值或错误,两者不是同一交付接口。
transparent inline 允许结果类型随展开变得更精确
普通方法的显式返回类型是稳定接口。transparent inline 可以让调用点根据展开结果得到更精确的类型,这对按类型选择返回值很有用:
1 | |
调用 defaultValue[Int] 的结果可赋给 Int,defaultValue[String] 的结果可赋给 String。正文说“可赋给”,是为了描述调用者得到的可用约束;不要求读者把所有常量单例类型推断细节都当成业务契约。
erasedValue[T] 并没有在运行时构造一个 T 对象。它用于让 inline match 根据类型结构选择分支,并要求相关使用在展开中消失。若错误地把它当成通用工厂,期待运行时返回任意类型的默认实例,就误解了这个 API。
这个例子可与上一章的 match type 对照。match type 计算的是类型;这里的 inline erasedValue[T] match 选择要生成的值代码。二者经常一起用于派生,但不必一一对应。生成值的实现仍须满足类型检查,不能因为静态分支已选中就跳过数据语义。
transparent 也会让调用点更依赖实现展开结果。公开库若改变返回分支,即使方法名称和参数列表不变,调用者看到的精确类型仍可能变化。因此它适合确实需要暴露类型精度的接口,不宜作为所有小方法的默认修饰符。稳定抽象边界有时比额外推断更有价值。
使用时可以问:调用者是否需要区分不同输入所对应的结果类型?如果只需要统一返回 String,普通 inline def ...: String 已经足够。只有需求确实依赖精确类型,才让实现展开参与接口推导。否则编译错误与兼容性成本会增加,却没有可见收益。
constValue 读取的是常量类型携带的信息
constValue 将常量类型对应的值带到表达式中:
1 | |
这里类型参数 2 是整数字面量对应的单例常量类型。constValue[2] 可以提供值 2,再与合法数量 3 相加。它不是通过反射检查一个未知整数,也不是把任意 Int 类型转换成某个代表值。
常量类型与普通类型的区别,类似“这个值就是二”与“这个值是某个整数”。Int 的信息不足以决定唯一值,因此不应期待 constValue[Int] 返回零或其他默认数字。若 API 接受的类型未必有可提取常量,需要另行选择可选查询或运行时路径,而不是伪造一个值。
类型层 tuple 也不能简单按普通单例常量处理。编译时工具包提供专门的组合操作,派生时经常用字段标签 tuple 逐个读取字符串常量。要依据实际 API 支持的结构设计实现,不能仅凭“这些元素看起来都是常量”推断整个结构可以用同一种操作提取。
与 erasedValue 相比,constValue 的用途更直接:前者让类型参与分支选择而不制造运行时占位值,后者把已知常量类型对应的信息转为值表达式。将两者都解释成“编译期拿到类型”过于含糊,会使下一章的标签提取与字段实例搜索难以分清。
生成产物实际包含什么
正例运行得到 compiledConstant == 5,还不能证明条件检查或加法已经在编译阶段消失。为回答这个问题,本章保存了 JDK 21 的 javap -c -p 输出,定位到生成的 Chapter25$ 类。
1 | |
这段产物显示该方法直接返回常量五,没有运行时数量条件或整数加法。它是冻结源码、编译器版本和当前方法的实际结果,不是所有 inline 方法的通用指令模板。更复杂表达式仍可能保留调用、分支或分配。
同一份产物中的 positiveRuntime(int) 保留比较与分支,并构造 Right 或 Left。它与静态入口的差异符合预期:动态入口必须接收运行时值并决定结果。是否可以由 JIT 在某个具体调用上下文进一步优化,是另一个层面的观察,不能从静态字节码单独决定。
计数实验的产物也给出交叉证据。普通参数版本先调用一次 next$1,保存到局部变量,再读取两次;inline 参数版本出现两次 next$1 调用。运行断言证明实际次数,字节码说明这些次数如何体现在生成程序里。两项一致,比只引用“内联相当于文本替换”准确得多。
“文本替换”尤其容易产生误导。Scala 仍进行类型检查、作用域处理和参数语义保持,不是任意字符串拼接。宏篇会进一步讨论生成绑定如何保证单次求值;本篇已经能看到普通参数展开时需要局部值保存,不能按肉眼复制源码忽略它。
还要区分 Scala 编译优化与 JVM 热点优化。iconst_5 在启动 JVM 前就存在于 class 文件中;JIT 编译发生在程序运行期间,可能受热度和执行配置影响。保存字节码证明前者,JMH 与诊断日志才适合研究后者。对一个方法看到常量返回,不能得出整个应用没有抽象成本的结论。
编译时间和代码大小也是工程成本
inline 递归、字段展开和派生可以减少运行时结构探索,却会增加编译工作或生成代码。把一个复杂实现展开到许多调用点,可能让编译时间增长,也可能产生重复指令。具体成本需要测量,但代码审查至少应识别展开规模与调用点数量。
因此应让 inline 部分只承担必须在编译期完成的选择,把普通运行逻辑留在可复用方法中。例如先根据类型选定编码器,再在普通方法里执行字符串拼接;不必把所有循环、日志和错误格式化都展开到调用者。下一章采用的派生结构就区分了类型 tuple 遍历与对象值编码。
公共 inline 方法还会暴露部分实现细节给调用者编译过程。修改它后,依赖方可能需要重新编译才能得到新展开结果。不要把“库的二进制文件替换成功”当成所有使用方行为已经更新;增量编译和发布策略需要在工程章节实际检查。
调试时应区分三种失败。普通方法体类型错误首先在定义处修复;某个调用无法完成 inline 条件归约,则应检查该调用能否提供静态信息;展开进入 error,表示输入触发了作者定义的静态约束。把三者混成“编译器无法推断”,容易导致无方向地补类型注解。
若用户需要友好的静态诊断,错误消息要指出哪个约束失败以及可用输入范围。若需要捕获更复杂源码结构、报告准确位置或生成新绑定,单纯 inline if 可能不够,此时再使用 Quotes 宏。不要为了一个正整数判断提前引入完整反射树操作。
将运行时校验拆出静态入口
迁移已有校验函数时,先保留普通方法并补上边界测试。确认合法、零、负数各自的行为后,再增加适用于字面配置的静态入口。两者的共同业务条件应清晰,失败表达则按阶段分别设计。迁移目标是扩大正确用法的检查范围,不是把所有动态调用变成编译失败。
第二步检查参数副作用。给方法加 inline 与给参数加 inline 是两个独立决策。若原函数承诺只计算一次输入,就保留普通参数,或在生成逻辑中明确保存临时值。测试应使用计数器、抛异常或可观察事件,确认求值次数与顺序,而非只用常量证明结果相等。
第三步审查返回类型。普通 inline 已足够时不要额外使用 transparent;确实需要按输入改变类型精度时,为每个公开分支添加显式类型赋值测试。改变分支之后,测试会在编译阶段暴露接口差异,而不是等某个远处调用点突然失去可用成员。
最后检查产物与收益。如果目的只是静态拒绝非法配置,那么拒绝诊断就是核心验收,没必要用微小计时差证明优化成功。如果目的涉及性能,则需要另立同结果负载、版本和运行参数,不能把删掉一段源码或看到一个常量指令作为最终性能指标。
从观察结果反推检查是否放在正确阶段
可以把本章的三个入口整理成一张判定表。每一行都问两个问题:值何时才知道,失败应该交给谁处理。
| 输入来源 | 可用信息 | 合适的结果 |
|---|---|---|
| 源码中的固定批量大小 | 编译时可判定的常量 | 合法时展开,非法时编译失败 |
| 命令行或文件字段 | 运行时才得到的整数 | 成功值或明确错误 |
| 泛型算法的类型参数 | 调用点提供的静态类型 | 选择受支持类型对应的生成逻辑 |
第二行不能强行按第一行处理,否则每次普通调用都缺少常量证据。第三行也不等于检查对象当前内容;静态类型是 String,只能据此选择字符串分支,不能由类型知道某个字符串是否为空。把输入来源列清楚之后,很多“为什么编译器不帮忙算”的问题就转化成了可判断的信息边界。
反例还要检查失败位置。若非法常量在方法定义时就导致所有调用失败,说明错误分支没有按预期延迟到展开选择;若动态输入在运行后才抛出异常,而接口承诺编译拒绝,则说明保留下来的是运行校验。正确的错误消息之外,正确的失败阶段也是 API 的一部分。
从运行次数反推同样有效。twice(next()) 与 duplicated(next()) 对常量输入无区别,对带状态输入却不同;因此优化前后的正确性不能只由几个纯值等式确认。若方法接受调用者提供的表达式,应把副作用和异常路径纳入语义回归,即使最终文档建议用户优先传纯表达式。
字节码检查也应只验证对应假设。本章在 compiledConstant 中查找常量返回,是因为要确认这个小表达式已经归约;在普通入口中看到分支,是因为输入确实动态。若把整个类里是否出现某条指令当成唯一判据,其他断言或辅助方法就可能干扰结论。先定位方法,再分析指令,证据才能对应到具体声明。
这种分层检查便于后续宏和派生排错。编译期结构选择错了,先看类型与展开;生成程序求值次数错了,检查绑定和复制;运行时资源生命周期错了,则不能靠调整 inline 参数解决。每个现象都落到能提供证据的阶段,比把所有问题归到“元编程很复杂”更容易修复。
实验记录
本章为 LAB_VERIFIED。在设置 JDK 21 的仓库根目录执行:
1 | |
正例退出零,输出 25 constant=5 defaults=Int/String argument-evaluations=1/2。examples/scala-lab/evidence/20261002-ch25/ 保存 25.json/.log、两个 negative-25-*.json/.log,以及 javap.json/.log。后者记录了精确 class 路径、JDK 命令和退出码,构建目录哈希不同的机器应按 RUN.md 查找本机产物。
反例成功条件分别是编译器出现自定义正数常量错误,以及普通参数导致的 inline 条件无法归约。任意其他语法错误不能代替这两项。源码生成的字节码已经检查,但本章没有运行 JMH,因此不报告耗时或“更快多少”。
判断与修改练习
判断 inline def f(x: Int) = x + x 是否保证传入表达式求值两次。答案是否定的,普通参数保持按值语义;需要观察的是参数声明,不能只看到方法上的 inline。再判断 inline if 条件无法归约时是否会自动保留普通 if,答案同样是否定的,本章反例实际拒绝了这种调用。
执行练习把数量上界限制为一百,为常量 1 和 100 加正常断言,为 0 和 101 各加独立编译反例。动态入口也加入相同边界,并断言返回的 Either。答案的关键不是两个函数共享多少行代码,而是四个边界在各自阶段得到一致业务判断。
再增加一个普通调用时会抛异常的参数表达式,分别放进只使用参数一次、两次和完全不使用参数的 inline 方法。先写出预期求值次数,再运行验证。普通按值参数即使方法体不使用,也不能随意丢弃有副作用的参数计算;inline 参数是否被保留则与展开使用相关。实验要保存具体代码和结果,不能只套一句“内联等于替换”。
参考与下一步
版本规则来自 3.3.7 Inline 文档与 compiletime 操作。下一篇将这些机制用于 Mirror 派生,分别观察编译期结构遍历、实例初始化和运行时递归编码。
