Scala 01:表达式与类型推导
实验状态:
LAB_VERIFIED。代码在examples/scala-lab/snippets/01/,失败样例在negative/01/;统一验收记录位于examples/scala-lab/evidence/20261002-batch01/,Scala CLI 与 sbt 均通过本章断言,两个编译反例命中目标诊断。
金额计算为什么会突然变成 Unit
一个函数先计算金额,再打印日志。如果最后一行从 subtotal + shipping 换成 println(subtotal + shipping),代码并没有停止执行,但返回结果已经改变。Scala 的代码块可以产生值,尾表达式参与确定整个块的结果。日志会输出金额,却不会因此成为那个金额。
这一点把语法、求值与类型联系起来。表达式的静态类型说明它允许怎样使用;运行时值是某次执行的结果;副作用是执行中对外部状态的改变。三个维度不能互相替代。看见日志打印 28.20,不足以证明函数返回 BigDecimal,也不能说明一个分支不会抛异常。
本章在 Scala 3.3.7 下,从订单金额的小例子推导代码块、分支和底类型,再检查类型推导何时保留联合、何时选择公共父类型。类型关系完整展开在后续章节,本章只使用解释这些结果所必需的部分。
从内层表达式向外确定结果
正常例子先算小计,再根据条件决定运费,最后把两者相加:
1 | |
可以从内向外分析。比较表达式产生 Boolean;if 用它选择一条路径;两条路径都给出 BigDecimal,因此 shipping 有可以参与金额加法的类型;最外层的尾表达式也给出 BigDecimal。外层显式标注要求这些结果满足约束,编译器不能仅因为内部曾经出现 BigDecimal 就认为整个块满足要求。
代码块中局部定义的作用域只覆盖相应区域。计算过程并不要求用可变字段保存每一个中间结果,表达式的嵌套结构已经表达先后关系。这里的局部绑定也不意味着编译器必须为每个名字生成一个长期存在的堆对象;运行时表示要另看产物,本章不从源码排版推出分配量。
如果把最后一行改成输出:
1 | |
它应因尾表达式的结果不满足 Int 而被拒绝。这个反例没有故意遗漏导入或使用不存在的变量,失败原因集中在 Unit 与 Int 的不匹配。独立文件 negative/01/block-unit/BadBlock.scala 用来检查这条诊断,不能把“某次编译失败”笼统称为规则已验证。
Unit 有值,Nothing 没有正常结果
Unit 的值写作 ()。它适合表示“调用已完成,但结果不携带额外信息”。println 和许多修改状态的方法返回 Unit,调用者可以观察它们的副作用,却不能把这个返回值当作打印出来的数据。Scala 3 类型入门给出了这一类型层次的基础定义。
因此“返回 Unit”不等于“完全没有值”。在 Scala 语义中可以把 () 绑定到变量;它的结果信息量很少,但仍是值。也不应从这个语言事实反向推断 JVM 对任何 Unit 表达式都必须分配一个对象,编译目标与使用位置可能采用不同表示。
Nothing 则没有实例,是类型关系中的底类型。抛异常的表达式不会正常提供一个结果,所以可以与正常返回金额的分支放在同一个函数里:
1 | |
正常分支产生 BigDecimal,另一个分支中断正常返回。后者不会要求调用者拿到一个“异常类型的金额”,因为异常传播与函数的正常结果是两条控制流。这解释了函数为何仍可声明返回 BigDecimal。
不能把 Nothing 解释为 null。默认 null 相关规则处理的是可能出现的空引用;Nothing 是没有正常值的类型。也不能说“所有永不结束的代码都推导为 Nothing”:一个语法上的 while 循环可以有 Unit 类型,即使程序员让它无限执行。类型检查根据语言规则和表达式形式推导,不是自动完成终止性证明。
| 表达式 | 正常结果的意义 | 可观察的其他行为 |
|---|---|---|
subtotal + shipping |
金额值 | 本例没有业务状态修改 |
println(subtotal) |
Unit 值 () |
输出文本 |
throw exception |
不正常返回值 | 异常向调用者传播 |
if ... then ... else ... |
被选分支的结果 | 取决于该分支是否有副作用 |
分支的运行结果与静态类型分开手算
考虑一个分支返回整数,另一个返回字符串的表达式:
1 | |
某次执行只产生其中一种值,静态检查却必须考虑两种可能。Int | String 是“值可能属于 Int 或 String”的联合类型。把它直接当 Int 使用不成立,因为参数为空时得到字符串。代码需要先匹配、缩窄,再使用各自操作:
1 | |
这里的类型模式只针对两个非泛型值类型,不能推广为运行时一定保留所有集合元素类型。模式匹配与擦除在第 08、28 篇另讲。本章首先要求读者区分:程序当前打印了 pending,不代表编译器可以把所有调用下的 mixed 都当成 String。
Scala 3.3.7 也不能套用“一旦分支不同就推导为 Any”的口令。冻结版的透明父类型说明解释了联合类型在只有透明公共父类型时可以被保留。于是该例没有显式标注 mixed,仍可以通过后续联合类型赋值检查。
但这个结果不是“任何不同子类都会保留精确联合”。如果两个类实现普通公共父类型,推导可能选择那个父类型,隐藏分支各自的精确信息。
普通公共父类型为什么会改变推导
反例建立一个普通 Status 父类型:
1 | |
第一行局部绑定允许推导选择 Status;第二行又要求精确到 Created 或 Paid。Status 接口本身并未禁止其他实现,所以仅凭一个静态类型为 Status 的值,不能无条件满足更窄的联合。此处检查的不是运行时对象到底是哪一个,而是绑定后仍保留多少静态信息。
修复有两条含义不同的路径。若 API 真正需要限制为两个实现,直接为 state 标注联合类型;若 API 只需要 Status 承诺的操作,保留公共接口并调整调用者,不强制要求更窄的结果。选择取决于边界上需要表达的契约,不能把更多类型细节一律视为更好的设计。
透明父类型的对照实验则定义 transparent trait Token,两个子类分别为 OrderToken 与 ProductToken。它们的条件表达式可以保留联合,然后赋值给显式的 OrderToken | ProductToken。源码和断言在 Chapter01.scala,检查只用于验证本版的推导差异,不是建议把所有公共接口都改成 transparent。
这些规则会随着语言版本和上下文约束变化。文章采用固定版本文档与编译正反例交叉核对,不能只从当前 REPL 的一行显示推广到全部 Scala 3。后续类型系统篇会讨论更多推导边界,本章保留最小反例即可。
显式返回类型是一条外部契约
自动推导减少了重复标注,但它不能替 API 设计者决定应向调用者暴露什么。一个公开方法只依赖最后表达式推导时,内部加入日志、调整分支或改变返回对象,就可能同时改变公开类型。显式返回类型让这些修改必须继续满足原契约。
例如金额函数声明返回 BigDecimal,最后误改成 println 时会在编译阶段暴露;若无标注,代码可能推导为 Unit,问题直到调用者想做金额计算才出现。推导本身没有错误,是程序没有在需要稳定契约的边界写下约束。
局部中间结果可以保持简洁,公开边界则应根据可读性和演进要求决定标注。每个变量都重复写类型并不会自动提高安全性,也可能压住推导想表达的关系。可迁移的判断是先确定契约边界,再看哪项信息需要稳定,而不是在“全部标注”和“全部省略”之间选阵营。
运算符也参与表达式结构
本章保留两项简单断言:2 + 3 * 4 得到 14,(2 + 3) * 4 得到 20。先决定解析形成的表达式结构,再按结构求值,不能用日志的先后输出猜测优先级。Scala 的中缀写法还可以调用方法,看到符号样式并不意味着它是独立于对象方法的特殊运算系统。
对于业务代码,括号可以明确关键分组,避免读者必须记住所有符号优先级。自定义运算符、右结合与重载的完整规则不是本章主题;它们需要在解释实际 DSL 时逐项验证。这里不把“看上去像数学公式”当作可读性的充分条件。
运行、负例与结果检查
在第 00 篇的工具链上,从仓库根目录运行:
1 | |
正例断言检查金额、fee、联合值的渲染以及运算符分组;透明父类型的结果还要满足精确联合赋值。负例分别检查 Unit 尾表达式与普通父类型拓宽,两个失败目录不会互相污染。统一执行时未传业务参数,mixed 应进入 pending 分支;带参数时需把相应预期也一起调整。
本次实际输出为 01 amount=28.20; fee=4.00; mixed=pending; precedence=14/20,记录在 all.log 与 sbt.log。两个反例均返回退出码 1,分别报告 Found Unit / Required Int,以及 Found Status / Required Created | Paid;完整文本在 negative-01-block-unit.log、negative-01-widening.log。这些诊断与版本、选项一并保留。
诊断匹配要求错误包含相应的 Found/Required 类型或目标类型不匹配信息。不直接依赖整段文字、颜色或显示列数,因为这些属于工具输出格式;也不能只检查“error”一词而遗漏真正失败的位置。实际输出归档后,语言规则、编译器拒绝与业务运行才能各自找到对应证据。
练习与答案
另一项控制流检查是 fee(0):它应抛出 IllegalArgumentException,不能拿到金额零。若业务约定零数量代表无需收费,应明确改变正常分支和返回规则,再增加该输入的断言。Nothing 能使异常分支与正常返回类型协作,却没有判断“零数量应该免费还是非法”。这项决定仍属于领域契约。
手算:将金额块最后一行写成 val finalAmount = subtotal + shipping,没有再补 finalAmount,块还能作为金额返回吗?答案是否定的,最后是定义语句,没有把所定义的金额作为块结果交出。补一行 finalAmount 可恢复所需结果;若外层已经标注返回类型,编译器会帮助发现遗漏。
改动练习:把普通 Status 示例中的 state 显式标为 Created | Paid,保持两个分支不变,再尝试赋给 Status。前一步保留精确联合,后一步仍符合公共父类型约束。两种赋值方向不同:窄信息可以用于更宽接口,已经拓宽的静态类型不能无条件倒推为窄联合。
| 遇到的问题 | 判断方法 | 对应检查 |
|---|---|---|
| 日志正确但返回值错误 | 从尾表达式向外确定结果 | Unit/Int 负例 |
| 某分支抛异常 | 分开正常结果与异常控制流 | Nothing 与 fee 返回类型 |
| 推导类型比预期更宽 | 查看公共父类型和上下文约束 | Status 拓宽反例 |
| 调整实现影响调用者 | 在公开边界固定返回契约 | 显式 BigDecimal 返回类型 |
参考资料与系列导航
- Scala 3 Book:类型入门:类型层次与基础定义。
- Scala 3 Book:控制结构:表达式形式;具体推导以本版实验为准。
- 3.3.7 联合类型参考及透明父类型:冻结版本规则。
- 上一篇:Scala 00:工具链与可复现实验;下一篇:Scala 02:求值次数与状态。
