Scala 24:Match types 与类型归约
一个返回类型,为什么有时是字符、有时无法确定
集合工具想描述“某种输入的元素类型”:字符串对应字符,数组对应数组元素,迭代容器对应容器元素。普通泛型参数可以在调用者给定类型之后保持一致,却不能单凭一个固定类型别名表达这三个选择。Scala 的 match type 允许按输入类型的结构计算结果类型。
1 | |
在 Scala 3.3.7 中,Elem[String] 可以归约为 Char,Elem[Array[Int]] 可以归约为 Int,Elem[List[String]] 可以归约为 String。但以下函数不能编译:
1 | |
诊断没有说 match type 语法非法,而是说 Elem[X] 无法完全归约:编译器不知道任意 X 是否匹配第一条 String,也不能证明它与 String 不相交,因此不能跳过该分支。归约停住是缺少静态事实,不是运行时尚未拿到一个对象。
本章把“能否归约”当成核心问题。实验固定 Scala 3.3.7、Scala CLI 1.9.1、JDK 21.0.11;默认语言模式没有借用新版本的类型规则。完整正例位于 examples/scala-lab/snippets/24/Chapter24.scala,两个隔离反例分别保留抽象输入与接口重叠造成的拒绝。
match type 的输入和输出都是类型
定义中的 X 是类型参数。case Array[t] 的小写 t 是类型模式引入的类型变量,匹配到 Array[Int] 时它代表 Int。右侧的 t 也是类型,不是数组里的某个值。整个表达式没有读取数组,更没有取出首元素。
这一层次关系很重要。Elem[List[String]] 能算出 String,但列表仍可能为空。类型结果不承诺数据存在,也不决定索引访问是否安全。若要写一个读取元素的函数,还需要实现值层操作,并定义空输入如何处理,例如返回 Option 或显式错误。
正例直接要求编译器提供类型等价证据:
1 | |
=:= 证明两侧类型相等关系,测试意图比把某个值赋给 Any 更明确。若结果类型意外扩大,普通运行断言可能仍然通过,而这些编译约束会失败。随后正例把字符、整数实际赋给对应别名,并检查值,分别覆盖类型计算和运行结果。
不能通过给 Elem[X] 写一个 toString 日志来直接观测编译器如何归约。类型不是可打印的运行时对象。这里使用编译约束、诊断与固定版本规则交叉验证;若另写宏显示类型,那观测的是宏阶段对类型表示的读取,也不是程序运行时保存了一份完整类型对象。
读类型定义时,可以先把它暂时视作一张转换表,但要给表加一条前提:输入必须提供足以选择分支的信息。String、Array[Int] 这些具体输入比较直接;一个没有约束的 X 不具备同样的信息。转换表的存在不代表每一项应用都能立即化简。
手推归约:匹配、证明不相交、停住
Scala 3.3.7 的固定版本 match types 文档给出了归约框架。对每个分支依次检查:若输入类型是模式类型的子类型,就使用对应结果;否则尝试证明二者不相交。证明成功才能继续后续分支;既不能匹配也不能证明不相交时,归约停住。
因此“不是已知子类型”与“已知没有交集”完全不同。前者只说当前没有足够信息接受这次匹配,后者才能保证跳过分支不会漏掉可能值。这与值层面的 match 有本质区别:运行时面对一个具体值,可以对这个值测试;类型归约面对整个静态类型范围,必须使选择对该范围成立。
对 Elem[String],第一步已经满足 String <: String,结果是 Char。对 Elem[Array[Int]],数组与字符串的类关系足以排除第一项,第二项匹配并绑定 t = Int。对 Elem[List[String]],前两项被排除,列表属于 Iterable[String],第三项给出 String。这三次推导都依赖具体输入提供的信息。
对 Elem[X],第一项没有证据说明所有 X 都是字符串;但 X 也完全可能就是字符串。因此不能跳过它。即使后面加一个 case _ => Int,也不会自动让任意未知输入的结果变成整数。默认分支只有在前面的可能性已经按规则排除之后才有机会被选中。
这种保守性保护分支顺序。若编译器在信息不足时直接跳到后面,后续把 X 具体化为 String 会发现先前结果与第一条规则矛盾。类型推导不能把“现在不知道”误当成“确定不可能”。阅读诊断时,找到它停在哪条分支,比只看最后一行类型不匹配更有用。
空类型还有单独边界。固定版本规则不会把没有值的输入,例如 Nothing,简单看作任意分支都可选并立即归约。不要把子类型关系的某个局部事实独立拿来替代完整算法。本章的实际编译样例集中在普通具体输入和信息不足的输入,不依赖空类型技巧构造 API。
两个 trait 没有继承关系,也可能发生重叠
第二个反例看起来更意外:
1 | |
为什么输入已经写成 HasCode,还不能选第二条?因为某个具体类可以同时实现 HasName 和 HasCode。静态类型 HasCode 包含这种可能值。若直接归约为 Int,就忽略了第一条优先匹配的可能性。
两个接口没有声明继承关系,并不证明交集为空。Scala 允许类同时混入多个 trait,正是这个开放组合能力使“不相交”不能仅凭名称判断。对封闭类结构、final 类、不同单例或不同常量类型,编译器可能拥有更强的排除依据;对任意开放接口,则不能做同样推断。
这个例子也说明分支顺序是类型定义的一部分。把两条规则交换后,语义可能改变,不能像联合或交叉两侧那样随意交换。设计公共 match type 时,应把更具体、确实应该优先的模式放在前面,并检查后续分支是否会被重叠阻断。
如果业务把 HasName 和 HasCode 当成互斥状态,问题首先在建模:两个可共同实现的接口没有表达互斥性。可以使用封闭 enum 家族区分状态,或者改成显式的类型类实例映射。选择哪一种要看是否希望开放扩展;为了让归约发生而把所有接口改成 final 类,可能会错误限制业务。
因此这个反例不是要求删除所有 trait 模式,而是提醒读者核对输入范围。已知具体类同时实现两个接口时,第一条可能直接匹配;只知道某个接口时,结果却可能停住。静态输入越精确,能完成的推导通常越多,但精确性必须来源于真实契约,不能用转换伪造。
递归归约按类型结构前进
match type 可以递归处理嵌套容器:
1 | |
Leaf[List[List[Int]]] 先进入列表分支,把输入缩小为 List[Int];再次进入列表分支,得到 Int;最后选整数分支返回 Int。正例使用 =:= 证据检查这一结果。它遍历的是类型嵌套,不是运行时列表元素,因此与列表长度无关。
递归必须有明确的结构进展。这里每一步剥掉一个 List 外层,而整数和字符串是终止分支。若定义不断以相同输入调用自身,就可能耗尽编译器归约限制或得到循环诊断。把编译选项上限调大,只改变何时失败,不能代替终止理由。
类型递归与值递归还需要分别审查。一个函数的结果类型成功归约,不代表函数处理循环对象图时能终止;一个值算法能够迭代完成,也不代表任意复杂 match type 都能在合理编译成本内计算。两条路径的输入规模不同:类型嵌套层数影响类型计算,数据大小影响运行执行。
维护者应明确公开支持哪些形状。这里没有为所有类型兜底,Leaf[Boolean] 不会凭空得到业务含义。如果需要扩展,先决定布尔叶子的规则,再加入分支与编译测试;不要为了消除报错把所有未知输入都扩大成 Any。扩大结果会使后续算法失去原本希望得到的精确信息。
对于大量相似分支,类型类或普通泛型函数可能更容易维护。match type 的价值在于结果类型确实随输入结构变化,并且调用者需要观察这种变化。若所有分支最终都被当作字符串处理,则普通返回类型通常足够,不必承担额外推导复杂度。
Tuple 拼接为什么需要显式上界
产品类型派生经常遍历字段类型 tuple。以下定义把两个 tuple 类型拼接:
1 | |
输入约束 X <: Tuple 和 Y <: Tuple 限定可参与运算的类型。结果位置的 <: Tuple 则是另一份承诺:即使当前应用尚未归约,结果也仍然是 tuple 的子类型。这一点使递归分支能够把 Concat[t, Y] 放到 *: 的尾部位置,因为该位置要求 tuple。
Concat[(Int, String), Tuple1[Boolean]] 可以分三步算出。第一步抽出 Int,尾部继续拼接 String *: EmptyTuple 与布尔 tuple;第二步抽出 String;第三步遇到 EmptyTuple,返回 Tuple1[Boolean]。从内向外重建后得到 (Int, String, Boolean)。
正例同时写出类型等价证据和具体值:
1 | |
这段代码没有实现运行时的 tuple 拼接函数。它证明类型计算得到预期形状,并且一个对应形状的值能赋给结果。若把它描述为“拼接算法已经完成”,就混淆了类型层与值层。真正实现拼接还需要读取两个输入值并构造结果,同时检查元素顺序和求值次数。
抽象输入下,上界仍然有用。一个只知道 Concat[X, Y] <: Tuple 的泛型函数可以使用 tuple 的共同接口,但不能因此知道首元素一定是整数。上界提供的是保底能力,具体归约提供的是更精确结构。两者不是失败与成功的二选一,而是不同精度的信息。
值匹配不能随意照抄成依赖返回函数
很容易尝试写一个值函数:输入类型为 X,返回类型为 Elem[X],然后对值进行模式匹配。Scala 确实支持一部分与 match type 形状对应的依赖匹配,但这种支持有条件:值匹配的分支、类型模式、匹配对象类型等需要符合规则,不能随意增加 guard 或更换模式再期待同样推导。
例如字符串分支“长度大于零时返回首字符,否则返回错误”已经涉及值条件。match type 只按 String 选择 Char,不会自动把空字符串情况变成另一个结果类型。若 API 必须覆盖失败,应在类型层明确返回 Option[Elem[X]] 或其他错误表示,并实际实现对应行为。
还应防止用 asInstanceOf[Elem[X]] 把难以证明的实现包装成已验证算法。转换让类型检查失去证明作用,运行时断言又可能只覆盖少量输入。对于库边界,如果确实需要在内部使用受控转换,必须记录为何满足不变量,并单独验证所有公开结构;本章的核心归约实验不需要这种转换。
输入类型与运行时值还可能精度不同。同一个字符串对象被赋给 Any 类型变量之后,泛型方法得到的静态 X 可能只有 Any,无法凭对象实际形状完成编译期归约。运行时模式匹配可以再次检查对象,但那属于另一条执行路径。编译器不会为了决定泛型方法类型而执行任意用户程序。
因此设计顺序应先明确结果类型关系,再判断值实现是否自然地遵守该关系。如果两者结构差异很大,改用类型类把每种输入对应的实现与关联结果封装在一起,可能比继续叠加复杂 match type 更稳妥。这里的判断标准是实现可解释、调用者受益,而非类型表达式越复杂越好。
不归约的类型仍然可以被保留和传递
归约停住不表示这个类型不能出现在合法程序里。正例还有一个非常小的方法:
1 | |
方法没有试图制造一个具体字符,也没有把未知结果当作整数,只把收到的值按原类型返回,因此不要求知道 Elem[X] 最终是哪种类型。调用 preserve[String]('S') 时,调用点有具体输入,结果可以是 Char;方法定义本身则保留抽象关系。
这与失败的 def wrong[X]: Elem[X] = 'x' 形成对照。两者返回类型一样,差异在证明来源:前者从参数得到一个已经符合约束的值,后者声称自己能为所有未知 X 构造同一个字符。错误不能简单归因于“泛型中的 match type 不可用”,而应问实现是否需要比签名已知事实更强的信息。
这类恒等、存储或转交操作适合保留抽象结果。若下一层同样接收 Elem[X],通常可以继续传递;若下一层只接收 Char,就必须补充 X 与对应分支的关系。关系可能来自更具体的参数、上下文等价证据或调用点类型参数,不应来自对当前某个测试输入的猜测。
还可以比较一个完全归约结果与一个声明上界。Concat[X, Y] <: Tuple 足以允许把值交给只需要 Tuple 的接口;Concat[(Int, String), Tuple1[Boolean]] =:= (Int, String, Boolean) 才能支持具体字段形状的推导。若调用者只需要前一种能力,迫使所有泛型方法完成后一种归约,会制造没有业务收益的约束。
这也给出 API 分层的方法:外围保留抽象输入与抽象结果,让泛型组合保持简单;只有真正使用具体结构的位置才要求更强事实。并非每个中间方法都需要展开完整结果类型。合理的信息隐藏能缩小升级编译器时需要检查的细节,同时保留必要的公开关联。
在审查复杂类型错误时,可以先把实现替换成这种转交形状作定位实验。若转交能够编译,而具体构造失败,缺口通常是构造结果所需的类型证明;若转交也失败,则应检查输入输出是否其实引用了不同类型参数或不同路径。定位实验完成后恢复真实实现,不能把一个只会传参的占位函数当作业务完成。
版本迁移与诊断定位
Match types 的规范与实现仍有演进。官方 SIP-56讨论了更精确的模式合法性与归约规则,也记录了兼容处理方向。它是理解历史变化的资料,不能直接当成冻结 3.3.7 每个边界行为的替代证明。本章使用固定源码文档并实际编译例子,避免把滚动官网中的规则混写成一个没有版本标签的算法。
迁移时应优先建立“类型测试集”。对关键输入使用 =:= 或显式赋值断言,保存无法归约、重叠和非法形状的编译反例,再比较升级前后的接受与拒绝。业务测试只验证几个最终值,可能看不出公开类型已经扩大或错误组合开始被接受。
遇到报错,先定位 selector 是具体类型、类型参数还是抽象成员;再找到第一条不能匹配且不能排除的分支;最后检查这个分支是否本来就可能与输入重叠。只有确认规则和契约都正确之后,才考虑编译器版本差异或缺陷。直接交换分支、增加兜底或扩大类型,容易让错误暂时消失却改变 API 含义。
运行证据与练习
本章为 LAB_VERIFIED。运行命令:
1 | |
正例退出零,输出 24 Elem[String]=Char Concat=3-elements recursive-leaf=Int。证据在 examples/scala-lab/evidence/20261002-ch24/ 的 24.json/.log。两个反例最终核验记录在 20261002-ch24-r2/;首次记录保留了真实拒绝输出,但诊断正则把 could not 写成 cannot,修正后重跑均匹配。这个差异属于验收脚本措辞,不是类型规则变化。
手算题:Concat[EmptyTuple, (Int, String)] 的结果是什么?Concat[Tuple1[Int], EmptyTuple] 呢?前者直接得到 (Int, String);后者先保留 Int,再拼接空尾,结果为 Tuple1[Int]。如果输入只是 X <: Tuple,不能承诺长度或首元素,只能依赖声明过的结果上界。
编译判断题:为什么 Choice[HasCode] 不能直接当成 Int?答案必须提到 HasCode 可能也满足前面的 HasName,因此不能证明前一项不相交。只说“泛型擦除”是错误解释:拒绝发生在类型归约阶段,与是否实际创建或读取对象无关。
执行练习为 Leaf 增加布尔终止分支,补上 Leaf[List[List[Boolean]]] =:= Boolean 的编译检查,再给结果赋值并运行断言。随后故意把终止分支删掉,确认测试在编译阶段失败。这个练习验证新增类型规则,而不是仅验证一个布尔值是否等于自己。
再把 tuple 拼接练习扩展到三个输入类型,使用两次 Concat 组合,并手推元素顺序。类型检查通过之后,若继续实现运行时拼接,应为具体元素顺序单独添加断言。类型正确与数据排列正确需要各自的证据,任何一项都不能由另一项自动推出。
后续阅读
本篇固定规则、正例与拒绝诊断的入口见对应 RUN.md。下一篇讨论 inline 与 compiletime:类型结构怎样驱动编译时分支,哪些表达式必须在展开阶段消失,以及普通运行时输入如何保留明确的处理路径。
