Scala 37:Scala 2 到 Scala 3 迁移
同一个金额库,为什么有两种迁移结果
订单库定义了 def amount(): BigDecimal,旧客户端使用 Pricing.amount 取得金额。Scala 2.13.16 可以编译这份代码。把库和客户端一起交给 Scala 3.3.7,编译失败;保留 Scala 2 编译的库,只迁移客户端到 Scala 3,旧调用却仍能通过。
区别来自方法的定义来源。Scala 3 默认要求调用 Scala 3 定义的空参数列表方法时写出括号,同时保留 Java、Scala 2 方法及相应覆盖方法的互操作豁免。只写“Scala 3 不再允许省略括号”,会漏掉迁移中很常见的分模块状态。Scala 3.3.7 的 auto-application 规则
迁移验收因而需要记录三件事:源文件交给哪个编译器、依赖来自哪份产物、最后执行了哪个客户端。只改构建文件里的版本号,无法说明一个调用为什么成功,更不能证明订单规则没有变化。
本实验固定 Scala 2.13.16、Scala 3.3.7、Scala CLI 1.9.1 与 Corretto 21.0.11。正常编译、预期拒绝、自动改写、双向二进制消费和 javap 共 19 条命令,原始记录位于 examples/scala-lab/modules/37/evidence/20261002-ch37/,状态为 LAB_VERIFIED。后文关于隐式搜索、宏、插件及 Scala 3.8 之后的边界来自官方资料,未由这组金额实验覆盖。
先定义需要保持的业务结果
共享源码 examples/scala-lab/snippets/37/PricingCore.scala 使用两个版本都接受的语法。它没有转换为缩进语法,也没有引入 Scala 3 专有的公共类型:
1 | |
计算顺序也是契约的一部分:订单行金额相加,乘以含费率系数,再将最终金额保留两位。把舍入提前到每个订单行,会改变某些输入的结果,即使两个版本都能编译。字符串构造的十进制输入还避免了先经过二进制浮点数这一额外变量。
现代客户端 modules/37/ModernClient.scala 验证三笔金额。第一笔是普通整数分值,后两笔位于舍入中点:
| 输入 | 舍入前金额 | 最终金额 | 检查目的 |
|---|---|---|---|
| 单价 12.00,数量 2,费率 0.05 | 25.2000 | 25.20 | 主业务路径 |
| 单价 0.10,数量 1,费率 0.05 | 0.1050 | 0.10 | 中点保留偶数分值 |
| 单价 0.30,数量 1,费率 0.05 | 0.3150 | 0.32 | 中点向另一个方向舍入 |
HALF_EVEN 在恰好居中时选择保留位为偶数的结果,因此后两行方向不同。只测试 25.20,无法区分多种舍入策略。客户端还用 Try(...).isFailure 检查数量为零的订单行被拒绝;它没有断言异常的具体类型。
这些断言共同定义了本次迁移要保持的有限行为。空订单、负单价、非法费率和多行累加虽然可以从实现继续分析,但没有全部纳入这份客户端的回归,不能把几个通过的输入称为完整金额性质证明。
源码改写改变了什么
旧客户端 modules/37/LegacyClient.scala 的关键语句是:
1 | |
Scala 2 编译库与客户端后,实际执行输出 PASS legacy: 25.20。同两份源码在 Scala 3 默认模式下编译,得到 E100,要求 amount 调用带上括号。两个运行环境中的类名并不能解释这个错误;错误发生在生成可执行产物之前,针对的是调用表达式与方法参数列表的关系。
无参数列表的 def amount: BigDecimal 与空参数列表的 def amount(): BigDecimal 是不同声明。为消除一处调用错误而直接删掉库定义中的括号,会改变 API 的源码形状,并可能影响覆盖关系。它也没有自动解决属性访问与动作调用的设计取舍。括号本身不证明方法有副作用,省略参数列表也不证明实现纯净;迁移首先应保持原有声明意图。
本次修复使用编译器的迁移模式。runner 先将旧客户端复制到本次运行的 target/<run-id>/rewrite/,保留原件用于失败对照,再对副本和共享库执行:
1 | |
归档的 before/after 表明实际变化只有调用处:
1 | |
迁移模式允许编译器在明确的兼容规则下给出改写。它不能代替目标默认模式的验收。runner 随后去掉这两个选项,将共享库、改写后的旧客户端和现代客户端分别交给 2.13.16、3.3.7 编译,再分别运行两个入口。两组现代客户端都通过金额和零数量断言,改写后的旧客户端也都输出 25.20。
这一顺序保留了失败的原因与修复的效果。若只留下开启兼容选项后的成功日志,就无法判断代码是否仍依赖迁移宽容规则。若先在唯一源文件上自动改写,又没有保存差异,就很难区分工具调整与人为业务改动。对真实项目,可按模块拆分改写提交,把括号、导入等机械修改与费率规则调整分开审阅。
源码、类型信息和运行行为分别验收
两份源码同时通过两个编译器,证明的是这份源码在指定配置下可编译。客户端能够读取另一编译器生成的库,涉及产物中的类型信息。程序加载后得到相同金额,则是运行行为证据。三者不能相互替代。
尤其容易混淆的是“用 Scala 3 重编译一个 Scala 2 库”和“Scala 3 客户端消费既有 Scala 2 库”。前者会对库中的源代码重新进行名称解析、类型检查和代码生成;后者保留了已经编译的库实现。某个库的源码暂时不能迁移,并不直接否定后一条路径;反过来,成功消费旧产物也没有验证库自身能用 Scala 3 构建。
本实验刻意拆开编译动作。Scala 2 先生成库产物,Scala 3 的消费步骤只收到客户端源码和指向该产物的 classpath。两个客户端均编译成功,其中旧客户端仍写 Pricing.amount,适用 Scala 2 定义方法的豁免。随后真正执行的是现代客户端,它通过三笔金额与零数量检查。不能把“旧客户端参与了编译”写成该步骤也运行了旧客户端。
模块边界还决定了应该从哪里开始迁移。假设订单应用依赖金额库,而金额库只依赖基础数据模型,可以先列出这条依赖链,再逐个检查每条边的生产者和消费者。应用先改用 Scala 3,并不要求同时把所有基础库重新编译;但如果金额库提前公开 Scala 3 专有类型,仍停留在 Scala 2 的其他消费者就需要单独验证。迁移顺序应由仍需服务的消费者决定,不能只按源码文件数量排序。
两个方向的测试也不能共用含有所有编译输出的宽泛 classpath。若同一类的 Scala 2 与 Scala 3 产物同时存在,先加载哪份定义会影响结论;甚至可能在声称验证新库时,实际仍读取旧类。独立输出目录、显式 classpath 和产物摘要共同限制了这种误判。本章 runner 分开生成两版库,并在消费命令中只指向对应产物。
由此得到一种可用的渐进路径:让已验证的普通领域库暂留 Scala 2,由 Scala 3 应用消费;库自身迁移单独安排。具体工程仍需检查传递依赖、公开类型和元编程入口。一个金额函数的成功不足以覆盖序列化框架、编译器插件或反射驱动的组件。
为什么反向消费需要 TASTy reader
另一组实验先单独用 Scala 3.3.7 编译金额库,再用 Scala 2.13.16 编译现代客户端。classpath 包含 Scala 3 库产物,并补充 scala3-library_3:3.3.7 依赖。不开 reader 时编译失败,目标诊断要求添加 -Ytasty-reader;加入该选项后编译和金额回归都成功。
Scala 源码需要的类型信息多于 JVM 方法描述符。Scala 3 生成的 TASTy 保存类型检查之后的程序表示,供相应工具读取。Scala 2 的 reader 根据 class 文件的 TASTY 属性找到同目录的 .tasty 文件,并核对两者 UUID,再读取定义。因此,搬运库产物时要保留匹配的文件集合;只拷贝部分 class 导致的缺失,不等于某项语言特性本身不支持。Scala 2.13.16 的 reader 实现说明
该实验的反例也有明确边界。失败组已经提供 Scala 3 运行库,故目标诊断来自没有启用 reader,而非刻意漏掉基础依赖。成功组使用的 API 是普通 case class、列表、整数和十进制金额;没有把 union、match type、上下文函数或 Scala 3 宏暴露给 Scala 2。
两版 javap 都能看到 amount(): scala.math.BigDecimal,返回值对应相同 JVM 描述符。这只说明这个方法在检查的层面有相同形状。Scala 编译器还需要读取类型元数据,调用者还可能依赖其他成员,方法实现也可能产生不同结果。因此,即使描述符完全一致,仍需保留客户端编译与业务回归。
归档保存了 Scala 2 的 class 文件摘要,以及 Scala 3 的 class 与 Pricing.tasty 摘要。这些摘要把日志和具体产物关联起来,不是跨版本字节相等的断言。重新构建产生不同字节时,应检查工具、输入与生成信息,而不是要求两种编译器输出相同散列。
隐式搜索迁移会影响所选业务策略
金额库在这次实验中显式传入费率,避开了隐式搜索变化。真实订单代码若通过隐式参数选择税率、货币编码器或报价策略,则需要额外的双版本样例。源文件保留 implicit 关键字也不能保留 Scala 2 的全部搜索语义:Scala 3 的新搜索规则同样适用于旧式声明。Scala 3.3.7 的隐式解析变化
一个具体检查点是嵌套深度。Scala 3 可优先选择更深层的合适实例,而 Scala 2 中相应候选可能形成歧义。对费率策略来说,“从编译失败变成成功”并不足够,还要确认成功选择的确是目标策略。另一个检查点是递归搜索中的歧义:Scala 3 会传播歧义,不能继续依赖某些 Scala 2 写法将它当作普通失败来触发后备候选。依赖这种行为模拟否定证据的代码,应重新设计,Scala 3 提供了 NotGiven。
迁移审阅可以从调用点向外建立候选表:局部定义、显式导入、通配导入、目标类型的伴生定义,各自提供哪个实例。将有风险的调用暂时改成显式传参,再比较省略参数前后的业务结果,能够把“搜索到了一个值”和“选对了策略”区分开。
例如费率结果恰好相同的两个策略,只断言最终金额无法证明选择来源。测试可以给候选增加独立标识,或选择能区分结果的边界订单;这属于需要补充的迁移实验,本次 19 条命令没有实施。把所有 implicit 一次替换为 given,不能替代这些检查,也不适合作为共享 Scala 2/3 源码的第一步。
宏和编译器插件不能按普通库处理
普通方法调用依赖已经编译的实现。宏调用还要求编译器执行对应的展开机制。Scala 2 宏建立在不同的编译器 API 上,Scala 3 不能直接展开它们;包含 Scala 2 宏的库并不因此所有方法都不可用,问题集中在需要展开的调用入口。官方元编程迁移说明
编译器插件也需要单独列项。Scala 3 标准插件使用自己的插件 API 和 plugin.properties 入口,不能只把旧插件坐标的后缀换成 _3。冻结版本的文档还明确取消了 Scala 2 analyzer plugin 这一机制;研究插件虽然可改动编译管线,却仅面向相应 nightly 或 snapshot 环境,不能作为稳定应用构建的等价替代。Scala 3.3.7 的插件规则
| 阻塞项 | 可尝试的替代路径 | 必须补充的验收 |
|---|---|---|
| Scala 2 宏调用没有 Scala 3 实现 | 升级到库作者的 Scala 3 版本,或用 inline、引号与 splice 重写相应入口 | 比较展开行为、错误诊断与运行结果 |
| Scala 2 编译器插件没有目标版本发行物 | 使用已移植插件;评估目标编译器内建能力是否覆盖所需功能 | 对插件负责的规则加入违规与合法样例 |
| Scala 3 公共类型超出 Scala 2 reader 能力 | 保留双版本实现,或增加只暴露受支持类型的普通接口 | 用真实 Scala 2 消费者编译并运行 |
| 运行时依赖 Scala 2 反射信息 | 升级或替换库;检查能否使用满足需求的显式注册或其他反射路径 | 执行实际反射入口,不能只检查类可加载 |
替代方案应按所需行为选择。宏用于生成编解码器时,手写实现可以作为小范围过渡;宏用于编译期拒绝非法输入时,换成运行时校验会改变错误出现的阶段,必须明确接受这一差异。插件承担额外静态检查时,去掉插件后构建通过,只能说明检查不再执行。
Scala 3.8 之后,兼容方向发生了变化
本实验的成功组合不能推广到任意新版本。官方 reader 状态说明列出了补丁级支持范围:Scala 2.13.16 对应到 Scala 3.6 的稳定 TASTy,2.13.17 对应到 3.7;Scala 3.8 及之后不在反向 reader 支持范围。本实验的 3.3.7 位于前一范围内。TASTy reader 状态说明
Scala 3.8 起标准库由 Scala 3 编译。Scala 3.9 发布说明同时强调,Scala 3 继续支持消费 Scala 2.13 库,但旧方向的反向兼容已经结束;依赖 Scala 2 scala-reflect 读取旧式信息的运行时路径也可能失败。这不意味着所有 Scala 2.13 库都失效,需要检查库是否实际依赖那条反射路径。Scala 3.9 发布说明
因此,依赖表应写完整的消费者版本、生产者版本、使用的公共 API 和运行库版本。“使用 _3 产物”没有充分说明消费者能否理解它。解决依赖冲突时,也不能把强制覆盖某个标准库版本当作兼容性证明;解析成功后仍须重新编译消费者并执行关键路径。
这里的版本分界是文档核验结果,未在本章启动 3.8 或 3.9 实验。若项目计划升级到这些版本,应建立新的运行目录和工具摘要,保留 3.3.7 的旧证据作为对照。
重跑、审阅与迁移顺序
在仓库根目录运行独立 runner,给每次实验一个尚未使用的编号:
1 | |
runner 会建立新 target 和 evidence 目录,已有同名目录会导致拒绝,避免覆盖原记录。工具可以从缓存复用,但首次准备或摘要核对可能访问 Maven。具体 Java 路径、编译参数与 classpath 以每个命令 JSON 为准。
本次原始日志中,scala3-default-reject 和 scala3-library-scala2-no-reader 都以预期的非零退出码结束,并匹配目标诊断;其余 17 条命令退出为零。现代客户端的实际输出是:
1 | |
审阅 19 条命令时,还要保留“预期失败也是通过验收”的区别。只统计进程退出零的数量,会把两个用于证明拒绝边界的反例误报为事故;仅确认反例非零,又可能把依赖下载失败当成正确诊断。runner 同时检查退出码与目标模式,日志中的失败必须发生在所测规则上。原始输出和命令一起保存,才便于复核失败究竟来自哪一阶段。
业务回归也有自己的遗漏方式。现代客户端分别以对应版本重新编译后再运行,证明了这两份新客户端的行为。它没有模拟历史客户端保留旧二进制并替换库,也没有覆盖远程发布后的依赖解析。若发布承诺包含这些使用方式,就需要额外保留旧客户端二进制、创建独立消费者工程并测试替换后的运行。对小库而言,准确列出这些缺口比扩大兼容性措辞更有用。
迁移应用时,可先建立最小的共享业务回归,再检查直接与传递依赖中的宏、插件和反射入口。阻塞项有明确替代方案之后,按依赖方向迁移模块,逐步去掉临时 source mode。每个模块交付时同时保存源码差异、消费者编译和业务回归,能够避免把一次成功启动误认为全部兼容性已经成立。
练习与解答
手算: 单价 0.50、数量 1、费率 0.05,沿现有公式最后保留两位,结果是什么?如果在乘费率之前先把单价保留两位,会改变这一例吗?
结果为 0.52,因为 0.525 在 0.52 与 0.53 之间,保留偶数末位 2。单价本来就是两位,所以该例不能区分提前舍入。若要检测舍入位置,应另外设计包含更多小数位或多行累加的输入,而不是依赖这个中点案例。
诊断: 旧客户端在 Scala 3 下编译成功,是否能证明库的源码也已迁移?
不能。先查编译命令是否包含库源码,还是只给了 Scala 2 产物的 classpath。后者保留了 Scala 2 定义方法的括号豁免。把库源码与客户端一起用 Scala 3 默认模式编译,才是本章失败组所验证的场景。
修改: 给现代客户端增加“费率为 1.01 时拒绝”以及一个多订单行案例,重新运行双版本回归。应该修改自动改写前的旧客户端来加入这些断言吗?
应将新业务断言放进现代客户端,保留旧客户端作为最小迁移差异样例。新案例需先写出按总额收费并最终舍入的预期值,再使用新的 run-id 执行。仅凭已有日志不能声称新增断言通过。
依赖判断: 将 Scala 3 库从 3.3.7 升到 3.9,再给 Scala 2.13.16 消费者加上 -Ytasty-reader,是否足够?
不足够,3.9 超出了该 reader 的支持范围。需要改变发布与消费方案,例如为旧消费者保留兼容的交叉发布产物,或迁移消费者;不能用本章普通 API 的成功日志抵消版本边界。
资料与实验入口
语言规则以冻结的 Scala 3.3.7 源码文档 和 Scala 2.13.16 reader 说明 为基础;较新版本方向分别引用上文 reader 状态与 3.9 发布说明。兼容性分类可对照 官方迁移指南。
完整实验在 examples/scala-lab/modules/37/。归档中的 source-sha256.json 标识共享库、两份客户端和 runner,toolchain.json 标识工具与基础库,products-sha256.json 标识实际产物。素材目录的 RUN.md 给出日志索引与复现边界。
