新增已发货状态后,旧展示函数还能通过吗

订单状态原本只有 Draft 和 Paid,页面展示函数也只有两个分支。随后模型增加了 Shipped,但展示代码没有变化。程序应在构建时发现遗漏,还是等用户打开已发货订单时才抛出 MatchError?这取决于匹配器能看到的类型、现有分支和编译选项。

模式匹配同时涉及几种容易混在一起的工作:根据类型或值选择分支,把数据拆成局部绑定,执行守卫条件,以及检查已知输入是否都被覆盖。它们使用同一种语法,不代表提供相同强度的保证。一个可以编译的 match 可能仍然有运行时失败,一个看起来写全类型的模式也可能只检查了擦除后的外层形状。

本章使用 Scala 3.3.7、标准库 2.13.16 和 JDK 21。订单状态承接前篇的三个分支,输入数量则来自字符串。实验把遗漏分支和泛型元素匹配分别放进独立编译反例,把提取成功、失败和元素逐个检查放进正常程序。

分支由上到下选择,解构之后才执行守卫

1
2
3
4
5
6
7
8
9
10
enum State:
case Draft
case Paid(receipt: String)
case Shipped(tracking: String)

def description(state: State): String = state match
case State.Draft => "draft"
case State.Paid(r) if r.nonEmpty => s"paid:$r"
case State.Paid(_) => "invalid-receipt"
case State.Shipped(t) => s"shipped:$t"

对于 Paid(“r1”),第一个模式不匹配,第二个模式识别付款分支并把收据绑定为 r,随后守卫检查非空。守卫成立,执行该分支结果表达式。对于 Paid(“”),同一个结构模式仍能匹配,但守卫失败,搜索继续到下一个 case,于是得到 invalid-receipt。

守卫失败不是异常,也不是返回 false 给调用方。它表示当前 case 不负责这个输入。理解这个控制流后,就能解释为什么只有 case Paid(r) if r.nonEmpty 仍没有覆盖所有付款值:空字符串对应的那部分输入没有结果分支。

把无守卫的 case Paid(_) 移到前面,会先接受所有付款值,后面的非空分支就不再负责实际输入。模式顺序因此属于程序行为,不是为了排版可以随意调整的列表。重构时不能仅验证“分支集合没变”,还应验证有交叠条件时的优先次序。

变量模式也有边界。小写标识符通常引入一个新绑定,并不自动拿它与外部同名值比较。需要匹配一个已经存在的稳定值时,可以使用反引号稳定标识符模式,或者写显式守卫。这类误读往往使第一条分支意外接受所有输入,比缺少分支更难发现。领域枚举使用 State.Draft 这样的限定名,可以减少名字解析的歧义。

穷尽检查检查覆盖,不检查业务含义

对已知 enum,编译器能根据各分支分析覆盖关系。省略 Shipped 的展示函数会产生穷尽性警告。本章用 -Werror 把该警告提升为编译失败;没有这个选项时,不能把“产生警告”写成“语言绝对禁止编译”。构建策略决定警告能否进入交付产物,语言分析负责指出潜在的遗漏。

通配模式 case _ => "unknown" 可以覆盖其余输入,却也会使新增分支被默默归入 unknown。对明确需要区分每个状态的界面,完整列举分支更容易在模型变化时暴露待办;对协议网关需要容忍未知值的场景,明确兜底又可能是合理契约。选择应由“新增状态应该强制修改这里吗”这个问题决定。

即使匹配被认为穷尽,也不代表分支结果正确。把 Shipped 显示为 draft,类型和覆盖都可能成立。编译器没有订单展示文案的业务规范,因此测试仍要检查具体输入对应的结果。穷尽检查是遗漏检测,断言是行为检测,两者覆盖不同的错误。

守卫的任意业务表达式通常不能被当成完整的数学证明。两个条件看起来刚好互补,也不应依赖编译器理解所有外部方法、可变状态和复杂算术关系。若希望某个类型分支必有结果,提供无守卫的最后分支更直接。条件与错误处理仍应保持清楚,避免为了消除警告而写一个永远不应到达的抛异常兜底。

提取器把识别和解构变成可定义的协议

外部数量字段是 String,但业务只接受正整数。直接用字符串结构无法表达“可解析且大于零”,可以定义一个提取器:

1
2
3
4
5
6
7
object PositiveQuantity:
def unapply(raw: String): Option[Int] =
raw.toIntOption.filter(_ > 0)

def quantity(raw: String): Either[String, Int] = raw match
case PositiveQuantity(n) => Right(n)
case _ => Left("quantity")

这个 unapply 接收待检查的字符串。返回 Some(n) 时提取成功,n 成为当前分支中的 Int 绑定;返回 None 时当前模式失败,继续尝试后续分支。输入 “3” 得到 Right(3),输入 “0” 与 “x” 都得到 Left(“quantity”)。失败没有抛异常,也没有产生默认数量。

把解析放在 unapply 中能复用识别逻辑,但会影响信息保留。这里 None 无法区分语法错误和非正数。如果用户界面需要不同提示,解析函数应先保留错误种类,再由外层匹配结果。case _ 无法恢复已经丢弃的原因。提取器适合“是否属于这个形状并提取数据”,不一定适合作为所有校验错误的最终接口。

unapply 也不被语言强制为纯函数。它可以读取时间、修改计数器或访问网络,但这样的模式会让分支尝试次数和顺序变成隐藏副作用。尤其在多个 case 重复调用同一提取器时,不能凭语法外观认为结果只计算一次。对昂贵或有副作用的工作,更清楚的方法是先求出命名结果,再对结果进行匹配。

Scala 3 的提取协议不只有 Option 返回值,还支持布尔、产品结构及其他符合规则的结果形状。本章有意采用 Option 这一最容易说明成功与失败的形式,不把这个例子概括成“所有 unapply 都必须返回 Option”。精确返回类型也会影响编译器对不可失败提取的判断,修改签名时应重新检查覆盖诊断。

PartialFunction 保存了输入定义域

只关注付款状态的函数可以写成:

1
2
val paid: PartialFunction[State, String] =
case State.Paid(r) => r

它并没有承诺为 Draft 和 Shipped 给出 String。直接把未定义输入交给它,可能产生 MatchError;使用 lift 则把结果改成 Option,让未定义输入对应 None。集合的 collect 接受部分函数,只保留有定义的输入并产生变换结果,因此 Draft 与 Paid(“r1”) 的列表经过 collect(paid) 后只得到 List(“r1”)。

“部分函数”中的部分是输入定义域不完整,不是函数参数只传了一部分。它与偏应用在类型和运行行为上都不同。偏应用后的普通函数仍然对其输入类型承诺一个返回结果;PartialFunction 另外提供定义域相关操作,但并不自动保证内部业务代码没有异常。

isDefinedAt 的结果也不应该被当作跨时间的许可证。如果部分函数的守卫读取可变状态,先检查有定义,再等待外部状态变化,然后调用,可能得到不同判断。无状态的匹配最容易组合;需要有状态行为时应让检查与执行的关系由明确接口负责。不能把两次调用分开就声称消除了竞态。

List[String] 的模式无法恢复已经擦除的元素类型

下面的判断看起来完整,实际检查不足:

1
2
3
def unsafe(value: Any): Boolean = value match
case _: List[String] => true
case _ => false

JVM 运行时通常能够识别对象属于 List,但不会仅凭这个对象恢复静态参数 String。编译器因此给出无法在运行时检查该元素类型的警告。本章把它作为独立的 -Werror 反例,目标诊断必须包含运行时不可检查的信息。不能只验证编译器退出非零,因为另一处拼写错误也会让命令失败。

安全的替代取决于真正需要的保证。如果只需知道输入是某种 List,可以匹配 List[?]。如果必须返回一份元素全部是 String 的列表,就逐个检查并构造结果。本章正常程序采用 foldRight:每个元素是 String 且后缀已经检查成功时,把它接到已验证后缀前面;任一元素不符合,则整份结果成为 None。

这个过程检查的是实际元素,而不是泛型参数标签。空列表没有违反约束的元素,可以得到 Some(Nil)。混合列表 List(“a”, 1) 则失败。返回新列表还让后续使用不依赖强制转换;如果只是过滤掉非字符串而宣称“原输入合法”,就改变了验收含义,调用方可能丢失订单数据却没有收到错误。

元素检查需要遍历,不能从一个外层类型测试推导常数时间完成。对于一次性 Iterator,检查本身还会消费输入;对于可变容器,检查后内容可能继续变化。这里选择不可变 List 保存新的已验证结构,避免把这些不同生命周期的问题混入泛型擦除例子。

用最小输入区分四种失败

模式匹配出错时,可以先确定失败发生在哪一层。编译时指出未覆盖某分支,说明静态模型与分支列表不一致;unapply 返回 None,说明该模式不接受当前输入;守卫返回 false,说明结构已经识别但附加条件不成立;执行分支时抛异常,则是结果计算失败。这四种现象不能统一描述成“匹配失败”。

本章正常入口 scalaexamples.Chapter08 分别断言非空/空收据展示、数量解析、部分函数收集,以及 String 元素逐个验证。两个编译反例彼此隔离:一个遗漏 Shipped,一个测试 List[String]。隔离的价值是让每份非零退出都能对应一条待证明规则,而不是由最先出现的错误遮蔽其余假设。

测试输入也应有针对性。提取器只测 “3” 无法证明失败分支存在,元素类型只测 List(“a”) 无法暴露擦除风险,展示函数只测 Draft 无法检验新增状态。为每条规则选择一个会把正确与错误实现区分开的输入,比机械增加同类正常样例更有效。

解构不是反向调用构造器

一个容易误导的类比是把 unapply 称为 apply 的严格逆函数。构造器可能接受全部整数,提取器却只认可其中的正数;提取结果也可以重新计算、删去字段或组合多个字段。因此,构造出一个对象不保证某个自定义提取器一定匹配它,提取之后再构造也不保证恢复原值。若编码和解码确实需要往返规律,应把规律写成断言,用边界输入验证,而不能从方法名称推出可逆性。

对订单数量,这个区别尤其具体:输入字符串 “003” 被提取为整数 3,原始格式已经丢失。若审计必须保留原始文本,应让解析结果同时保存文本与规范值。模式绑定只把 unapply 返回的数据交给分支,不保存提取过程之外的信息。

手算与修改练习

手算:将 description 中无守卫的 Paid(_) 分支移动到带守卫分支之前,Paid(“r1”) 会得到什么?答案是 invalid-receipt,因为先匹配的宽分支已经接受该值。调整顺序改变了行为,不能称为纯排版修改。

修改练习:让 PositiveQuantity 只接受不超过一百的正整数,并新增 “101”、“100”、“-1” 三个断言。参考实现把 filter 改为 n => n > 0 && n <= 100。随后把错误类型细化为 Malformed、NonPositive、TooLarge,并保留不同原因。第二步不适合继续把所有失败压成 None;应让解析函数返回 Either,再对 Right/Left 解构。练习的重点是根据输出信息要求选择接口,而不只是让一个模式越来越复杂。

实验记录与依据

源码在 examples/scala-lab/snippets/08/Chapter08.scala,失败案例在 negative/08/。同名素材目录的 RUN.md 记录实际命令、退出码和日志,两个警告案例均明确使用 -Werror。

前置阅读:ADT 与 enum。

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