Scala 22:联合、交叉与相等检查
同一个输入接口,三种不同的约束
订单查询接口接收两种标识:新系统使用 OrderId,外部导入文件暂时使用字符串。实现需要保留两种输入的区别,却不想要求两个类型继承同一个业务父类。另一处资源清理函数要求对象既能提供名称,也能关闭。还有一次重构把客户编号传给订单编号比较,代码能够运行,结果却始终为假。这三个问题分别涉及联合类型、交叉类型和相等比较的准入规则。
它们都能缩小错误出现的范围,但表达的约束不同。OrderId | String 表示传入值可以来自其中任何一类;Named & Closeable 要求同一个值满足两份接口;CanEqual[A, B] 表示类型系统允许两类值使用 ==、!= 比较。第三项不说明比较结果,更不替代业务身份定义。把三者统称为“更强的类型安全”,不足以解释编译器为什么接受或拒绝一行程序。
以下实验冻结 Scala 3.3.7、Scala CLI 1.9.1、Amazon Corretto 21.0.11。相等检查开关只用于单独的反例或局部代码,不改变整个实验工程的默认语言模式。完整正例在仓库 examples/scala-lab/snippets/22/Chapter22.scala;正文保留解释规则所需的片段。
联合类型保留的是输入可能性
先定义一个具有业务意义的包装类型:
1 | |
调用者可以传 OrderId(7),也可以传 "legacy"。两次运行的结果分别是 order:7 与 external:legacy。联合类型不需要为这次调用临时生成一个 Left 或 Right 包装。函数内部仍接收原对象或值表示。这里限制的是编译期允许输入哪些类型,不能由此推出所有平台、所有泛型用法都不产生分配。
从替换关系推导,既然函数承诺接受 OrderId | String,它就必须接受每一个 OrderId,也必须接受每一个 String。因此有 OrderId <: OrderId | String,以及 String <: OrderId | String。反方向不成立:一个联合类型的值不能直接交给只接受 OrderId 的函数,因为它也可能是字符串。联合把允许进入接口的值集合扩大了,函数可以无条件使用的操作反而受到限制。
这解释了一个常见错误:
1 | |
字符串有 length,整数没有。编译器不能因为“某个备选类型有该方法”就接受调用。仓库的 negative/22/union-member 实际编译失败,诊断包含 length is not a member。修复要么在模式匹配后对字符串分支调用 length,要么重新定义接口,明确整数分支应该做什么。强制转换只把缺失的证明改成了运行时风险。
这里没有“联合一定不能访问任何成员”的规则。某些所有备选类型都能安全使用的操作仍可用,例如来自共同上层类型的操作。关键是操作是否由当前静态类型保证。维护公共接口时,与其依赖复杂的推导细节,不如先列出调用者允许输入什么、函数在区分输入之前必须完成什么。若区分前就需要统一的业务行为,一个共同接口往往比联合更清楚。
Scala 3.3.7 的固定版本联合类型文档还说明,类型推断可能把分支结果扩大为共同父类型。需要保留联合边界时,应显式写出返回类型或参数类型;不要把某次 REPL 展示的推断结果当作永久 API 契约。透明父类型会影响这类推断,本例使用显式类型,避免把接口设计依赖于该细节。
模式匹配给每个分支增加局部事实
label 的第一条分支识别 OrderId 并取出编号,第二条分支把值缩窄为 String。缩窄只在对应分支的作用域内成立。走到字符串分支时,函数能够调用字符串的方法;回到整个匹配之外,原参数声明仍是联合类型。类型系统没有修改对象,也没有把输入永久转换成另外一个类。
可以手推完整的数据路径。输入 OrderId(7) 满足第一个模式,取出的 n 是 Long,插值产生字符串。输入 "legacy" 不满足第一个模式,进入第二个分支,s 的静态类型是 String。两条分支都返回 String,所以整个匹配的结果也是字符串。这个推导同时解释输入与输出,不需要把“模式匹配很强大”作为理由。
但联合不是互斥和类型。若两个备选是可以共同实现的 trait,一个对象可能同时属于两者。这时值匹配依然按分支顺序选择第一个适用分支。A | B 与 B | A 作为类型可以等价,case _: A 与 case _: B 的代码顺序却未必能交换。类型运算的交换律不能推导控制流的交换律。
订单标识这个例子之所以容易处理,是因为 OrderId 和 String 的运行时形状能够直接区分。若接口写成 List[Int] | List[String],事情会不同:JVM 泛型擦除不能保证普通运行时类型测试区分两个列表的元素参数。联合类型本身不会增加运行时类型标签。外部数据的结构验证、泛型模式的 unchecked 警告与 TypeTest 属于后续边界,不能通过换一个类型运算符跳过。
业务上还要分清“值的来源”与“值的类型”。两个来源都返回 String,类型 String | String 不会保存来源标签。如果后续处理必须知道编号来自导入文件还是人工输入,就应显式建模,例如用不同 case class 或 enum 分支。联合适合表达已有类型之间的可选输入;来源身份则需要数据本身携带证据。
交叉类型要求同一个对象履行两份接口
资源清理需要名称用于记录,也需要关闭操作:
1 | |
调用这个函数时,证明责任在调用者。只拿到一个 Named 引用还不够,因为该引用可能指向完全没有关闭能力的对象。反例 negative/22/intersection 把仅实现 Named 的对象传进去,编译器报告需要 Named & Closeable。把函数参数改为联合无法修复业务:联合允许只具备其中一种能力,而函数体无条件需要两种能力。
正例使用一个具体类同时实现两个 trait:
1 | |
断言分别检查返回名称与关闭状态。若只检查返回名称,删掉 close() 后实验仍会通过,不能证明函数履行资源责任。这里用布尔字段替代真实文件,目标是隔离交叉类型的能力要求;它不验证关闭异常、并发调用或文件系统行为。真实生命周期会在资源管理篇单独运行。
交叉类型也不是把两个对象自动合并。拥有一个 Named 值和另一个 Closeable 值,并不能自动得到 Named & Closeable。需要同一个对象实现两个接口,或者编写一个适配器对两个依赖进行委托。适配器如何处理错误、对象身份和重复关闭,是它自己的行为契约,类型运算不会代写。
从子类型关系看,Named & Closeable <: Named,并且 Named & Closeable <: Closeable。交叉把合法值限制得更严格,却给使用者更多可调用成员。与联合并排比较时,方向很容易记错;从“这个参数是否能安全代替原来那个参数”检查一次,比机械背诵符号更可靠。
交叉中的同名成员还可能提出更强要求。若两个接口对同一个成员分别约束返回类型,具体实现必须同时满足两个约束;编译器不会凭空生成满足条件的方法。部分类型组合可能根本没有正常可构造的值。因此看到 A & B 时应继续问:什么具体实现能交付这个类型,构造条件是否真实成立?类型表达式可写出来,不等于应用已经拥有对应实例。固定版本交叉类型文档将它描述为值必须满足的要求集合。
相等许可和相等结果是两件事
前两节约束值能传给哪个函数。相等检查约束另一种操作:两个静态类型是否适合比较。定义两个编号:
1 | |
即使底层都是 Long,客户编号和订单编号也不是同一个业务域。重构后把 OrderId(7) == CustomerId(7) 留在代码里,可能只得到一个稳定的假值,从而让业务分支永远不执行。严格相等模式让这种错误在编译期暴露。
1 | |
这两次比较能够编译,运行结果由 case class 的相等行为决定。隔离反例使用编译参数 -language:strictEquality 比较两种编号,实际诊断为两种类型 cannot be compared。实验开关的边界很重要:局部导入只影响相应作用域;命令行开关会影响本次编译的所有相关源文件。把实验开关写进共享构建,会让其他章节遇到与其主题无关的拒绝。
CanEqual[L, R] 是准入证据,不是包含 equals 实现的比较器。显式提供跨类型证据可以允许比较,但不会自动让两个类具有跨类型相等关系。如果客户编号与订单编号确实需要按某种映射比较,应优先设计名称清楚的业务函数,而不是因为底层数字相同就放宽 == 的许可。
默认模式也不能简单概括为“任意两类都能比较”。Scala 为兼容旧代码保留了一些回退规则,同时 derives CanEqual 会影响可接受的比较。文章不要求记住所有回退分支;工程中要先确定是否启用严格模式,再在目标模式下测试拒绝样例。完整规则见3.3.7 相等检查文档。
相等许可还不能证明业务不变量。两个 OrderId 可以比较,不代表编号已经在数据库存在;两个 case class 比较相等,也不代表它们来自同一个交易快照。类型检查、值相等与外部事实各自需要证据。为它们使用不同的函数名和测试输入,比继续增加隐式证据更有助于定位错误。
将旧接口逐步改成可检查的边界
假设旧方法参数是 Any,函数内部靠类型测试处理订单编号或字符串。迁移的第一步是统计实际支持的输入分支,而不是直接把 Any 替换成任意联合。确认只有两类输入后,声明 OrderId | String,让所有调用点重新编译。此时编译失败的调用点能揭示历史上“可以传进来但从未定义行为”的值。
第二步检查两条分支的失败语义。字符串为空、编号非正数等是值约束,联合类型不会验证。应在边界解析时构造经过校验的领域对象,或者明确返回错误。对联合的成功缩窄只能证明“它是哪种类型”,不能证明“里面是什么合法业务内容”。
第三步将清理函数的能力约束写在参数上。一个资源工厂若返回过宽的 Named,应核查它是否承诺可关闭,然后决定收紧返回类型或增加适配层。不要在每个调用点散布转换。若某些对象真的不能关闭,则应拆分业务流程,避免把缺失能力伪装成类型推断问题。
最后再引入严格相等。先为领域标识定义同域证据并运行回归,再分模块启用编译参数,逐个检查原有跨类型比较。允许比较的证据属于 API 契约,应该像普通公开方法一样接受审查。为“让编译通过”添加过宽的 CanEqual[Any, Any] 会削弱本来希望建立的隔离。
迁移完成需要具体判据:原有输入结果不变;非约定输入在边界被拒绝;参数类型足以保证资源操作所需能力;错误跨域比较在选定模式下无法编译。一次全绿构建无法替代后三项的拒绝测试,因为删掉类型约束有时只会让更多程序编译成功。
把接口能力和业务状态分别列出
下面的表格适合在代码审查时直接检查参数声明。先判断调用者需要提供哪类值,再判断实现能够依赖哪些事实,能够避免符号方向相反造成的误读。
| 参数形状 | 调用者需要保证 | 实现可以直接依赖 |
|---|---|---|
A |
值属于 A | A 声明的接口 |
A | B |
值属于任一备选 | 联合静态类型共同保证的操作 |
A & B |
同一个值同时满足两项 | 两份兼容接口约束 |
A 加 CanEqual[A, B] |
值属于 A,比较许可可找到 | 可以与 B 值比较,但结果未知 |
最后一行与前三行不在同一分类层级。前三行描述参数类型,最后一行增加上下文证据。表格把它们放在一起,是为了对照证明责任,而不是把 CanEqual 当成第三种集合运算。工程讨论若把“联合”“交叉”“可比较”混成同一种约束,很容易在适配器或隐式实例里放入意外的全局许可。
还可以对照三个不同失败时点。传入完全无关的值时,参数类型检查就应失败;传入正确类型但空内容时,解析或业务校验应失败;资源在前一次操作后已经关闭时,状态协议应拒绝后续使用。三个输入可能都被业务称为“非法”,但对应修复位置不同。前者收紧静态接口,中间者增加值验证,后者管理生命周期。把它们全部转换成一个宽泛异常会丢失诊断信息。
类型扩大是另一个需要审查的动作。若方法起初返回 Named & Closeable,中间变量却显式声明为 Named,使用者就丢失了关闭能力的静态保证。对象本身并没有改变,丢失的是引用携带的信息。后面再通过模式测试恢复能力,需要添加失败路径。若业务始终要求可关闭,最好在这一段数据流中保留较精确的类型。
相等检查也有类似现象。为了绕过严格检查,把两种领域值都先扩大为过宽类型,再进行比较,会使原本有意义的静态区别失去作用。这样的代码即使符合某项语言规则,也应当作为接口退化审查。类型系统只检查程序实际声明的关系,不会猜测开发者本来希望保留哪个业务边界。
因此,错误信息应与最近一次信息丢失一起阅读。缺少 close 未必是资源工厂有问题,可能是中间返回类型过宽;不能比较两种标识也未必需要新的证据,可能是参数传错。优先查声明、赋值和导入,再考虑添加适配器或许可,修复通常更小,边界也更容易解释。
实验记录与读法
本章状态为 LAB_VERIFIED。在仓库根目录设置 SCALA_LAB_JAVA_HOME 指向 JDK 21 后,执行:
1 | |
正例退出码为零,输出:
1 | |
本次原始记录位于 examples/scala-lab/evidence/20261002-ch22/。22.json 保存完整命令和退出码,22.log 保存输出。三个 negative-22-*.json/.log 分别对应缺失成员、缺失交叉能力、跨域相等;成功标准是编译退出非零并匹配该反例声明的诊断,不能只凭出现任意编译错误就算通过。
这些记录验证当前示例在冻结环境的行为。对泛型擦除、类型推断的全部边界或所有相等回退规则,实验没有做穷举,因此文章引用具体规则时保留版本范围。滚动更新的官网联合类型页面适合继续阅读;复现实验以固定提交和工具版本为准。
判断题与可执行练习
判断下列四句话:联合类型的两个分支一定互斥;交叉类型允许把两个不同对象拼在一起传入;交换联合两侧会自动交换模式匹配优先级;存在 CanEqual[A, B] 就意味着某个 a == b 为真。
四句均不成立。两个 trait 可同时由一个对象实现;交叉需要同一个值满足约束;类型等价不改写函数体控制流;相等证据只允许进行比较。回答时应分别指出是类型集合、对象身份、执行顺序还是值语义出了错,而不是只写“编译器会处理”。
执行练习把 Session 的 close() 改成增加关闭计数,连续调用 finish 两次。先预测类型检查是否变化,再增加断言验证计数。答案是类型约束仍满足,而关闭操作会执行两次;是否应该只关闭一次必须由业务协议决定。若把第二次关闭改成抛异常,交叉类型仍无法证明调用安全,这正好说明能力存在与能力可在当前状态使用的差别。
再为查询输入增加 ExternalId(value: String)。要求明确区别普通字符串和外部编号,而不是把它们都转换成字符串后再判断。可将参数扩大为三项联合并新增匹配分支,也可以改成 enum 统一建模。完成后应为三个输入各写正常断言,并保留无关类型的编译反例。选择联合或 enum 的依据,是备选类型是否已经独立存在,以及协议是否需要一个统一、封闭且有名称的业务家族。
参考与系列位置
主要规则来自 Scala 3.3.7 固定提交中的联合类型、交叉类型和多元相等文档,链接已放在对应推导旁。实验素材说明见本篇资产目录 RUN.md,可执行源代码与原始诊断共同构成本章的验收材料。
前置内容包括泛型边界与型变、代数数据类型和上下文参数。下一篇讨论类型成员、稳定路径与细化:当两个仓库的键值都长得相同,如何让类型保留它们所属的对象。
