Scala 06:泛型边界与型变
一条子类型关系能传播到哪里
创建订单命令 Create 是命令 Command 的一种,因而一个 Create 值可以传给需要 Command 的方法。但这不能直接推出 Cell[Create] 可以传给需要 Cell[Command] 的方法。若那个单元格允许写入取消订单命令,原先持有它的代码再按 Create 读取,就会得到类型承诺之外的值。
泛型型变讨论的正是这种传播关系:已知 Create <: Command,把两者放入同一个泛型类型后,替换方向保持、反转,还是没有由元素关系导出的替换关系。+A、-A 是对整个接口如何使用类型参数的承诺,编译器会检查这些承诺能否成立。Scala 官方型变教程
本篇使用订单命令的生产者、消费者、函数与批次容器,逐个检查调用者实际能做什么。冻结环境为 Scala 3.3.7、Scala CLI 1.9.1、Amazon Corretto JDK 21.0.11。实验状态为 LAB_VERIFIED:本篇断言已在 Scala CLI 和 sbt 两条入口通过,五个独立反例均按对应类型错误拒绝。批次证据位于 examples/scala-lab/evidence/20261002-batch01/;成功运行见 all.log、sbt.log,拒绝诊断见 negative-06-covariant-var.log、negative-06-invariant-assignment.log、negative-06-function-direction.log、negative-06-upper-bound.log、negative-06-covariant-input.log,同名 JSON 保存实际命令与退出码。完整正例位于 examples/scala-lab/snippets/06/Chapter06.scala,独立拒绝样例位于 negative/06/;RUN.md 给出重跑入口。
生产者为什么协变
先定义三种领域类型。这里仅表达分类和编号访问,尚未校验外部输入,也没有断言任意字符串都是合法订单编号。
1 | |
一个 Source[Create] 每次调用都承诺返回 Create。需要 Source[Command] 的调用者只会把返回结果作为命令处理;返回值实际更具体,不会破坏它的需求。因此,原本的元素子类型关系可以沿 Source 保持方向传播:
1 | |
具体赋值不需要复制来源对象,不会把每个元素重新包装成父类型。变量使用更宽的静态类型观察同一个来源对象,编译器允许调用者依赖的信息随之减少。正例通过 Source[Command] 获取创建命令并读取 id,不需要强制转换。
1 | |
推导的前提是这个接口的操作方向。若随后给 Source[+A] 添加 def replace(value: A): Unit,调用者便获得向来源写入任意 Command 的能力,原先的安全替换论证失效。型变不是让类型检查放宽的标签;它是声明接口可接受怎样的替换,并要求接口的每个成员保持这个约定。
生产者模式可迁移到只返回数据的查询结果、只读视图、解码器输出。迁移时仍要检查操作是否真的只把该类型参数当作输出。一个叫作 Reader 的类可能同时提供缓存更新入口,名称不能作为可协变的证明。
消费者为什么逆变
消费者接受一个命令,返回固定类型的处理结果。固定返回值不携带 A,因此 A 只参与输入约束:
1 | |
需要 Sink[Create] 的调用者只会提交创建命令。一个能处理所有 Command 的消费者当然能接受这部分输入,因而它可以替代只要求处理 Create 的消费者。方向是 Sink[Command] <: Sink[Create],与元素类型方向相反。
反过来,一个只能处理 Create 的消费者不能代替 Sink[Command]。后者的调用者有权传入 Cancel,而前者未承诺能处理这种输入。“越具体越适合”在输入位置并不成立;接受范围过窄会提高调用者的义务。
两种方向可以统一成一次调用的检查:实际接收者能否接受调用者提供的全部输入,实际返回值是否满足调用者依赖的全部能力。生产者返回更具体的值仍符合要求,消费者接受更宽的输入范围也符合要求。这种推导比把 +、- 分别背成读取和写入更可靠,因为复杂接口可能把函数作为参数,输入里还会嵌套另一个输入位置。
型变只保证这里的静态调用协议。一个声明接受全部命令的实现仍可能主动抛异常、拒绝某些业务操作,或者返回错误字符串。编译器不会从 Sink[Command] 推出“所有命令业务上都成功”。领域拒绝应在后续的错误类型中表达,并由对应测试验证。
函数同时包含两种方向
普通一元函数 A => B 对应的 Function1 在输入参数上逆变,在结果上协变。标准库签名中的 Function1[-T1, +R] 同时记录这两条约束。Function1 官方 API
考虑两个函数类型:
1 | |
需要 Create => Command 的调用者只会输入 Create。general 接受所有 Command,所以可以接受它。调用者只要求返回 Command,而 general 承诺返回 Create,也满足要求。因此,这个赋值同时利用了输入的逆变和输出的协变:
1 | |
函数内部从现有编号构造 Create 只是为了使实验保持确定性,不代表所有命令都应该转换成创建命令。此处验证的是函数值能否替换,业务转换规则仍须另行建模。
反向赋值同时存在两处缺口:一个只接受 Create 的函数无法承担处理任意 Command 的要求;一个只承诺返回 Command 的函数也无法保证结果一定为 Create。隔离反例 function-direction/BadFunction.scala 保留两个函数类型,让编译器在赋值处报告不匹配。不要用 asInstanceOf 将其改成运行时故障,否则失去了证明静态边界的目的。
读复杂签名时可以逐层标注方向。例如一个方法接受 A => Unit 回调,方法参数位置先反转一次,函数输入位置再反转一次,A 的净位置就是正向。这说明“出现在方法参数列表里就必须逆变”过于粗糙,必须考虑类型表达式内部的嵌套。后续阅读集合的 foreach、map 时,这个位置分析比只看最外层括号更有用。
位置分析也不会自动生成正确实现。即使声明被接受,回调调用几次、以何种顺序调用、抛异常后是否继续,都属于求值和行为契约。类型只说明每次调用的输入输出怎样匹配,事件计数和异常路径仍需运行验证。方法参数及嵌套类型实参的方向规则可对照冻结版本的型变规范。
可变单元格为什么保持不变
可读又可写的单元格同时对 A 提出两种相反需求。读取要求结果满足调用者的类型,写入要求实现接受调用者允许传入的值。
1 | |
公开 var value 对外包含读取和赋值能力。假设允许 Cell[Create] <: Cell[Command],调用者就能通过后者写入 Cancel;此前持有 Cell[Create] 的别名仍可以按 Create 读取,类型承诺被破坏。假设反向允许 Cell[Command] <: Cell[Create],读取者又可能直接从一个原本保存 Cancel 的单元格取得“创建命令”。两个方向都不能仅由元素子类型关系保证。
因此,Cell[A] 保持不变。这里“不变”描述泛型实例之间没有这种自动传播的子类型关系,与“对象内部状态永远不改”没有关系。正例创建 Cell[Command],先保存 Create 再写入 Cancel,完全合法。所选静态类型从一开始就允许这两种值。
1 | |
两个拒绝样例分别检查不同的错误层次。covariant-var/ 直接声明 BadCell[+A](var value: A),预期编译器指出协变参数出现在逆变位置;invariant-assignment/ 保持类声明正确,却尝试把 Cell[Create] 赋给 Cell[Command],预期在使用点报告类型不匹配。声明合法与某次赋值合法,是两个需要区分的问题。
如果实际需求只允许某个调用方读取,可以向它暴露 Source[+A] 一类只读接口,内部仍保留不变的可变实现。这个视图限制调用方能做的操作,不会禁止其他别名修改同一对象。接口分离增加可替换性,是否允许共享更新仍属于状态设计。
下界通过新类型参数保留旧值
协变批次需要增加一个元素时,直接使用类参数 A 作为方法输入会冲突:
1 | |
从替换角度看,如果 BadBatch[Create] 可以当成 BadBatch[Command] 使用,后者的 prepend 应接受 Cancel。但原来那份批次的方法签名只承诺接收 Create。即使实现打算返回新对象,不修改旧对象,这份公共签名仍然不能成立。
修复方式是让每次调用引入新的结果元素类型 B,并约束原来的 A 是它的子类型:
1 | |
B >: A 读作 B 是 A 的超类型,也就是 A <: B。旧批次里的每个 A 都能作为 B 使用,新元素本身也是 B,所以新批次的所有元素都满足 B。本例显式选择 B = Command,避免把类型推导细节与边界机制混在一起。Scala 官方下界教程
返回类型变化是修复的一部分。原来的 Batch[Create] 没有被改写成另一种类型,也没有往其内部塞入取消命令;返回的是 Batch[Command],调用者需要保存这个新值。正例既断言新批次包含两个不同命令,也断言旧批次仍只有原来的创建命令。
下界不会自动阻止过度拓宽。如果明确选择 B = Any,就能允许与命令毫不相关的值进入新批次。通用集合通常允许这种操作,而领域 API 可能需要更窄的范围。此时可以把元素约束为命令,并为新参数同时声明下界与上界,例如约束 A <: Command 后使用 B >: A <: Command。是否需要这种限制,应由容器的领域职责决定。
上界给实现可用的能力
上界解决另一个问题:泛型方法想访问 id,必须知道传入类型具有这项能力。
1 | |
B <: Command 允许 B 取 Command 本身或它的子类型。方法体据此可以调用 Command 保证的 id。没有这个上界时,任意 B 不一定具有该成员;仅凭参数名叫 command 不会增加类型信息。Scala 官方上界教程
隔离反例显式写 idOf[String]("o-1")。字符串的值看上去像订单编号,与字符串类型是否属于 Command 无关;编译器应拒绝 String 不满足上界。这类错误最好保留显式类型实参,以便诊断直接暴露“所选类型不符合边界”,而不是同时引入复杂的推导过程。
上下界与型变分别约束不同对象。+A、-A 约束一个泛型类型怎样随参数变化保持替换关系;B >: A、B <: A 约束一次泛型实例化可以选择哪些类型。给 Cell[A] 的 A 加上 A <: Command 只会限制元素范围,无法消除公开读写造成的不变性。
迁移 Java 泛型接口时,也不应仅凭 extends、super 的字面把符号逐一替换。应列出调用方需要读取什么、写入什么,以及边界属于类型声明还是某次使用。Scala 的声明处型变将一部分约束放在类型定义处,方法上下界则可以让单次操作选择新的类型参数;两者常常需要配合,而不是相互替代。
协变容器不保证元素深不可变
列表可以协变,却仍然包含内部可变的对象。以下实验同时保留具体元素类型和拓宽后的视图:
1 | |
列表结构没有被替换,列表里的引用也还是同一个引用;发生变化的是那个引用指向对象的字段。拓宽成 List[AnyRef] 只减少通过这一静态视图直接可用的成员,并不会复制对象、冻结状态或切断已有别名。
这个区分对缓存和事件快照尤其重要。把一个可变订单放入不可变列表后发布,之后继续修改订单,就可能改变所有持有该列表的观察者看见的内容。如果要求某一时刻的稳定快照,需要保存不可变的领域值,或在边界构造满足快照契约的数据;容器名字和型变注解不足以证明这个契约。
同样,Source[+A] 的类型合法性也不要求 A 自身不可变。调用方拿到 A 后能做什么,由 A 的成员决定。协变只保证替换不会破坏静态输入输出约束,对线程安全、对象所有权和时间上的稳定性没有额外承诺。
编译与拒绝场景的验收
从仓库根目录运行正例:
先将 JAVA_HOME 指向冻结的 JDK 21.0.11,并使 PATH 中的 Java 与其一致;--jvm system 使用这一 JDK。已完成第 00 篇工具引导时,也可直接运行 python3 examples/scala-lab/run.py chapter 06 --run-id local-06,由 SCALA_LAB_JAVA_HOME 指定 JDK 并自动记录日志。
1 | |
程序通过断言检查生产者与消费者的替换、函数调用结果、旧批次与新批次内容,以及可变元素共享引用。预期稳定输出包括 function=o-1、bounds=o-2,o-1; original=1 和 mutable-element=paid; same-reference=true。退出码必须为零,不能仅凭打印出其中一行便判断整章通过。
拒绝样例各在独立目录编译。例如:
1 | |
每个目录的 case.json 声明预期诊断语义。验收同时要求非零退出码和所有诊断模式匹配。网络失败、依赖下载失败、主类冲突或另一个样例的错误都不能替代当前类型规则的拒绝证据,因此不要把全部 negative/06 一次性丢给编译器。
| 场景 | 预期拒绝位置 | 所证明的边界 |
|---|---|---|
covariant-var |
类中公开可写成员 | 协变参数不能这样进入赋值输入 |
invariant-assignment |
单元格赋值 | 元素子类型不自动传播到不变容器 |
function-direction |
函数值赋值 | 输入、输出必须分别满足替换方向 |
upper-bound |
显式类型实参 | 类型必须符合上界 |
covariant-input |
协变批次的输入参数 | 新元素操作需要重新设计签名 |
练习与答案
练习一:已有 Sink[Command],能否作为 Sink[Cancel] 传入只发送取消命令的方法?如果消费者还添加 def last: A,原来的 -A 声明是否仍成立?
答案:第一问可以,因为所有取消命令都是命令。添加返回 A 的成员后,逆变参数出现在输出位置,原声明不再满足这种公开接口的约束。可以拆分只写与只读接口,或者保留一个不变的读写接口,取决于调用方是否需要同时拥有两种能力。
练习二:Batch[Create] 调用 prepend[Command](Cancel("o-2")) 后,能否继续把返回结果声明为 Batch[Create]?为什么旧变量仍然是原来的类型?
答案:返回值不能赋给 Batch[Create],因为结果中确实存在取消命令;旧变量没有被修改,仍指向原来的批次。下界允许创建一个元素范围更宽的新值,不会追溯修改旧变量的静态类型。
练习三:现有类型关系 Create <: Command,需要一个 Create => String。Command => String 与 Create => Any 哪个可以提供?
答案:Command => String 可以,它接受所需的所有输入并返回所需结果;Create => Any 不行,因为调用者有权把结果当成 String 使用,实际函数却只保证 Any。推导时先固定需求,再检查候选的输入覆盖范围与输出保证,不要同时交换两个方向。
练习四:把 MutableOrder.status 从 var 改为 val,是否足以证明整个对象图深不可变?
答案:只能证明这个成员不可重新赋值。若对象还持有可变集合、可变嵌套对象或允许状态更新的方法,仍须继续检查引用可达的数据。对于本例仅有一个字符串字段的简化类,这次修改会消除展示的更新入口,但不能把这一局部结论套到所有带 val 的类。
