约束求解与类型类

类型推断的本质是解一组约束方程,而类型类与 trait 把这组方程从等式扩展成带实例解析的谓词集合。本文讲解 Hindley-Milner 的扩展、约束生成与合一、trait 解析与关联类型,以及约束错误如何被翻译成人能看懂的信息。

1. 类型推断作为约束求解

一句话总结: 类型推断不是「猜类型」,而是「从程序结构生成一组约束,再求解出满足约束的最一般类型」。

任何一次类型推断都可以拆成两个独立阶段:生成约束与求解约束。生成阶段只看语法结构,把「这个表达式的类型必须等于那个表达式的类型」写成方程;求解阶段把方程化简,得到每个类型变量的具体取值。

-- 源程序
f x = x + 1

-- 生成的约束
--   f 的类型记为 t0,x 的类型记为 t1
--   约束 1: t0 = t1 -> t2         (f 是一个函数)
--   约束 2: Num t1                 (x 必须支持加法)
--   约束 3: Num t2, t2 = t1        (结果与操作数同类型)

求解后得到 f :: Num a => a -> a。注意结果里保留了 Num a 这个谓词——它不是方程,而是「必须存在一个实例」的要求。这类约束无法在求解阶段消掉,只能保留到实例解析阶段。

阶段输入输出复杂度
约束生成语法树方程与谓词集合与程序规模线性
合一方程集合替换(类型变量的取值)近线性(带并查集)
实例解析谓词与替换具体实现(字典或函数指针)可能指数(需限制)
泛化自由类型变量带量词的类型方案线性

2. Hindley-Milner 及其扩展

一句话总结: HM 用「合一加泛化」给出了完整且最一般的类型推断,但它只能表达等式约束;类型类把等式扩展成谓词,代价是推断的复杂度与可判定性都变差。

2.1 HM 的核心算法

一句话总结: HM 的算法 W 递归地遍历表达式,为每个子表达式生成类型变量,用合一消解方程,用泛化引入全称量词。

# 算法 W 的骨架
def infer(env, expr):
    if is_literal(expr):
        return fresh_type_var()
    if is_var(expr):
        return instantiate(env[expr.name])          # 泛化类型实例化
    if is_lambda(expr):
        tv = fresh_type_var()
        body = infer(env.extend(expr.param, tv), expr.body)
        return Func(tv, body)                       # t_param -> t_body
    if is_apply(expr):
        tf = infer(env, expr.func)
        ta = infer(env, expr.arg)
        tr = fresh_type_var()
        unify(tf, Func(ta, tr))                     # 生成并消解方程
        return tr
    if is_let(expr):
        ta = infer(env, expr.value)
        return infer(env.extend(expr.name, generalize(env, ta)), expr.body)
# 合一的并查集实现:解 t1 = t2 这类方程
def unify(t1, t2):
    t1, t2 = prune(t1), prune(t2)      # 先沿已绑定的链找到代表元
    if t1 is t2:
        return
    if isinstance(t1, TypeVar):
        t1.bind(t2)                    # 绑定,同时做 occurs check
    elif isinstance(t2, TypeVar):
        t2.bind(t1)
    elif same_ctor(t1, t2):
        for a, b in zip(t1.args, t2.args):
            unify(a, b)                # 结构递归
    else:
        raise UnifyError(t1, t2)

occurs check 是必须的:若不检查就绑定 t1 = List(t1),会构造出无限类型,后续操作陷入死循环。

2.2 从等式到谓词

一句话总结: 类型类把「x 支持加法」这类要求写成谓词 Num t1,求解时既要合一等式,又要为谓词找到实例。

class Eq a where
  (==) :: a -> a -> Bool

instance Eq Int where
  (==) = primIntEq

instance Eq a => Eq [a] where       -- 实例本身带约束
  []     == []     = True
  (x:xs) == (y:ys) = x == y && xs == ys
  _      == _      = False

这里 instance Eq a => Eq [a] 是关键:它说明「只要元素可比较,列表就可比较」。求解 Eq [Int] 时,解析器先匹配到列表实例,产生子目标 Eq Int,再匹配到 Int 实例,成功。这个过程可以递归任意深度,因此求解器必须设递归深度上限,否则可能不终止。

特性HMHM 加类型类
约束形式仅等式等式加谓词
求解合一合一加实例解析
最一般类型总是存在需要「约束的最一般形式」
可判定性是(带显式类型标注)否(可能不终止)
错误信息类型不匹配实例缺失或歧义

3. 约束生成与化简

一句话总结: 现代编译器不直接求解,而是先化简约束集合:把等式代入消元、把谓词按「能否立即解析」分流,最后留下一个小的待解集合。

# 约束化简的三条规则
def simplify(constraints):
    # 规则 1:等式直接替换,代入其他约束
    for eq in equations(constraints):
        apply_subst(eq.var, eq.rhs, constraints)
    # 规则 2:可由局部实例立即解决的谓词直接解决
    for p in predicates(constraints):
        if p.is_solved_by_local_instance():
            constraints.resolve(p)
    # 规则 3:无法解决的谓词泛化,进入类型方案的上下文
    return constraints.remaining()
-- 化简示例
f x = show (x + 1)
-- 生成:t0 = t1 -> t2, Num t1, Show t2, t1 = t2
-- 代入 t1 = t2 后:Num t2, Show t2
-- 泛化:f :: (Num a, Show a) => a -> String

约束化简的顺序很重要。若先解析谓词再消等式,可能因为类型变量尚未确定而无法匹配实例;反之先消等式能让更多谓词变得可解析。主流实现采用交替进行的定点迭代:消一轮等式、解析一轮谓词、再消等式,直到不再变化。

# GHC 的约束求解过程可以用 -ddump-tc-trace 观察
ghc -ddump-tc-trace -dsuppress-all Foo.hs 2>&1 | head -60

4. trait 与 typeclass 解析

一句话总结: trait 解析是「在候选实现集合中找到唯一匹配」的过程,它需要在编译期确定用哪个实现,并据此生成直接调用或字典传递。

trait Shape {
    fn area(&self) -> f64;
}

impl Shape for Circle {
    fn area(&self) -> f64 { std::f64::consts::PI * self.r * self.r }
}

fn total_area<S: Shape>(shapes: &[S]) -> f64 {
    shapes.iter().map(|s| s.area()).sum()      // 静态分派:内联后无虚调用
}
// 动态分派:类型擦除成 trait object,走虚表
fn total_area_dyn(shapes: &[Box<dyn Shape>]) -> f64 {
    shapes.iter().map(|s| s.area()).sum()
}

两种分派的编译产物差别很大:

维度静态分派动态分派
代码生成单态化,每类型一份共享一份,走虚表
调用开销可内联,零开销一次间接调用,难内联
代码体积随类型数量增长固定
解析时机编译期必须唯一确定运行期按实际类型
错误表现实例缺失编译报错运行期仍可能 panic
// 单态化:编译器为每个具体类型生成一份代码
// total_area::<Circle> 与 total_area::<Square> 是两个独立函数
// 代价:代码体积膨胀(monomorphization bloat)

解析算法本身是一个受限的逻辑程序:把每个 impl 看作一条 Horn 子句,把待解谓词看作查询,求解就是 SLD 归结。Rust 为此加了孤儿规则(impl 必须与类型或 trait 至少一个在本地 crate)与一致性检查(同一类型对同一 trait 只能有一个 impl),否则求解结果会依赖链接顺序。

5. 关联类型与依赖方法

一句话总结: 关联类型把「trait 的一个输出类型」提升为可被约束求解器推理的实体,它让 trait 解析从「找实现」变成「找实现并解出输出类型」。

trait Iterator {
    type Item;                       // 关联类型:输出
    fn next(&mut self) -> Option<Self::Item>;
}

impl Iterator for Counter {
    type Item = u32;
    fn next(&mut self) -> Option<u32> { /* ... */ }
}

// 使用:编译器必须解出 <Counter as Iterator>::Item = u32
fn sum_all<I: Iterator<Item = u32>>(it: I) -> u32 { /* ... */ }

关联类型带来的新问题是投影类型的规范化。当编译器遇到 <I as Iterator>::Item 且 I 还是类型变量时,它无法立即确定这个投影等于什么,只能生成一条「投影等式」约束推迟求解。

// 关联类型的约束生成
//   I: Iterator              (谓词)
//   <I as Iterator>::Item = u32   (投影等式)
// 求解时先找 I 的实例,再由实例的 type Item 代入投影等式
形式表达求解方式
类型参数trait Shape<T>谓词参数合一
关联类型trait Shape { type T; }投影规范化
关联常量trait Shape { const N: usize; }常量求值
泛型关联类型type T<U>高阶投影,需显式约束

关联类型与类型参数的区别在实践中很重要:一个 trait 对某个类型只能有一个 impl,因此关联类型是函数式依赖(输入决定输出),而类型参数允许多个 impl(一个类型对同一 trait 可以有多个参数组合)。前者让推断更容易(输出唯一),后者让表达更灵活(但需要标注)。

6. 约束错误诊断

一句话总结: 约束求解器失败时留下的信息是「哪条约束无解」,而用户需要的是「源码里哪一行写错了、为什么错、怎么改」,中间这段翻译是诊断系统的核心工作。

原始求解失败信息:
  cannot unify t3 with t7
  约束 t3 = List(t5) 来自第 12 行
  约束 t7 = Int      来自第 15 行

翻译后的用户信息:
  error: expected List(a), found Int
    --> src/main.rs:12:5
     |
  12 |     process(items)      <- 这里传入的是 Int,但函数要求 List
     |             ^^^^^ expected List(a), found Int
     |
  note: process 的参数类型由 src/main.rs:8 定义

诊断翻译的三条常用技术:

  1. 保留约束的来源位置。每条约束记录「由哪个语法节点生成」,出错时回溯到最相关的两个位置。
  2. 避免暴露内部类型变量。把 t3 替换成用户能理解的描述(「闭包参数类型」「未确定的元素类型」)。
  3. 给出修复建议。当失败原因是实例缺失时,提示可能需要的 impl;当原因是缺少类型标注时,提示加标注的位置。
-- 歧义约束的诊断:编译器知道有谓词解不出,但不知道用户想要哪个
-- 错误:Ambiguous type variable 'a0' arising from a use of 'show'
-- 建议:加类型标注 (show (read s :: Int))
main = putStrLn (show (read "42"))      -- 无法确定 read 的返回类型
// 实例缺失的诊断:明确告诉用户缺少哪个 impl
// error[E0277]: the trait bound `Circle: Clone` is not satisfied
#[derive(Clone)]
struct Circle { r: f64 }        // 加上 derive 即可修复

一个容易忽略的点是歧义与失败的区别。失败意味着「无解」,诊断方向是「哪里类型不匹配」;歧义意味着「有多个解或解不出但不影响正确性」,诊断方向是「请加标注帮我确定」。把这两类混在一起报错,是很多编译器诊断质量差的根源。

7. 实现要点与陷阱

一句话总结: 约束求解器的实现陷阱集中在「终止性」与「性能」上:开放世界假设让解析可能不终止,而单态化会让代码体积爆炸。

陷阱表现应对
缺少 occurs check无限类型导致死循环绑定时检查变量是否出现在目标中
递归实例不终止编译挂起设深度上限并报告「超限」
开放世界与一致性解析结果依赖链接顺序孤儿规则与一致性检查
单态化体积爆炸二进制急剧增大对非热点改用动态分派
错误位置丢失报错指向泛型定义处保留约束来源,回溯到调用点
求解顺序不当本可解析的谓词被推迟等式与谓词交替定点迭代
// 陷阱:单态化体积爆炸
fn process<T: Display>(x: T) { println!("{}", x); }
// 被 100 个不同类型调用 -> 生成 100 份机器码
// 对策:把不依赖 T 的部分提取到非泛型函数
fn process<T: Display>(x: T) {
    let s = x.to_string();       // 泛型部分只做转换
    process_str(&s);             // 公共部分只编译一份
}
fn process_str(s: &str) { /* 体积大的逻辑 */ }
-- 陷阱:约束求解顺序导致的假歧义
-- 若先解析 Show a 再消等式 a = Int,会误报歧义
-- 正确做法:等式优先消元,再解析谓词
# 观察单态化产物体积
cargo bloat --release --crates        # 按 crate 看体积占比
cargo bloat --release -n 20           # 看最大的 20 个符号

理解了约束求解,也就理解了现代语言类型系统的能力边界:凡是能被表达成「等式加谓词」的推断需求,编译器都能处理;凡是要依赖运行期信息的,就必须由程序员显式标注。类型标注的作用不是「帮助编译器偷懒」,而是把一个不可判定的问题切成若干可判定的片段。下一篇我们转向一个看起来与类型系统无关、但同样依赖「确定性」的主题:可复现构建。

8. 总结

环节要点
推断本质生成约束再求解,而非猜测类型
约束生成按语法结构线性生成方程与谓词
合一并查集加结构递归,必须有 occurs check
类型类扩展从等式扩展到谓词,牺牲可判定性
约束化简等式与谓词交替定点迭代,避免顺序依赖
trait 解析受限逻辑程序,需一致性检查与孤儿规则
关联类型引入投影等式,需推迟到实例确定后规范化
错误诊断区分失败与歧义,保留来源并给出修复建议
实现陷阱终止性、单态化体积、诊断位置

类型推断与约束求解是编译器中「数学味」最重的部分:合一、归结、投影规范化都直接来自数理逻辑。但工程实现里最难的从来不是算法本身,而是在不牺牲可判定性的前提下尽可能多推断——Haskell 与 Rust 都为此付出了大量的规则细化工作。这也解释了一个常见现象:同一段代码在小规模时推断得很好,规模一变大就冒出各种歧义与超限错误。下一篇我们讨论可复现构建,那里有另一种「确定性」:如何让同一个源码在两次构建中产出逐字节相同的二进制。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「compiler」更多文章

  1. MLIR 与多层次 IR
  2. 可复现构建与确定性输出
  3. 异常处理编译与栈展开