Scala 23:类型成员、路径依赖与细化
两个仓库的键,为什么不能互换
订单仓库和用户仓库都使用整数编号。若两个接口都接收 Int,orders.fetch(userId) 可以顺利编译,即使编号恰好存在于另一个业务域。第 16 篇的 opaque type 能区分固定业务类型;当隔离依据变成“由哪一个仓库实例创建”时,路径依赖类型提供了另一种表达方法。
1 | |
本章隔离反例的最后一行无法编译。Scala 3.3.7 报告找到的是 users.Key,需要的是 orders.Key。整数相同、内层类名称相同、外层类定义相同,都不能替代所属路径相同这一条件。这一拒绝是本章要解释的结果。
路径依赖类型并不要求编译器读出运行时对象内容,再按内容任意计算新类型。这里的事实是:orders 和 users 是两个稳定引用,每个引用提供一个可选择的类型成员或内层类型。类型系统保留选择发生在哪条路径上,从而把对象与它关联的数据联系起来。它解决的是静态关系的表达,不能替代访问控制或运行时租户校验。
本章实验冻结 Scala 3.3.7、Scala CLI 1.9.1 和 JDK 21.0.11。完整正例 examples/scala-lab/snippets/23/Chapter23.scala 除了仓库隔离,还验证抽象类型成员、依赖方法、依赖函数值与细化。它们属于同一条数据流,但不能相互当成同义词。
类型成员先描述一个尚未公开的选择
看一个不涉及仓库实现的接口:
1 | |
type Key 声明了一个抽象类型成员。它没有说 Key 一定是整数或字符串,只说这个对象会选择一个类型,并且 key 的值与该选择一致。只拿到 Entry 的调用者可以保留、传递这个值,但不能未经证明就对它进行整数加法。
这与 trait Entry[K] 都能表达“键有某种类型”,但信息组织方式不同。类型参数由使用 Entry[K] 的位置显式提供;类型成员让对象携带一个关联类型,调用者通过某个对象路径引用该选择。前者适合在容器、函数参数和通用算法中显式传播类型变量,后者适合把几组互相关联的类型封装在一个模块或对象接口里。
抽象类型不是每次读取都随机改变的类型。对于稳定对象路径 e,e.Key 是一个可以在其他签名中引用的类型。若接口有 def lookup(k: Key),e.key 与 e.lookup 之间的关系可以一直保留。调用者不必知道具体类型是什么,也能够把同一对象产出的键交回该对象处理。
但“未知”也不是 Any 的别名。Any 表示一个明确的上层类型,而 e.Key 表示由该对象选择、尚未公开具体定义的类型。把某个整数作为 Any 传递通常没有问题;把它当成未知的 e.Key 则没有根据,因为该对象可能选择字符串。抽象类型保留的是一致性要求,不能靠“反正最后都是对象”绕过。
本章第二个编译反例正好暴露这一点:
1 | |
返回值实际属于 e.Key,方法却承诺一定是 Int。编译器拒绝是正确的:接口并没有给出这个等式。增加强制转换只会把承诺变成未经验证的运行时假设,合理修复应该补充类型关系,或收窄该方法真正需要的输入。
依赖方法把输入路径写进结果
若函数只是从对象中提取键,正确签名可以直接保留关联:
1 | |
返回类型提到参数 e,所以这是一个依赖方法。对于每一次调用,结果类型关联该次输入对象。把它写成 Entry => Any 会丢掉这项关系,后续使用者即使拿着原对象,也无法从返回签名中获得“键属于这个对象”的保证。
推导时可以分三步。首先,参数 e 的类型是 Entry,因此可以选择成员 Key。其次,e.key 根据接口定义属于 e.Key。最后,方法结果声明也正是 e.Key,所以方法体满足返回约束。全过程不需要知道键内部是整数、字符串还是另一种领域对象。
参数在后续参数列表中也能承担同类作用。例如 def fetchFrom(r: Repository)(k: r.Key) 先确定仓库路径,再要求键与它匹配。分成两组参数是为了让后面的类型引用前面已经引入的路径。API 阅读者可以直接从签名看出依赖方向,比在实现里检查两个字段是否相等更早暴露错误。
这种方法仍然需要谨慎选择接口稳定性。若返回值只声明成一个与输入无关的上层类型,路径关系会在该边界丢失。随后编译失败往往不是路径依赖机制无法表达,而是中间某个函数提前扩大了类型。排查时应从错误处向上检查每个返回签名,寻找关联首次消失的位置。
反过来,也不应在所有内部方法上都增加依赖返回类型。如果调用者只需要显示键的文本形式,返回普通 String 就足够。保留过多关联会使接口难以组合。签名应暴露调用者真正需要证明的关系,而不是展示实现拥有的全部类型细节。
细化把未知选择补成可见等式
现在构造一个明确选择整数键的对象:
1 | |
Entry { type Key = Int } 是对既有成员的细化:它保留 Entry 的契约,并增加 Key 与 Int 相同这一事实。调用 extract(entry) 的结果类型先关联 entry.Key,再通过等式化简为 Int。因此把结果赋给 exact 不需要转换。
这段代码涉及两种不同的信息:对象实现确实把 Key 定义成 Int;变量声明也把这项事实公开给调用者。若把变量只声明成 Entry,具体实现仍可能使用整数,但调用者从该声明看到的约束更弱。不能因为初始化表达式“看上去就是整数”就要求所有后续抽象边界都自动保留它。
细化还可以增加边界约束而非完整等式,例如声明关联类型是某个领域接口的子类型。等式适合调用者必须精确知道结果类型的场景;上界适合只承诺一组操作、保留实现选择的场景。选择过强的等式可能把实现细节固化进 API,选择过弱的上界则可能让必要关系无法传递。
这里的细化是在父接口已经声明的成员上增加信息,不涉及按名称反射调用任意结构成员。Scala 3 还有结构类型和 Selectable 机制,但不能据此推断本例需要反射。类型成员细化、结构选择和 JVM 反射属于不同问题。阅读代码时应先看选择对象是否已经有该成员,再判断是否进入结构选择机制。
在模块设计中,细化常用于建立两个组件之间的等式。例如读取器与解析器分别定义 Value 类型,组合接口可以要求二者选择一致。如果这个关系只存在于文档中,调用点可能需要转换;若它确实是稳定契约,放进类型签名会让不匹配的组合无法构建。不过运行时格式是否符合协议,仍需要解析器检查真实输入。
Scala 3 的依赖函数值保留参数关联
方法能写出依赖关系还不够。若希望把提取操作存到集合、传给高阶函数,函数值本身也需要相应类型:
1 | |
普通函数 A => B 的输入输出类型在签名中固定。这里函数结果依赖命名参数 e,所以每次应用都将结果关联到那次输入。Scala 3 支持这种依赖函数类型,官方固定版本文档使用相同机制解释依赖方法如何成为可传递的函数值。
不要把它与上一章上下文函数的 ?=> 混淆。(e: Entry) => e.Key 仍是显式接收参数的函数,只是输出类型关联输入路径;Environment ?=> Result 讨论的是参数以给定上下文传递。一个关注类型依赖,一个关注参数传递方式,可以组合使用,但解决的问题并不相同。
高阶组合也要保留关系。把 extractor 赋给一个只返回 Any 的函数变量,往往是合法的信息扩大;再从这个变量调用时,就不再能承诺结果等于输入的关联类型。丢失关系之后再添加转换,并不是依赖函数正常使用所需步骤,而是前面接口选择的结果。
依赖函数也不会在调用时自动验证键的真实归属。静态安全依赖于正常构造和使用规则;如果外部 Java 代码、反射、反序列化或不受控转换能制造不符合约定的值,仍需在边界验证。类型系统减少正确代码中的误用,不是对所有外部输入的真实性担保。
因此实验同时保留静态与动态观测。把结果赋给 Int 是编译器检查类型关系;断言结果等于 42 是运行时检查函数确实返回了预期值。只有后者不能证明关联被保留,因为一个返回 Any 的错误接口也可能在这组输入上得到同样数字。
仓库隔离来自稳定路径,而非编号内容
正例中的仓库实现把构造入口留给所属对象:
1 | |
orders.key(1) 的结果属于 orders.Key,users.key(1) 的结果属于 users.Key。两个调用各自返回的编号内容相同,类型却保留不同路径。fetch 在仓库内部接收自身的 Key,于是跨仓库传参在方法调用处失败,不必等到查询后才发现数据不属于预期领域。
这里的“稳定”是类型选择需要一条可确定的路径,不代表对象深层不可变。val orders 绑定不再指向别的仓库,但仓库内部仍可以有缓存或其他可变状态。路径依赖约束不会让这些状态自动线程安全,也不会让同一实例下所有键都永久有效。过期、删除和权限变化仍然是运行时问题。
路径也不同于对象相等。两个仓库可能重写 equals 得到相等结果,但那不自动给出 orders.Key = users.Key 的类型等式。反过来,两个引用若明确保留同一单例路径,可以共享更精确的静态关系;把它们扩大为普通 Repository 后,编译器不应依据运行时猜测恢复等式。避免靠引用别名的偶然推断设计公共 API,更稳妥的做法是在需要处明确传递所有者。
生产接口还需考虑键跨进程传输。序列化为整数后,路径信息不会随字节自动保留。接收端应在选定仓库下重新校验并构造它自己的键,例如检查租户、版本和存在性,然后再进入内部静态安全区域。直接把反序列化值转换成某条路径下的类型,会跳过最重要的信任边界。
若业务希望两个仓库共享键,路径隔离可能过强。可以把键类型提到外部,采用 Repository[OrderId],或者通过类型成员等式明确共享。建模标准应是业务是否允许互换,而不是某种类型技术是否更高级。过强约束会逼迫正常调用频繁转换,过弱约束则留下误传空间。
关联路径不等于每个对象都生成互斥类型
抽象类型成员与内层类在本章中分别承担两种工作。Entry.Key 可以被实现细化成已有类型,Repository.Key 则是带所属路径的内层类。不能从仓库反例推广出“只要写了类型成员,每个对象的成员类型都不相同”。
例如两个对象都公开 Entry { type Key = Int },它们的 Key 都等于 Int。只要消费函数需要整数,跨对象传递整数在类型上没有矛盾。此时保留路径有助于描述关联,但公开的等式已经允许它们统一。若业务要求实例级隔离,必须选择确实保留该隔离的表示与构造方式。
这个区别会影响配置系统和数据库访问层。两个数据库实例都使用 Long 主键,不意味着系统希望允许跨库传递;仅将关联类型定义为 Long 并公开出去,不能提供那种隔离。可以采用本章内层键对象的形式,或者在更外层为不同业务域引入独立包装。反过来,两个数据源本来就是同一订单域的副本,则不该强行隔离它们的编号类型。
审查时可以写出需要的等式和不等式。若希望共享,检查签名哪里给出了类型相等;若希望隔离,检查构造和公开接口是否保留不同路径,是否有把成员统一为底层原始类型的细化。最后用两个最小程序分别证明允许的组合能编译、禁止的组合不能编译。仅凭类型名称不同或对象来自不同工厂,无法替代这两项验证。
从类型参数迁移时,保留真正需要的关系
已有代码若使用 Repository[K],不应只为了缩短参数列表改成抽象类型成员。先画出调用者实际传递什么:一个独立键值、一个携带键类型的仓库,还是一个仓库与其结果打包形成的模块。若大多数算法只围绕 K 变换,类型参数通常更直接;若调用者总是拿着同一个对象访问多种关联类型,成员形式更自然。
迁移可先保留外部行为,把所有构造集中到工厂,再让方法结果带回对应路径。每改动一个边界,都运行正确所有者的断言和错误所有者的编译反例。若正常调用必须添加 asInstanceOf 才能继续,应检查是否过早丢失类型关系,或者业务本来需要共享键而非隔离键。
对于要放进异构集合的模块,抽象成员能隐藏各自具体实现,但取出元素后必须保留“某个模块和自己的值”之间的联系。只存一列模块、另一列无类型值,再按索引拼接,不能从容器布局自动获得静态证明。把关联信息装进同一对象的接口,往往比扩大为 Any 后恢复更清楚。
如果函数只需要统一行为,可直接在模块上定义操作,减少抽出未知类型值的次数。例如外部只要求“查询并格式化”,就可以暴露返回字符串的方法,而不是让调用者先提取键再拼装依赖调用。类型成员是支持封装的工具,合理封装有时表现为更少的公开类型操作。
还要区分Scala 2的通用类型投影与这里的路径选择。SomeType#Member 把注意力放在类型本身,而 value.Member 关联稳定值路径。Scala 3 删除了某些通用投影形式;这不是把所有成员类型都删掉了。迁移应检查官方限制说明,根据实际约束重写,不要把所有 # 机械替换成点号。
本章不声称 Scala 拥有任意值依赖的完整定理证明能力。整数内容为 3 并不会让任意程序都能自动证明数组长度是三。这里已经足够处理一类具体问题:让某个对象选定的类型在相关参数和结果之间保持一致。把范围限定在稳定路径和关联成员上,错误诊断也更容易读懂。
实验结果与可重跑入口
本章为 LAB_VERIFIED。仓库根目录设置 JDK 21 后执行:
1 | |
正例输出:
1 | |
原始命令、退出码和输出保存在 examples/scala-lab/evidence/20261002-ch23-r2/。23.json/.log 证明正例断言通过;negative-23-wrong-owner.json/.log 验证错误仓库键被拒绝;negative-23-erased-refinement.json/.log 验证未知成员不能当作整数返回。每个反例都有独立源目录和 case.json,因此不存在一个错误掩盖另一个错误的情况。
正常路径验证两种仓库各自读取自己的键、依赖方法返回整数、依赖函数保留结果关系以及普通泛型恒等函数的对照。它没有测试持久化、网络鉴权或 Java 反射绕过约束,不应从这个小程序推导跨服务租户隔离已经完成。
判断与修改练习
手算题:函数参数写成 e: Entry,函数返回 e.key。直接把结果声明成 Int 是否成立?若把参数改成 Entry { type Key = Int } 呢?前者不成立,因为缺少成员等式;后者成立,因为调用者必须先交付选择整数键的对象。答案不依赖当前传入的某个对象恰好使用整数。
第二题:两个仓库生成编号都为 1 的键,可以互传吗?正例的路径设计下不可以,内容相同不证明所有者路径相同。若需求允许共享订单键,应该在 API 中声明共享的类型关系,而不是在单个调用点转换。这个问题检验的是业务隔离模型,不是数字相等规则。
执行练习给 Entry 增加 def render(k: Key): String,编写 def display(e: Entry): String,内部将 e.key 交给 e.render。不要公开具体 Key,也不要转换。答案形状是 e.render(e.key):生产和消费都沿同一条路径发生。分别提供整数键和字符串键的实例,断言两者均返回预定文本。
再把 display 改成接收两个独立的 Entry,尝试把第一个对象的键交给第二个对象渲染。应保存一个编译失败样例,说明缺少的是两个关联类型之间的关系。可执行修复有两种:要求它们共享某个类型参数,或用细化声明相同类型成员。应根据业务是否允许这种跨对象组合选择,而不是仅以“能编译”作判断。
参考与后续
依赖方法和依赖函数的规则以 Scala 3.3.7 固定文档为版本依据,抽象成员与细化可继续查阅同提交的类型规范及基本定义。原始代码与实验命令见本篇 RUN.md。
下一篇讨论 Match types:当结果类型不只依赖一个路径,还需要按类型结构选择分支时,编译器在什么条件下能够完成归约。
