本篇建立 Scala 系列的冻结实验基线。主线统一入口现已覆盖 00–39,Scala CLI 与 sbt 均实际运行 40 章断言,52 个隔离编译反例命中目标诊断。首批 examples/scala-lab/evidence/20261002-batch01/ 保留七章历史记录;全系列记录与各独立模块实验见工程 README 和每篇 RUN 附件。

从订单金额开始

两件商品各售 12.50 元,另一件售 3.20 元,总额应该是 28.20 元。对这样一个程序,“编辑器没有红线”“命令打印了数字”“测试通过”分别说明不同的事情。编辑器通常有自己的索引与编译服务;运行命令可能使用默认版本;打印出的数字可能根本没有经过业务断言。第一篇需要建立一个能排除这些差异的最小闭环。

实验采用 Scala 3.3.7、JDK 21、Scala CLI 1.9.1 与 sbt 1.11.7。这是系列冻结组合,方便后续重现求值、初始化和型变问题,不是最新版本推荐。Scala 3.3.7 的定位与 JDK 支持范围分别由发布公告和JDK 兼容说明支持。语言版本、构建工具版本、实际启动的 Java 和业务结果要分别记录。

本机默认 Java 是 8,另有 JDK 21 可用。实验通过环境变量指定已有的 JDK 21,不改变系统默认 Java。Scala CLI 的版本页还会显示默认 Scala 版本;它与本工程实际选择的 3.3.7 不必相同。因此程序运行参数显式传入 --scala 3.3.7,不能拿工具首页的默认数字作为编译器证据。

一次运行包含哪些边界

1
2
3
4
5
6
7
8
9
Scala 源文件
|
Scala CLI 或 sbt:选版本、解析依赖、组织源文件
|
Scala 3 编译器:检查语法、类型,生成 JVM 产物
|
指定的 JDK:加载并运行 main
|
业务断言与进程退出码

Scala CLI 与 sbt 都能组织 Scala 程序,却不因此成为语言语义本身。前者适合少量文件与局部实验,后者适合持续增长的工程。编译器负责判断表达式和类型是否合法;JVM 执行产物;断言判断本次业务结果是否正确。这几层用的是不同的检查,任何一层成功都不能替另外一层作保证。

例如依赖下载失败时,编译器可能还没有开始处理 OrderTotals.scala;这不能作为类型错误的实验。程序编译成功后,如果漏算一条订单,JVM 仍可正常启动。只有业务断言明确检查 28.20,才能发现这种逻辑错误。构建工具返回零退出码也不是“订单模型在所有输入下正确”的证明,它最多覆盖实际运行的检查。

观察 本章据此判断什么 需要补的证据
工具版本输出 调用的是哪一个工具程序 实际编译参数与依赖
编译退出码为 0 本批源文件在所选模式下通过检查 程序结果与业务限制
main 正常返回 选定入口执行完成 是否执行了预期断言
金额断言通过 样本总额与预期相等 其他输入与货币规则
CLI 与 sbt 结果一致 两条构建路径完成同一组检查 其他操作系统、版本与部署环境

最小模型先表达必要约束

配套工程的领域代码在 examples/scala-lab/src/main/scala/OrderTotals.scala。第一批只处理一种货币下的订单行,不引入数据库、网络或复杂状态机:

1
2
3
4
5
6
7
8
9
10
11
12
package scalaexamples

final class OrderLine(val unitPrice: BigDecimal, val quantity: Int):
require(unitPrice >= 0, "unit price must not be negative")
require(quantity > 0, "quantity must be positive")

object OrderTotals:
def total(lines: List[OrderLine]): BigDecimal =
var result = BigDecimal("0.00")
for line <- lines do
result += line.unitPrice * line.quantity
result

这里的循环与局部变量刻意保持简单。val unitPrice 与 val quantity 暴露可读取字段,require 把构造前提写成运行时检查;语言并不会仅凭 Int 就知道数量必须大于零。第一篇不用高级组合式写法遮住“逐行金额相加”的规则。后续讲纯函数与集合变换时,再在相同结果断言下改写。

金额从十进制字符串构造,避免先把业务小数转换成二进制浮点数。BigDecimal("12.50") 与 BigDecimal(12.50) 的输入路径不同,不能将后者的一次显示结果当作所有浮点输入都安全的依据。当前样本没有折扣、税率、除法或多币种问题;它也不构成完整货币类型。遇到这些需求时,必须补币种、精度和舍入策略,而不是认为用了 BigDecimal 就已经解决金额语义。

构造约束同样有边界。它阻止新建零数量或负价格订单行,却没有定义空订单是否允许。total(Nil) 返回零是这个汇总函数的数学边界,不自动等于业务允许创建空订单。区分“计算函数可以处理什么”和“业务命令允许什么”,能避免后续把验证散落在每一个调用者里。

让程序自己判断结果

本章入口位于 snippets/00/Chapter00.scala。它用明确断言检查正常样本、空集合与非法数量:

1
2
3
4
5
6
7
8
9
10
11
12
val lines = List(
new OrderLine(BigDecimal("12.50"), 2),
new OrderLine(BigDecimal("3.20"), 1)
)
val amount = OrderTotals.total(lines)
assert(amount == BigDecimal("28.20"))
assert(OrderTotals.total(Nil) == BigDecimal("0.00"))

var rejected = false
try new OrderLine(BigDecimal("1.00"), 0)
catch case _: IllegalArgumentException => rejected = true
assert(rejected)

如果只打印 amount,漏算第二条时会得到 25.00,但进程依然可能成功退出。断言把预期变成机器可检查的条件。非法数量用指定异常检查,其他异常不会被无条件吞掉;否则依赖初始化失败或代码中的空指针也可能被误记为“数量验证通过”。

第一批使用普通可执行断言,尚不引入测试框架。这样读者可以看清每个验证真正执行了什么。性质测试、输入生成和失败缩减在第 34 篇展开;此处三个案例不能覆盖所有订单组合,也不必伪装成大规模测试套件。

从终端重跑相同实验

工程提供 run.py 固定参数并保存输出。Python 只组织下载、进程与证据,不实现 Scala 的订单算法。需要 Python 3、可访问 Maven Central 的网络以及本地 JDK 21。工具 jar 与依赖缓存默认放系统临时目录,不提交进 Git。

从仓库根目录运行,/path/to/jdk-21 替换为本地实际目录:

1
2
3
export SCALA_LAB_JAVA_HOME=/path/to/jdk-21
python3 examples/scala-lab/run.py bootstrap --run-id local
python3 examples/scala-lab/run.py chapter 00 --run-id local

bootstrap 核验 Scala CLI jar 的发布校验值并运行版本命令,chapter 00 编译本章入口与共享领域代码。它的核心 Scala CLI 操作等价于:

1
2
3
4
scala-cli run examples/scala-lab/snippets/00 \
examples/scala-lab/src/main/scala \
--server=false --scala 3.3.7 --jvm system \
--main-class scalaexamples.Chapter00

这里的 system 指所选运行环境中的 Java,因此直接执行这条命令时还应确认 JAVA_HOME 和 PATH 指向 JDK 21。共享脚本会设置这些进程级环境。--server=false 关闭常驻编译服务以减少首批实验的后台状态,不是提高程序正确性的语言选项。

临时目录中的缓存若被系统清理,下次运行会重新下载。已存在的 jar 也要核验校验值,不能仅靠“文件在这里”就认为来源和版本正确。发布方提供的 SHA-1 可检查传输一致性,它不等同于独立签名验证;版本冻结记录额外保存本地 SHA-256,便于后续核对实际产物。

依赖树中的几个 Scala 版本

冻结编译器后,依赖目录里仍可能出现别的 Scala 数字。本次实际解析 org.scala-lang:scala3-library_3:3.3.7,它的发布 POM 依赖 org.scala-lang:scala-library:2.13.16。因此第 03、04、06 篇引用 2.13.16 的基础标准库 API,并不意味着业务代码改用 Scala 2 编译。Scala 3 编译器、Scala 3 配套库与这份共享基础库分别承担不同职责,版本不能只抄一行 scalaVersion 就算全部记录。

另一个例子是 sbt 自身运行使用的 Scala:缓存中会出现 2.12.20,仍不能据此判定订单程序由 2.12 编译。工具运行依赖与项目编译依赖有不同边界。排查一个版本冲突时,先问错误发生在哪条进程、哪项任务和哪个类加载路径,再从对应依赖查起,直接删除看上去较旧的 jar 可能破坏工具启动。

本文版本清单记录发布坐标与实际 jar 的 SHA-256。它没有穷举所有传递依赖,更不是完整供应链审计;但足以将后续基础语言实验绑定到同一组核心产物。重现时若诊断与文章不同,先确认编译参数,再确认这些产物,不通过改动例子去迎合一个来源不明的编译结果。

把同一程序交给 sbt

累计工程只设置版本与额外源码目录,没有提前拆模块:

1
2
3
4
5
6
ThisBuild / scalaVersion := "3.3.7"
ThisBuild / organization := "blog.scalaexamples"
ThisBuild / version := "0.1.0"

Compile / unmanagedSourceDirectories += baseDirectory.value / "snippets"
Compile / scalacOptions ++= Seq("-deprecation", "-feature", "-unchecked")

project/build.properties 将 sbt 固定为 1.11.7。语言版本与构建工具版本位于不同配置中,它们不应因数字相似而混淆。src/main/scala 是工程代码,snippets 是本批分章入口,src/test/scala 放统一断言入口。负例在 negative 中独立保存,不能混进要求编译成功的源码集合。

首批统一验收执行:

1
2
3
python3 examples/scala-lab/run.py all --run-id local
python3 examples/scala-lab/run.py sbt --run-id local
python3 examples/scala-lab/run.py negative --run-id local

第一条通过 Scala CLI 调用统一断言入口,第二条用 sbt 运行第 00 篇与同一组统一断言,第三条逐个编译预期失败样例。首批记录执行七章,当前工程执行全部 40 章;读者只学习本章时,先执行 chapter 00 即可。sbt 任务、插件与多模块在第 35 篇细讲,此处只验证构建边界。

本次两条运行路径都以退出码 0 完成,输出包含 00 total=28.20; empty=0.00; invalid quantity rejected 与统一入口的通过标记。13 个失败样例均以退出码 1 结束,并匹配各自的错误原因。记录分别在 all.log、sbt.log 与 negative-*.log,同名 JSON 保存执行条件;这项实测不包含其他 JDK、操作系统或性能测试。

失败样例为什么要有专门的验收

第二篇的尾表达式反例会要求一个 Int,却以 println 结束代码块。它应因得到 Unit 而被拒绝。如果所有源码一起编译,整个工程失败后很难区分预期错误与意外错误;如果把负例排除但从不运行,又无法证明解释和实际编译器一致。

因此每个负例目录有 case.json,记录诊断需要匹配的原因,以及只作用于该样例的编译选项。脚本同时检查退出码与诊断,网络失败的非零退出码无法通过。某些规则默认只发出警告,本批另用 -Werror 把目标警告提升为失败,并在对应文章写清前提。

证据目录保留实际命令、工作目录、输出、退出码和预期模式。绝对本机路径只是本次执行记录,重跑命令依然使用仓库相对路径。不能把这些原始文件的存在本身当作验收;还要确认输出里的断言执行完整,没有跳过或把所有异常都当成预期结果。

练习、答案与可迁移方法

重跑时还要给证据选择合适的 run-id。同一编号下重复执行同一种模式会覆盖对应输出,适合日常 local 检查;需要比较版本或提交前后的结果时,应分别使用不同编号。成功、失败和耗时属于那次执行,覆盖旧日志后就不能再用它声称先前环境也得到相同结果。首批归档编号保留为历史记录,后续批次另建目录。

手算:删去第二条订单行,总额是多少,哪项检查应该失败?答案是 25.00,28.20 的正常金额断言应失败,空集合与非法数量检查不会自动发现漏行。不同检查保护的性质不同,必须逐个说明。

改动练习:将 quantity 的构造检查从 > 0 改为 >= 0,保留所有测试。零数量构造不再抛异常,rejected 保持 false,最后断言失败。恢复代码后再次运行,不能删除这项断言来通过验证。

遇到的需求 可迁移的方法 本章用例
别人无法重现结果 分开冻结语言、构建、运行时和输入 CLI/sbt/JDK 各有版本记录
错误结果仍正常退出 把业务预期写成执行中的断言 总额必须为 28.20
想证明编译器拒绝错误 单独编译并验证诊断原因 负例与成功源码隔离
想判断测试覆盖了什么 明确每项输入和不变量 总额、空输入、数量约束分别检查

本章建立的是可重复的实验入口,不宣称完整订单系统、跨平台兼容或性能优势。后续文章在同一闭环中逐步增加语言规则,每次增加规则也增加相应反例。

参考资料与系列导航

顺序导航:系列入口:00 · 下一篇:01。