同一行修改为什么有时只编译一个模块

一个小库提供 twice,应用调用它得到整数。把实现从乘二改成乘三,应用没有改源码,运行结果却应当变化;把返回类型从 Int 改成 Long,应用中的显式整数变量又必须重新接受类型检查。这两个修改可以用来区分编译器阶段、产物格式与构建工具的增量判断。

本章使用 Scala 3.3.7、sbt 1.11.7、Scala CLI 1.9.1 和 JDK 21.0.11。独立工程包含 core 与 app,运行脚本复制工程到临时目录,按顺序修改副本,保存每次命令、退出码、编译输出及产物摘要。源码模板不会被实验过程反复改写,下一次运行仍从相同起点开始。

1
2
3
4
5
6
7
object Core:
def twice(n: Int): Int = n * 2

object Main:
def main(args: Array[String]): Unit =
val result: Int = Core.twice(21)
println(s"result=$result")

正常值从四十二变成六十三、返回类型修改引发拒绝、应用适配之后恢复运行,都是实际执行结果。另一组实验打印编译阶段以及 typer、erasure 后的树,再检查 TASTy 文件和 JVM 指令。两组证据相互补充,却回答不同的问题:一组观察单次编译中的表示变化,一组观察两次构建之间复用什么。

源代码先形成树,再得到类型信息

编译器不能直接把字符序列交给 JVM。它先把关键字、标识符和符号组织成语法结构,再处理名字究竟指向哪个声明、方法选择是否成立、表达式的类型是什么。Scala 的缩进、括号和中缀写法属于源语言形式,后续阶段主要处理内部树结构。

以 Core.twice(21) 为例,类型检查需要知道 Core 绑定到哪个对象、twice 是否可见、实参是否符合形参、结果能否赋给左侧变量。这些问题在生成乘法指令之前就要解决。把返回类型改成 Long 后,val result: Int 的不匹配发生在类型层,不能靠运行时恰好得到六十三来证明赋值合法。

typer 阶段输出里有经过解析和检查的符号、类型以及某些显式化结构。原本简短的源码可能展开为更详细的树。阅读这种输出时要找出变化对应的语义,而不是把内部打印文本当成另一种稳定的用户语言。

类型树也不是最终指令清单。内联、模式处理、闭包转换、擦除和后端生成仍可能改变表示。一个源级表达式在较早阶段存在,在后面可能被拆开、替换或消除。要研究具体变换,应选择该变换前后的阶段,而不是只打印第一棵树和最后一个类文件。

本章用的简单乘法不包含宏和复杂模式,因此树之间的差别相对有限。这是有意控制变量:先确认工具入口和基本阶段,再把同样方法用于前几篇的内联或派生代码。不能因为小程序在两个阶段长得相似,就认为中间所有阶段都没有作用。

阶段列表是当前编译器的执行安排

冻结源码中的 Compiler.scala 给出阶段组织。本章核对了包含 typer、Pickler 和 Erasure 的阶段列表;它是 Scala 3.3.7 实现证据,不是对所有未来编译器版本的永久承诺。阶段名称、分组和打印形式都可能随实现演进改变。

typer 负责建立后续变换所依赖的类型信息。Pickler 与 TASTy 序列化相关。后续转换把高层语言结构逐步降到后端能够表示的形式,erasure 处理 JVM 泛型表示限制。最终后端输出 class 文件,JVM 才能加载执行。

这些阶段并不意味着每个源语法特征只对应一个独立阶段。一个特征可能在多个阶段协作处理,几个细小变换也可能组合执行。研究某项行为时,应从实际树和对应源码入口追踪,避免仅凭阶段名字推断全部实现。

本章命令使用 -Xshow-phases 和 -Xprint:typer,erasure。这些属于编译器诊断接口。诊断输出很适合调查问题,却不宜直接变成业务程序依赖的文本协议。例如辅助符号命名改变,不一定表示程序语义变化;产物描述符变化则可能影响已有客户端,需要另外判断。

阶段日志保存完整原文,文章只抽取与问题有关的部分。若以后需要研究模式穷尽性、内联失败或宏展开位置,可以先从当前版本的阶段列表选取观察点,再建立小反例。一次打印全部阶段通常会产生大量信息,未必更容易定位问题。

TASTy保存供Scala工具使用的类型化结构

编译后的目录同时出现 Chapter29.tasty 与 class 文件。TASTy 保存 Scala 类型化语法结构,使后续 Scala 编译和工具能够取得 class 格式本身不充分表达的信息。它不是另一套交给 JVM 执行的机器码,JVM 执行仍依赖 class 文件。

本次脚本验证 TASTy 文件存在且非空,记录字节数、SHA-256 与前十六字节。该记录只能证明当前编译产生了真实文件;它没有完整解码 TASTy,也没有证明任意版本的工具都能读取。把文件扩展名存在直接解释成跨版本兼容,是超出证据的推断。

TASTy 对 Scala 的类型信息很重要,但不能代替公共 API 设计。库改变了方法语义,即使类型结构仍能被读取,业务结果也可能变化;库输出了更新版本的 TASTy,旧编译器还可能因为格式或特性限制无法消费。源码兼容、TASTy 可读性、JVM 二进制兼容、运行行为兼容需要分别验收。

第 37 篇会实际使用两套冻结编译器测试迁移方向。本章只建立产物角色,不根据当前滚动文档推断所有 Scala 2.13 与 Scala 3 组合。尤其不能把“Scala 3”视为一个无需小版本的依赖标签。

发布库时还要检查打包任务是否包含预期元数据。开发目录中有文件,并不自动证明最终发布的 JAR 也完整。第 35 篇会检查实际本地发布的坐标与 JAR;这里检查的是编译输出目录,证据边界停留在这个范围。

擦除后的树与JVM指令互相印证

打印 erasure 后的树,可以观察静态类型经过 JVM 表示约束处理后的形状。前一篇已经用泛型恒等函数验证对象描述符与装拆箱,本章的 twice 则保留整数乘法,是一个较容易对应源码的对照。

javap 输出里能找到 twice 方法及 imul 指令。这支持“当前方法执行整数乘法”的结论。它不证明整段应用只需要一条指令,也不证明运行时性能;方法调用、输出以及 JVM 执行环境还有其他成本。

树是编译器内部表示,字节码是 JVM 接口。两者之间的对应关系有助于解释变化,但不必是一行树对应一条指令。后端可能合并、分解或生成必要的适配成员。检查时应关注数据流和调用约定,不能用行数相等作为正确性标准。

本章同时记录源码、打印树和类文件路径,避免只凭一个片段猜测输入。编译器实验中常见的错误是修改源文件后仍检查旧目录,或者对另一个构建配置生成的同名类执行 javap。临时目录与命令清单使本次观察点可以被复核。

临时目录结束后会被删除,留下的 JSON 中绝对路径用于说明当时检查的对象,而不是承诺该路径长期存在。复跑脚本会生成新目录并重新执行所有步骤。需要复核具体指令时读取保留的日志,需要重新产生 class 时执行脚本,这两种操作都有明确入口。

增量编译属于构建过程中的复用判断

Scala 编译器负责把给定输入编译成输出;sbt 的增量编译通过 Zinc 维护源文件、依赖和产物之间的信息,判断哪些工作需要重新执行。两者协作,但不是只要调用一次 scalac 就自动得到与 sbt 完全相同的增量行为。

独立工程用 app.dependsOn(core) 建立编译依赖。初次干净构建编译库与应用,运行得到四十二。然后脚本只修改库的方法体,将 n * 2 改成 n * 3,再次运行 app/compile 和 app/run,结果变成六十三。

本次日志只出现核心模块的源文件编译记录;应用 class 的前后 SHA-256 相同。这两个观察结合起来支持当前场景下应用产物得以复用。只看哈希相同还不够,因为重新编译也可能产生相同字节;只看没有熟悉的日志行也可能误读输出。组合证据比单个迹象更稳妥。

为什么不重新编译应用仍能得到六十三?应用保持对同一个库方法的调用,运行时加载的是修改后的库实现。源 API 没变,调用形状也没变,应用不需要把乘法逻辑复制进自己的代码。这里的 twice 是普通方法,不能把这个结论不加限定地迁移到 inline 定义。

如果应用引用了编译期展开的实现,或者库修改涉及被跟踪的其他依赖,失效范围可能不同。增量策略需要保守保证正确性,不能按“只改方法体就永远不重编译下游”这样的口号理解。本章只验证了一个明确的普通方法场景。

返回类型变化让旧假设暴露出来

第三次构建把方法改成:

1
def twice(n: Int): Long = n.toLong * 3

应用仍声明 val result: Int。本次 app/compile 非零退出,诊断指出找到 Long 而需要 Int。这表明依赖变化已使应用中的旧类型假设重新受到检查,不能继续把上一次成功结果当作当前源码的有效构建。

随后脚本把应用变量改成 Long,再次编译与运行,得到六十三。这个恢复步骤很重要:若实验只有一次失败,就难以区分预期类型拒绝与环境故障。适配之后成功,连同诊断内容,能更清楚地说明失败来自什么边界。

显式类型注解在这里提供了可观察的契约。假如应用只写 val result = Core.twice(21),新类型可能被顺利推导出来,编译不一定失败,但其他调用点仍可能受影响。因此迁移检查不能只依赖恰好存在的编译错误,还应有业务结果和外部接口测试。

改变返回类型也可能改变 JVM 描述符,旧应用若不重新编译就混用新库,会面临不同的问题。本章实验是由 sbt 重新分析和编译项目,不是二进制替换兼容测试。两者不能混为一个结论:前者证明当前源码工程能否构建,后者需要保留旧客户端产物另行运行。

如果项目使用生成代码、宏、编译器插件或跨平台产物,失效关系还会更复杂。应先保留一份可以干净重建的基线,再研究增量是否遗漏或过度重编译。缺少干净构建对照时,旧产物掩盖的问题很容易被误认为语言特性。

如何定位“清理之后才正常”的工程问题

这类现象首先说明增量路径与干净路径存在差异,并不直接证明是编译器缺陷。可能原因包括生成任务没有声明输入、输出写到未跟踪位置、源目录配置不同、依赖解析变化或工具缺陷。定位应从可重复的最小变化开始。

本章脚本把修改拆成实现变化、签名变化和调用者适配,每一步记录命令和输出。实际项目可以沿用这种方法:先保存初始成功构建,再只改一个输入,比较重编译文件及运行结果,最后用干净构建确认结果是否一致。

不要为了让测试通过而立刻删除所有缓存并丢弃日志。清理是一种有效恢复手段,却也会消除诊断增量问题所需的状态。可以复制最小工程到临时目录,把异常构建记录保留下来,再验证干净路径。恢复与调查可以分开完成。

依赖下载缓存与项目编译缓存也要区分。本章临时目录没有已有 target,却复用固定工具与下载缓存,因此它是干净项目输出重建,不是从完全离线空机器安装所有依赖。报告时写清这一点,比笼统声称“全新环境通过”更准确。

构建速度同样不能从这组实验推导。日志有耗时,但没有控制操作系统缓存、进程预热和并发负载;这里只用它们追踪执行过程。要比较增量性能,需单独设计稳定基准,不能把一次修改的墙钟时间当作通用速度指标。

读源码时选与问题对应的层

若问题是某种类型为何被拒绝,应定位类型比较、推导或约束处理;若问题是闭包如何生成,应看相关转换与后端;若问题是改库后哪些源码重编译,应看构建工具与依赖分析。把所有行为都归结为“编译器内部”会使定位范围失去边界。

本章对固定版本 Compiler.scala 的核验,仅支持阶段排列与命名的解释。没有运行 Scala 编译器自身完整测试套件,也没有审计全部增量算法。公开源码链接让读者能继续追踪,但链接存在本身不等于本文已经证明那一层所有性质。

官方文档描述总体模型,真实日志说明冻结工具在具体输入下的行为,固定源码帮助解释机制。这三类材料有不同强度。发现滚动文档与旧版本产物不一致时,应优先明确版本差异,再决定是否需要新实验,不能把材料拼成一个看似统一却不存在的版本。

同一个名字对应哪些不同的缓存

工程排查还需要区分源码中的名字与构建图中的节点。两个模块都能看见名为 Core 的对象,不代表它们使用同一份产物。如果依赖配置混入发布 JAR 与项目依赖,或者类路径先后顺序不同,运行时可能加载另一个版本。编译日志正常而输出不变时,应先核对实际类路径与模块依赖,再怀疑增量失效。

本章临时工程只建立单向的 app 到 core 依赖,没有同名外部 JAR。这个约束使输出从四十二变成六十三更容易解释:库方法体确实改变,应用仍调用那个库。对照实验减少了可替代解释,但没有模拟所有生产构建布局。

源码缓存、编译分析缓存和依赖下载缓存也不能相互替代。源文件是输入,分析信息描述依赖关系,class 与 TASTy 是输出,下载缓存则保存外部工具和库。删除其中一种可能修复特定故障,却未必影响另一种。例如更新了本地发布坐标但没有改变版本,消费者是否重新取得产物还涉及解析和缓存策略,不能简单套用本章源文件变化的结论。

这也解释了为什么实验不用相同目录反复覆盖最终日志。编译分析本来就具有历史,首次构建和第三次构建的输入状态不同。若只保存最后一次成功输出,返回类型错误发生时有哪些产物仍存在便无法复核。当前脚本为每个阶段分别命名,失败日志与修复后的成功日志都保留。

构建任务自身也有依赖图。app/compile 需要核心模块可用,app/run 又需要应用可执行;在本章实验中顺序明确。若自定义生成任务把副作用藏在设置求值中,或者没有声明输入任务,构建工具无法按预期追踪变化。第 35 篇会把 setting 与 task 的差别放进实际多模块工程,而不是通过命令行外观判断它们是否等价。

复跑、判定题与执行练习

本章状态为 LAB_VERIFIED。普通入口与类型拒绝样例可运行:

1
2
3
python3 examples/scala-lab/run.py chapter 29 --run-id local-ch29
python3 examples/scala-lab/run.py negative 29 --run-id local-ch29
python3 examples/scala-lab/modules/29/run.py --run-id local-ch29-module

独立脚本要求 SCALA_LAB_JAVA_HOME 指向 JDK 21,支持 SCALA_LAB_CLI_JAR 覆盖 CLI JAR 路径。最终完整模块记录在 evidence/20261002-ch29-module-r2/,其中 initial、implementation-change、signature-change、adapted 记录构建序列;phases、trees、tasty.json、javap 记录编译表示。普通入口记录在 20261002-ch29/。

手算题:方法体改成乘三之后,应用 class 没变,为什么输出变化?因为普通方法调用仍在运行时连接到新的库实现。若把方法改成 inline,答案还可直接套用吗?不可,调用者可能包含展开后的逻辑,需重新实验并检查依赖失效与产物。

第二题:TASTy 文件存在能否证明 JVM 会执行它?不能,JVM执行class。能否证明旧编译器可读取它?也不能,读取能力涉及具体版本。文件存在、格式可读、类型兼容和业务回归通过是四个不同观察。

执行练习把 twice 改成接收 Long,保留应用的整数实参,再检查编译树中是否出现适配。接着增加一个确实依赖返回类型的方法调用,比较推导变量与显式变量的诊断。最后恢复普通方法,只改实现常量,重复增量场景并检查应用字节哈希与编译日志,两者都保留后再下结论。

参考

Scala 3.3.7 编译阶段源码用于核对阶段安排;冻结版本的阶段文档解释编译表示;sbt 增量重编译文档说明构建侧依赖分析。上一章讨论擦除与字节码,下一章把这些二进制边界放进真实 Java 双向调用。

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