Blog. Understanding Local Type Inference [blog/lti]

在静态类型编程语言的演进历程中,类型推断(type inference)机制始终扮演着至关重要的角色。 它允许程序员省略那些可由上下文导出的类型标注,从而极大地降低了代码的冗余度, 使得程序无论在阅读还是编写上都更为便捷。

在这一背景下,System $F_\leq$ 堪称一座里程碑式的理论高峰。 它优雅地统一了两种在现代编程中至关重要的概念:源自面向对象编程、 为代码复用与抽象提供极大便利的子类型化(subtyping), 以及作为泛型编程基石的非直谓多态impredicative polymorphism)。 然而,一个严峻的现实阻碍了其在实践中的广泛应用: 其强大的表达能力,使得完全类型推断(complete type inference)被证明是不可判定的(undecidable)难题。

本文基于 Pierce 与 Turner 的研究 Local Type Inference, 但也关注工程实践。 它提出了一条迥然不同的路径:摒弃对「完全性」的执着,转而探索一种更简单、更务实的部分类型推断(partial type inference)。其核心理念,在于引入一个额外的简化原则 —— 局部性(locality)。 所谓「局部」,意指任何缺失的类型标注,都应仅仅依据其在语法树上的相邻节点信息来恢复,而不引入任何长距离的约束(例如 Algorithm J 中的那种全局性的合一变量)。

1. How to Read Typing Rules [blog/lti/how_to_read]

对于初次接触编程语言理论的读者而言,文中遍布的数学符号与推导规则或许会显得有些陌生和令人生畏。 请不必担忧。这些形式化工具并非为了故作高深,恰恰相反, 它们是为了达到自然语言难以企及的精确性(precision)与无歧义性(unambiguity)。 本节将提供一份阅读这些规则的简单指南,熟悉的读者可以直接跳过。

读者可以将这些规则,看作是一门编程语言最根本的「物理定律」。它们以一种极为严谨的方式, 定义了什么样的程序是结构良好、有意义的,而什么样的程序则不是。

本文中的大多数规则,都呈现为如下的结构: $$\frac{\text{前提}_1 \quad \text{前提}_2 \quad ...}{\text{结论}} \quad (\text{规则名称})$$ 这条长长的横线,是理解一切的核心。

  • 横线上方:前提(Premises)
    • 这里列出的是一组条件假设
    • 只有当所有前提都得到满足时,这条规则才能被激活或使用。
  • 横线下方:结论(Conclusion)
    • 这是在所有前提都满足后,我们能够推导出的新事实
  • 右侧括号:规则名称 (Rule Name)
    • 这只是为了方便我们引用和讨论,给规则起的一个名字,如 VarApp

在规则的前提与结论中,您会反复看到一种形如 $\Gamma \vdash e : T$ 的核心判断(judgment)。让我们来拆解它的每一个符号:

  • $\Gamma$ (Gamma):这是上下文(Context)。它代表了我们进行推导时,所有已知的背景信息或假设。通常,它记录了变量已经被赋予的类型,例如:$x : \mathbb{Z},\ f : \mathrm{Bool} \to \mathrm{Bool}$。您可以把它看作是「在…的环境下」或「已知…」。
  • $\vdash$ (Turnstile):这个符号读作「推导出」或「证明」。它是上下文与待证论断之间的分隔符。
  • $e$:这是项(Term),即我们正在分析的那段程序代码。它可以是一个简单的变量 $x$,一个函数 $\mathsf{fun}(x)\ x + 1$,或是一个复杂的表达式。
  • $:$ 表示「拥有类型」(has type)。
  • $T$:这是类型(Type),例如 $\mathbb{Z}$、$\mathrm{Bool}$、$\mathrm{String} \to \mathbb{Z}$ 等。

将它们组合在一起,$\Gamma \vdash e : T$ 这整个判断的直白解读就是:「在上下文 $\Gamma$ 所提供的已知条件下,我们可以推导出(或证明),程序项 $e$ 拥有类型 $T$。」

2. Just Enough Type Information [blog/lti/enough]

在放弃了对「完全类型推断」的追求之后,一个至关重要的问题便浮现出来:一个「部分」类型推断算法,其推断能力需要达到何种程度,才算是「足够」?一个极具实践智慧的回答:一个好的部分类型推断算法,其核心使命在于消除那些既普遍又愚蠢的类型标注。

此处的「愚蠢」与「合理」相对。「合理的」标注,如顶级函数定义中对其参数的类型声明,通常能作为有价值的、且经由编译器检查的文档,帮助人们理解代码。「愚蠢的」标注则恰恰相反,它们徒增代码的冗余,却几乎不提供任何有用的信息。可以想见,在一个完全显式类型的语言中,任何人都不会愿意去书写或阅读 cons[Int](3, nil[Int]) 中那两个多余的 Int 标注。

Pierce 对十几万行 ML 代码的调研,揭示了三种最主要的「愚蠢标注」来源:

  • 多态实例化:在所测量的代码中,类型应用(即对多态函数的实例化)的现象无处不在,平均来看,每三行代码中就至少出现一次。这些在多态函数调用点插入的类型参数,几乎没有任何文档价值,纯属语法上的累赘。
  • 匿名函数定义:在类似 map(list, fun(x) x+1) 这样的上下文中,为匿名函数的参数 x 补上类型标注,只会掩盖其核心逻辑,实为一种不必要的干扰。
  • 局部变量绑定 (Let): 为这些生命周期短暂的中间变量一一标注类型,显然是繁琐且意义不大的。

基于上述的定量观察,我们可以勾画出一个「足够」的部分类型推断算法的轮廓:

  1. 它必须能够推断出多态函数应用中的类型参数。与此同时,要求程序员为顶级函数或相对稀少的局部函数提供显式标注,则是完全可以接受的,因为这些标注本身就是有益的文档。
  2. 为了使高阶编程更加便捷,算法应当能够推断匿名函数参数的类型,尽管这并非绝对必要。
  3. 局部变量绑定通常不应要求显式的类型标注。

2.1. Strategies [blog/lti/strategies]

为了实现我们的目标,我们需要设计一套高效的策略来处理上述三种类型推断的场景。 下面的是两种本文将要介绍的主要策略:

  • 局部类型参数合成 (Local Synthesis of Type Arguments):此策略旨在自动推断多态函数应用中被省略的类型参数。其基本思路是,通过比较函数参数的期望类型与实际参数的类型,生成一组关于待定类型参数的局部子类型约束。随后,算法会求解这组约束,并选取一组能使整个应用表达式获得「最佳」(即最精确、最小)结果类型的解。
  • 双向类型信息传播 (Bidirectional Propagation of Type Information):此策略主要用于推断匿名函数中绑定变量的类型标注。它通过将类型信息从外围的表达式(如函数应用节点)向下传播至其子表达式,从而为子表达式提供一个「期望类型」,以指导其类型检查过程。

这两项技术均恪守「局部性」原则,即所有推断所需的信息都只在语法树的相邻节点间传递,不涉及长距离的依赖或全局性的合一变量。

3. Language Specification [blog/lti/language]

在严谨地探讨类型推导这一议题之前,我们必须首先清晰地界定其推断的目标——一个无歧义的、 被完全标注的内核语言(internal language)。此语言是编译器内部的「真实」表达,亦是程序员书写的、 允许省略标注的外部语言(external language)所要翻译成的最终形式。 本文所用的内核语言,源自 Cardelli 与 Wegner 提出的,融合了子类型化与非论域性多态的著名演算 System $F_\leq$, 但在此基础上我们增设了 $\bot$ (Bottom) 类型,为了保证上确界和下确界的存在,这种代数结构的完备性, 是后续章节中约束求解算法得以简洁、确定地运行的根本保障。此外它也可以被用作那些永不返回的表达式(如抛出异常的函数)的结果类型, 我们先定义形式语言如下 (注:横线是序列记号 $\overline{X} = X_1, X_2, \dots, X_n$,类似的,$\overline{x} : \overline{T} = x_1 : T_1, \dots, x_n : T_n$):

$$ \begin{array}{ll} T ::= X & \text{type variable} \\ \quad~\mid~ \top & \text{maximal type} \\ \quad~\mid~ \bot & \text{minimal type} \\ \quad~\mid~ \forall \overline{X} . \overline{T} \rightarrow T & \text{function type} \\[1em] e ::= x & \text{variable} \\ \quad~\mid~ \text{fun}[ \overline{X} ]\, ( \overline{x} : \overline{T} )\, e & \text{abstraction} \\ \quad~\mid~ e[ \overline{T} ]\, ( \overline{e} ) & \text{application} \\[1em] \Gamma ::= \bullet & \text{empty context} \\ \quad~\mid~ \Gamma, x : T & \text{variable binding} \\ \quad~\mid~ \Gamma, X & \text{type variable binding} \end{array} $$

我们可将上面的形式语言简单翻译到下面的 MoonBit 代码, 其中 Var 是类型变量的实现:

pub(all) enum Type {
  TyVar(Var)
  TyTop
  TyBot
  TyFun(Array[Var], Array[Type], Type)
} derive(Eq)

pub(all) enum Expr {
  EVar(Var)
  EApp(Expr, Array[Type], Array[Expr])
  EAbs(Array[Var], Array[(Var, Type)], Expr)
}

3.1. Subtyping Relation [blog/lti/subtype]

子类型关系,记作 $S \lt: T$(读作 $S$ 是 $T$ 的一个子类型),是本形式系统的核心。它定义了类型之间的一种「可替换性」:凡是需要类型 $T$ 的地方,都可以安全地使用一个类型为 $S$ 的项来代替。

与许多理论文献中的定义不同,本文特意选择了一种算法化(algorithmic)的方式来呈现子类型关系。这意味着,定义中仅包含一组最核心的、可直接用于实现判定的规则,而像传递性(transitivity)这样通常作为公理的性质,在此系统中则成为了可被证明的引理。这种风格使得定义本身更接近于一个类型检查算法的规约:

$$ \begin{array}{ll} X \lt: X & \text{(S-Refl)} \\[1em] T \lt: \top & \text{(S-Top)} \\[1em] \bot \lt: T & \text{(S-Bot)} \\[1em] \dfrac{ \overline{T} \lt: \overline{R} \quad S \le: U }{ \forall \overline{X}. \overline{R} \rightarrow S \lt: \forall \overline{X}. \overline{T} \rightarrow U } & \text{(S-Fun)} \end{array} $$

其中 $\overline{T} \lt: \overline{R}$ 成立当且仅当 $\text{len}(T) = \text{len}(S) \land \forall 1 \leq i \leq \text{len}(S). S_i \lt: T_i$。 S-Fun 规则体现了函数类型子类型化的核心特征:在参数类型上是逆变(contravariant)的(子类型关系的箭头方向反转),而在返回类型上是协变(covariant)的(子类型关系箭头方向保持不变)。读者可以自己尝试一下将上文的形式语言翻译到 MoonBit 的一个谓词函数,或参考下面折叠起来的代码片段。

3.1.1. Solution [blog/lti/subtype_code]

/// l <: r
struct Subtype {
  l : Type
  r : Type
}

pub fn subtype(self : Subtype) -> Bool {
  match (self.l, self.r) {
    (TyVar(v1), TyVar(v2)) => v1 == v2
    (_, TyTop) => true
    (TyBot, _) => true
    (TyFun(xs1, r, s), TyFun(xs2, t, u)) if xs1 == xs2 =>
      subtypes(t, r) && { l: s, r: u }.subtype()
    _ => false
  }
}

为了支持后续的约束求解算法,系统必须能计算任意两个类型的最小上界(join, 记作 $\lor$)和最大下界(meet, 记作 $\land$)。得益于 $\bot$ 和 $\top$ 的存在,这两个运算在本系统中是全函数(total functions),即对于任意一对类型,其界都必然存在。下面我们给出这两个运算的定义:

  • 最小上界 $S \vee T$

    • 若 $S \lt: T$,则结果为 $T$。
    • 若 $T \lt: S$,则结果为 $S$。
    • 若 $S$ 和 $T$ 分别为 $\forall\overline{X}.\overline{V} \to P$ 和 $\forall\overline{X}.\overline{W} \to Q$,则结果为 $\forall\overline{X}.(\overline{V} \wedge \overline{W}) \to (P \vee Q)$。
    • 在其他所有情况下,结果为 $\top$
  • 最大下界 $S \wedge T$

    • 若 $S \lt: T$,则结果为 $S$。
    • 若 $T \lt: S$,则结果为 $T$。
    • 若 $S$ 和 $T$ 分别为 $\forall\overline{X}.\overline{V} \to P$ 和 $\forall\overline{X}.\overline{W} \to Q$,则结果为 $\forall\overline{X}.(\overline{V} \vee \overline{W}) \to (P \wedge Q)$。
    • 在其他所有情况下,结果为 $\bot$

通过简单的结构归纳法可以证明,这两个运算满足以下性质:

  • $S \vee T$ 和 $S \wedge T$ 分别是 $S$ 和 $T$ 的最小上界和最大下界。
  • $S \lt: U$ 且 $T \lt: U$ 则 $S \vee T \lt: U$
  • $U \lt: S$ 且 $U \lt: T$ 则 $U \lt: S \wedge T$

这里同样鼓励读者自己进行代码翻译,答案可展开下面的代码片段。

3.1.2. Solution [blog/lti/subtype_code2]

在 MoonBit 代码中我们将 $S \vee T$ 实现为 s | t, 将 $S \wedge T$ 实现为 s & t。这两个运算符的定义如下:

impl BitAnd for Type with land(s, t) {
  match (s, t) {
    (TyFun(xs1, v, p), TyFun(xs2, w, q)) if xs1 == xs2 => {
      guard v.length() == w.length() else { TyBot }
      TyFun(xs1, v.zip(w).map(z => z.0 | z.1), p & q)
    }
    (s, t) if s.subtype(t) => s
    (s, t) if t.subtype(s) => t
    _ => TyBot
  }
}

impl BitOr for Type with lor(s, t) {
  match (s, t) {
    (TyFun(xs1, v, p), TyFun(xs2, w, q)) if xs1 == xs2 => {
      guard v.length() == w.length() else { TyTop }
      TyFun(xs1, v.zip(w).map(z => z.0 & z.1), p | q)
    }
    (s, t) if s.subtype(t) => t
    (s, t) if t.subtype(s) => s
    _ => TyTop
  }
}

4. Explicit Typing Rules [blog/lti/explicit]

在定义了类型与子类型关系之后,我们便可以给出内核语言的类型定则。这些规则定义了类型判断(typing judgment)的形式 $\Gamma \vdash e : T$,意为「在上下文 $\Gamma$ 中,项 $e$ 拥有类型 $T$ 」。

与子类型关系的定义一脉相承,这里的类型定则同样采用了一种算法化的呈现方式。且省略了传统类型系统中常见的包容规则(subsumption,若 $e$ 的类型为 $S$ 且 $S \lt: T$,则 $e$ 的类型亦可为 $T$)。

通过省略此规则,本系统为每一个可被类型化的项,都计算出一个唯一的、最小的类型,作者称之为该项的显式类型(manifest type)。这一设计选择,并不改变语言中可被类型化的项的集合,而只是确保了任何一个项的类型推导路径都是唯一的。这极大地增强了系统的可预测性。

核心定则 (Core Rules)

  • 变量 (Variable)

    $$ \frac{}{\Gamma \vdash x : \Gamma(x)} \quad (\text{Var}) $$

  • 抽象 (Abstraction) 此规则统一了传统的项抽象与类型抽象。

    $$ \frac{\Gamma, \overline{X}, \overline{x}:\overline{S} \vdash e : T}{\Gamma \vdash \mathbf{fun}[\overline{X}] (\overline{x}:\overline{S}) e : \forall \overline{X}.\overline{S} \to T} \quad (\text{Abs}) $$

    若想要求解 $\mathbf{fun}[\overline{X}] (\overline{x}:\overline{S})$ 的类型,必须在上下文 $\Gamma$ 中添加类型变量 $\overline{X}$ 和值变量 $\overline{x}:\overline{S}$ 的绑定。然后,在这个扩展的上下文中,推导函数体 $e$ 的类型为 $T$。最终,整个函数的类型便是 $\forall \overline{X}.\overline{S} \to T$。

  • 应用 (Application) 此规则同样统一了传统的项应用与多态应用。它首先推导函数 f 的类型,然后验证所有实际参数(包括类型参数与项参数)是否与函数的签名相符。 这里的要求更宽松了一些:只要实际参数的类型满足参数类型的子类型关系即可,而不需要完全匹配。

    $$ \frac{\Gamma \vdash f : \forall \overline{X} . \overline{S} \to R \quad \Gamma \vdash \overline{e} \lt: [\overline{T}/\overline{X}]\overline{S}}{\Gamma \vdash f[\overline{T}] (\overline{e}) : [\overline{T}/\overline{X}]R} \quad (\text{App}) $$

    其中,记法 $\Gamma \vdash \overline{e} \lt: [\overline{T}/\overline{X}]\overline{S}$ 是一个缩写,表示 $\Gamma \vdash \overline{e} \lt: \overline{U}$,然后验证 $\overline{U} \lt: [\overline{T}/\overline{X}]\overline{S}$。最终,整个应用表达式的结果类型,是通过将实际类型参数 $\overline{T}$ 代入函数原始返回类型 $R$ 中得到的。

    4.1. Type Parameter Substitution [blog/lti/subst_code]

    这里出现了一个新的记号 $[\overline{T}/\overline{X}]R$, 它表示将类型参数 $\overline{X}$ 替换为实际类型 $\overline{T}$ 后的结果类型。 这个记号在后续的代码中会频繁出现。 下面的代码定义 mk_subst 函数,用来生成一个类型替换映射, 还有一个 apply_subst 函数可以将这个映射应用到一个具体类型上。 $[\overline{T}/\overline{X}]R$ 就是通过 $\text{apply\_subst}(\text{mk\_subst}(\overline{X},\overline{T}), R)$ 得到的。

    pub fn mk_subst(vars : Array[Var], tys : Array[Type]) -> Map[Var, Type] raise {
      guard vars.length() == tys.length() else { raise ArgumentLengthMismatch }
      Map::from_array(vars.zip(tys))
    }
    
    pub fn apply_subst(self : Type, subst : Map[Var, Type]) -> Type {
      match self {
        TyVar(v) => subst.get(v).unwrap_or(self)
        TyTop | TyBot => self
        TyFun(xs, ss, t) => {
          let fs = subst.iter().filter(kv => !xs.contains(kv.0)) |> Map::from_iter
          let new_ss = ss.map(s => s.apply_subst(fs))
          let new_t = t.apply_subst(fs)
          TyFun(xs, new_ss, new_t)
        }
      }
    }
    

  • Bot 应用 (Bot Application) $\bot$ 类型的引入,要求我们补充一条特殊的应用规则,以维护系统的类型安全(type soundness)。 由于 $\bot$ 是任何函数类型的子类型,一个类型为 $\bot$ 的表达式应当可以被应用于任何合法的参数,而不会产生类型错误。

    $$ \frac{\Gamma \vdash f : \bot \quad \Gamma \vdash \overline{e} : \overline{S}}{\Gamma \vdash f[\overline{T}] (\overline{e}) : \bot} \quad (\text{App-Bot}) $$

    此规则规定,当一个类型为 $\bot$ 的项被应用时,无论参数为何,整个表达式的结果类型也是 $\bot$,这正是我们能给出的最精确(即最小)的结果类型。

这些规则共同保证了本类型系统的一个关键性质,即显式类型的唯一性(Uniqueness of Manifest Types):若 $\Gamma \vdash e : S$ 且 $\Gamma \vdash e : T$,那么必有 $S=T$。

5. Local Type Argument Synthesis [blog/lti/synthesis]

截至目前,我们已经定义了内核语言的类型规则, 但是距离我们的目标还有很大距离:核心语言要求我们做出很多注解, 包括多态实例化时需要提供的类型参数,因为这在代码中非常常见,因此这是我们本节首要解决的痛点。

此即局部类型参数合成(Local Type Argument Synthesis)的目标:允许程序员在调用多态函数时,安全地省略其类型参数,写成 $\text{id}(3)$ 而不是 $\text{id}[\mathbb{Z}](3)$ 的形式。省略类型参数后,一个核心挑战随之而来:对于一个给定的应用,如 $\text{id} (x)$(其中 $x : \mathbb{Z}$,且 $\mathbb{Z} \lt: \mathbb{R}$),通常存在多种合法的类型参数实例化方案,例如这里的 $\text{id} [\mathbb{Z}](x)$ 或 $\text{id} [\mathbb{R}](x)$。我们必须确立一个清晰的标准来做出选择:选择能为整个应用表达式带来最精确(即最小)结果类型的类型参数。在 $\text{id} (x)$ 的例子中,由于 $\text{id} [\mathbb{Z}](x)$ 的结果类型 $\mathbb{Z}$ 是 $\text{id} [\mathbb{R}](x)$ 的结果类型 $\mathbb{R}$ 的一个子类型,前者显然是更优、更具信息量的选择。

然而,这种基于「最佳结果类型」的局部策略并非万能。在某些情况下,「最佳」解可能并不存在。例如,假设一个函数 $f$ 的类型为 $\forall X. () \to (X \to X)$。对于应用 $f()$,$f[\mathbb{Z}]()$ 和 $f[\mathbb{R}]()$ 都是合法的补全,其结果类型分别为 $\mathbb{Z} \to \mathbb{Z}$ 和 $\mathbb{R} \to \mathbb{R}$。这两种函数类型在子类型关系下是不可比较的,因此不存在一个唯一的最小结果类型。在这种情况下,局部合成宣告失败。

回顾之前的核心语言定义,我们要求 application 构造的形式为 $e[\overline{T}](\overline{e})$, 也就是说我们手动填写了类型参数 $\overline{T}$,规则 App 能够根据此参数为应用表达式计算出结果类型。 现在我们为了语言更简单易用,允许省略类型参数 $\overline{T}$,现在我们更新语言的构造:

pub(all) enum Type {
  TyVar(Var)
  TyTop
  TyBot
  TyFun(Array[Var], Array[Type], Type)
} derive(Eq)

pub(all) enum Expr {
  EVar(Var)
  EAppI(Expr, Array[Expr])
  EApp(Expr, Array[Type], Array[Expr])
  EAbs(Array[Var], Array[(Var, Type)], Expr)
  EAbsI(Array[Var], Array[Var], Expr)
}

这里加入了新的表达式结构 EAppI(Expr, Array[Expr]),对应我们的省略类型参数形式。 (为了后文叙述方便,这里也增加了后文会用到的 EAbsI 构造) 现在我们需要一条新的规则:

$$ \frac{\text{magic we don't know}}{\Gamma \vdash f (\overline{e}) : [\overline{T}/\overline{X}]R} \quad (\text{App-Magic}) $$

身为人类我们可以用直觉来制定规则,甚至设计出一些无法写成代码的声明式规则, 精确地定义「何为一次正确的、最优的类型参数推断」:

$$ \frac{ \begin{array}{l} \Gamma \vdash f : \forall \overline{X}. \overline{T} \to R \qquad \exists \overline{U} \\ \Gamma \vdash \overline{e} : \overline{S} \qquad |\overline{X}| > 0 \qquad \overline{S} \lt: [\overline{U}/\overline{X}]\overline{T} \\ \text{forall} (\overline{V}). \overline{S} \lt: [\overline{V}/\overline{X}]\overline{T} \implies [\overline{U}/\overline{X}]R \lt: [\overline{V}/\overline{X}]R \end{array} }{\Gamma \vdash f(\overline{e}) : [\overline{U}/\overline{X}]R} \quad (\text{App-InfSpec}) $$

此处我们使用存在量化 $\exists \overline{U}$,并且要求 $\overline{U}$ 满足很多条件。 例如 $\overline{S} \lt: [\overline{U}/\overline{X}]\overline{T}$ 为合法性约束。 它规定我们所选定的类型参数 $\overline{U}$ 必须是合法的。 所谓合法,即指将 $\overline{U}$ 代入函数的形式参数类型 $\overline{T}$ 后,实际参数的类型 $\overline{S}$ 必须是其子类型。 更重要的是最后一条 $\text{forall} (\overline{V}). \overline{S} \lt: [\overline{V}/\overline{X}]\overline{T} \implies [\overline{U}/\overline{X}]R \lt: [\overline{V}/\overline{X}]R$ 规则, 它要求我们对所有可能的类型参数元组 $\overline{V}$ 进行考量, 这可以转化为对潜在无限空间 $\overline{V}$ 进行搜索的过程,是典型的非构造性描述, 我们无法在计算机中实现它。

至此,我们的目标已经明确:我们需要一个真正可执行的算法,其结果与 (App-InfSpec) 一致, 但不需要我们进行非构造性的搜索和回溯。这正是「约束生成」这一步骤所要扮演的角色。 它的设计动机,源于对问题本身的一次精妙的视角转换。

6. Constraint Generation [blog/lti/cg]

与其将类型参数推断视为一个在无限可能性中「寻找并验证最优解」的搜索问题, 不妨将其重构为一个类似于解代数方程的「求解未知数边界」的问题。 我们不再去猜测类型参数 $\overline{X}$ 可能是什么, 而是通过分析子类型关系本身,去推导出 $\overline{X}$ 必须满足的条件。

观察到我们的规则存在子类型约束,不妨考虑一般情况 $S \lt: T$, 这本身就隐含着对其构成部分(包括其中的未知变量)的约束。 若 $X$ 是 $T$ 中的一个未知变量,那么 $S$ 的结构就必然会限制 $X$ 可能的形态。 我们的算法,正是要将这种隐含的、结构上的限制,转化为一组显式的、关于 $X$ 上下界的断言。

然而,在系统性地提取这些约束之前,我们必须首先面对一个与变量作用域相关的、 虽属前期准备但至关重要的挑战。若不妥善处理,我们生成的约束本身就可能是非良构的。 这一挑战,催生了算法的第一个具体操作步骤:变量消去。

6.1. Variable Elimination [blog/lti/var_elim]

设想我们想为变量 $X$ 生成约束,以使得类型 $\forall Y. () \to (Y\to Y)$ 成为 $\forall Y. () \to X$ 的一个子类型。根据函数子类型的逆变/协变规则,这要求 $Y \to Y \lt: X$。然而,我们不能直接生成约束 $\{ Y \to Y \lt: X \lt: \top\}$,因为类型变量 $Y$ 在此约束中是自由的,但它本应被 $\forall Y$ 所绑定,这就造成了一种作用域错误。

正确的做法是,找到 $Y \to Y$ 的一个不含 $Y$ 的、且尽可能精确的超类型,并用它来约束 $X$。在本例中,即 $\bot \to \top$。变量消去机制,正是为了形式化地完成这一「寻找最精确边界」的任务。

  1. 提升 (Promotion), $S \Uparrow^V T$:$T$ 是 $S$ 的一个不含集合 $V$ 中任何自由变量的最小超类型
  2. 下降 (Demotion), $S \Downarrow^V T$:$T$ 是 $S$ 的一个不含集合 $V$ 中任何自由变量的最大子类型

这两套关系由一组递归的定则所定义,确保了对于任何类型 $S$ 和变量集 $V$,其结果 $T$ 都是唯一且总能被计算出来的(即它们是全函数)。 此处建议读者停下来思考一下,如何设计这两套关系的递归规则并自己使用代码实现它(提示:对于提升规则的情况,若 $X\in V$ 则将其提升到 $\top$,反之不变,其他情况是显然的)

6.1.1. Promotion and Demotion Rules [blog/lti/ve_rules]

  • 提升规则 ($S \Uparrow^V T$)

    • 对于 $\top$ 和 $\bot$: $$ \top \Uparrow^V \top \quad (\text{VU-Top}) $$ $$ \bot \Uparrow^V \bot \quad (\text{VU-Bot}) $$
    • 对于类型变量 $X$:
      • 若 $X$ 属于需要被消除的集合 $V$,则将其提升至 $\top$。 $$ \frac{X \in V}{X \Uparrow^V \top} \quad (\text{VU-Var-1}) $$
      • 若 $X$ 不在 $V$ 中,则保持不变。 $$ \frac{X \notin V}{X \Uparrow^V X} \quad (\text{VU-Var-2}) $$
    • 对于函数类型:
      • 递归地对其参数类型进行下降(因为参数位置是逆变的),并对其返回类型进行提升(因为返回位置是协变的)。 $$ \frac{\overline{S} \Downarrow^V \overline{U} \quad T \Uparrow^V R \quad \overline{X} \notin V}{\forall\overline{X}.\overline{S} \to T \Uparrow^V \forall\overline{X}.\overline{U} \to R} \quad (\text{VU-Fun}) $$
  • 下降规则 ($S \Downarrow^V T$)

    • 对于 $\top$ 和 $\bot$ 的处理和提升规则一致。
    • 对于类型变量 $X$:
      • 若 $X$ 属于 $V$,则将其下降至 $\bot$。 $$ \frac{X \in V}{X \Downarrow^V \bot} \quad (\text{VD-Var-1}) $$
      • 若 $X$ 不在 $V$ 中,则保持不变。
    • 对于函数类型:
      • 递归地对其参数类型进行提升,并对其返回类型进行下降。 $$ \frac{\overline{S} \Uparrow^V \overline{U} \quad T \Downarrow^V R \quad \overline{X} \notin V}{\forall\overline{X}.\overline{S} \to T \Downarrow^V \forall\overline{X}.\overline{U} \to R} \quad (\text{VD-Fun}) $$

这可以非常直接地在 MoonBit 中实现:

pub fn promotion(self : Type, v : Set[Var]) -> Type {
  match self {
    TyFun(xs, ss, t) => {
      let vP = v.iter().filter(x => !xs.contains(x)) |> Set::from_iter
      let us = ss.map(_.demotion(vP))
      let r = t.promotion(vP)
      guard xs.iter().all(x => !v.contains(x))
      TyFun(xs, us, r)
    }
    TyVar(vr) if v.contains(vr) => TyTop
    _ => self
  }
}

pub fn demotion(self : Type, v : Set[Var]) -> Type {
  match self {
    TyFun(xs, ss, t) => {
      let vP = v.iter().filter(x => !xs.contains(x)) |> Set::from_iter
      let us = ss.map(_.demotion(vP))
      let r = t.demotion(vP)
      TyFun(xs, us, r)
    }
    TyVar(vr) if v.contains(vr) => TyBot
    _ => self
  }
}

这套精心设计的规则,保证了变量消去操作的正确性与最优性,这由两个关键引理所保证:

  • 可靠性(Soundness):若 $S \Uparrow^V T$,那么可以证明 $S \lt: T$ 且 $T$ 中确实不含 $V$ 中的自由变量。对偶地,若 $S \Downarrow^V T$,则 $T \lt: S$ 且 $T$ 不含 $V$ 中的自由变量。
  • 完备性(Completeness):此操作找到了「最好」的边界。例如,对于提升操作,若存在另一个不含 $V$ 中变量的 $S$ 的超类型 $T'$,那么 $S \Uparrow^V T$ 所计算出的 $T$ 必然是 $T'$ 的子类型(即 $T \lt: T'$),这证明了 $T$ 是所有可能选项中最小的那一个。

约束生成是局部类型参数合成算法的核心引擎。 在通过变量消去确保了作用域安全之后, 此步骤的使命是将一个子类型判定问题——例如, $S \lt: T$,其中 $S$ 或 $T$ 中含有待定的类型参数 $\overline{X}$ 转化为一组对这些未知参数 $\overline{X}$ 的显式约束。 现在我们形式化地定义代码中的约束具体是什么结构:

  • 约束 (Constraint):在本系统中,每一个约束都具有形式 $S_i \lt: X_i \lt: T_i$,它为单个未知类型变量 $X_i$ 同时指定了一个下界 (lower bound) $S_i$ 和一个上界 (upper bound) $T_i$ 。

  • 约束集 (Constraint Set):一个约束集 $C$ 是关于一组未知变量 $\overline{X}$ 到其对应约束的有限映射(在代码中可以实现为一个 Hash Map)。约束集的一个关键不变量是,其中任何约束的上下界($S_i, T_i$)都不能含有任何待定的未知变量(即 $\overline{X}$ 中的变量)或任何需要被消去的局部变量(即 $V$ 中的变量)。空约束集 ($\emptyset$) 代表最无限制的约束,相当于为每一个 $X_i$ 指定了约束 $\bot \lt: X_i \lt: \top$ 。

  • 约束集的交 ($\wedge$) 定义为两个约束集 $C$ 和 $D$ 的交集,是通过将其对同一个变量的约束进行合并得到的。新的下界是原下界的最小上界(join, $\vee$),而新的上界是原上界的最大下界(meet, $\wedge$)

6.2. Constraint and Constraint Set Code [blog/lti/cg_def_code]

struct Constraints(Map[Var, Subtype])

pub fn Constraints::empty() -> Constraints {
  Constraints(Map::new())
}

pub fn meet(self : Constraints, other : Constraints) -> Constraints {
  union_with(self.inner(), other.inner(), (l, r) => {
    let { l: l1, r: r1 } = l
    let { l: l2, r: r2 } = r
    { l: l1 & l2, r: r1 | r2 }
  })
}

pub fn meets(c : Array[Constraints]) -> Constraints {
  c.iter().fold(init=Constraints::empty(), Constraints::meet)
}

约束生成过程被形式化为一个推导关系 $V \vdash_{\overline{X}} S \lt: T \Rightarrow C$,其意为:在需要回避的变量集为 $V$ 的条件下,为使 $S \lt: T$ 成立,关于未知变量 $\overline{X}$ 需满足的(最弱)约束集是 $C$,这个 $C$ 可以被视为是该推导关系的输出。

算法由一组递归规则定义,其中关键规则如下(注:我们始终假定 $\overline{X} \cap V = \emptyset$):

  • 平凡情况 (Trivial Cases):当子类型关系的上界是 $\top$ 或下界是 $\bot$ 时,该关系无条件成立,因此生成一个空约束集 $\emptyset$ 。
  • 上界约束 (Upper Bound Constraint):当需要判定 $Y \lt: S$(其中 $Y \in \overline{X}$ 是未知变量,而 $S$ 是已知类型)时,算法会为 $Y$ 生成一个上界约束。 $$ \frac{Y \in \overline{X} \quad S \Downarrow^V T \quad \text{FV}(S) \cap \overline{X} = \emptyset}{V \vdash_{\overline{X}} Y \lt: S \Rightarrow \{ \bot \lt: Y \lt: T \}} \quad (\text{CG-Upper}) $$ 注意,这里利用了前述的变量消去操作($S \Downarrow^V T$)来确保上界 $T$ 本身是良构的。
  • 下界约束 (Lower Bound Constraint):对偶地,当需要判定 $S \lt: Y$ 时,算法为 $Y$ 生成一个下界约束。 $$ \frac{Y \in \overline{X} \quad S \Uparrow^V T \quad \text{FV}(S) \cap \overline{X} = \emptyset}{V \vdash_{\overline{X}} S \lt: Y \Rightarrow \{ T \lt: Y \lt: \top \}} \quad (\text{CG-Lower}) $$
  • 函数类型 (Function Type):当比较两个函数类型时,算法递归地处理其参数和返回类型,并将生成的子约束集合并。 $$ \frac{V \cup \{\overline{Y}\} \vdash_{\overline{X}} \overline{T} \lt: \overline{R} \Rightarrow \overline{C} \quad V \cup \{\overline{Y}\} \vdash_{\overline{X}} S \lt: U \Rightarrow D}{V \vdash_{\overline{X}} \forall \overline{Y}.\overline{R} \to S \lt: \forall \overline{Y}.\overline{T} \to U \Rightarrow (\bigwedge \overline{C}) \wedge D} \quad (\text{CG-Fun}) $$ 这里,通过对子约束集取交集 ($\wedge$),实现了约束的累积。

6.3. Constraint Generation Code [blog/lti/cg_code]

/// Pre-condition: xs and v are disjoint sets of variables.
pub fn generate(
  self : Subtype,
  xs : Array[Var],
  v : Set[Var],
) -> Constraints raise {
  match (self.l, self.r) {
    (_, TyTop) | (TyBot, _) => Constraints::empty()
    (TyVar(y1), TyVar(y2)) if y1 == y2 && !xs.contains(y1) =>
      Constraints::empty()
    (TyVar(y), s) if xs.contains(y) => {
      let t = s.demotion(v)
      Map::from_array([(y, { l: TyBot, r: t })])
    }
    (s, TyVar(y)) if xs.contains(y) => {
      let t = s.promotion(v)
      Map::from_array([(y, { l: t, r: TyTop })])
    }
    (TyFun(ys1, r, s), TyFun(ys2, t, u)) if ys1 == ys2 => {
      let vy = v.union(Set::from_array(ys1))
      let cs = t.zip(r).map(tr => { l: tr.0, r: tr.1 }.generate(xs, vy))
        |> meets()
      cs.meet({ l: s, r: u }.generate(xs, vy))
    }
    _ => raise ConstraintGenerationError
  }
}

一个至关重要的观察是,在任何一次调用 $V \vdash_{\overline{X}} S \lt: T \Rightarrow C$ 时,未知变量 $\overline{X}$ 都只会出现在 $S$ 和 $T$ 的其中一边。这使得整个过程是一个匹配模子类型(matching-modulo-subtyping)问题,而非一个完全的合一(unification)问题,从而保证了算法的简洁性与确定性。

7. Calculating Type Arguments [blog/lti/calc_args]

通过前述的约束生成步骤,我们已经成功地将一个非构造性的最优解搜索问题, 转化为了一个具体、有形的产物:一个约束集 $C$。 这个约束集,凝聚了为了使整个多态应用类型正确,所有待定类型参数 $\overline{X}$ 必须满足的全部边界条件。 对于每一个未知变量 $X_i$,我们都得到了一个形如 $S_i \lt: X_i \lt: T_i$ 的合法区间。

至此,算法的第一阶段「我们对未知数了解了什么?」已经圆满完成。 然而,我们的工作尚未结束。一个约束区间,例如 $\mathbb{Z} \lt: X \lt: \mathbb{R}$, 本身可能允许多个合法的解(如 $\mathbb{Z}$ 或 $\mathbb{R}$)。 我们最终必须为每一个 $X_i$ 挑选出一个具体的类型值,以完成对内核语言项的最终构造。

这就引出了算法的最后一个,也是画龙点睛的一步:我们应依据何种准则,从每个变量的合法区间中做出最终的选择? 答案,必须回归到我们的初衷,即 App-InfSpec 规约中所声明的最优性要求: 我们所做的选择,必须能使整个应用表达式获得唯一的、最小的结果类型。 因此,算法的最后一步,便是要设计一个选择策略, 它能利用我们已经辛勤收集到的约束集 $C$, 并结合函数原始的返回类型 $R$, 来计算出一组能最终最小化 $R$ 的具体类型参数。 这便是本节「参数计算」的核心任务。

7.1. Polarity [blog/lti/polarity]

为了计算关于 $R$ 的具体类型参数,还有一个至关重要的操作:变量代换导致的极性变化。 一个类型变量 $X$ 在另一个类型表达式 $R$ 中的极性,描述了当该变量的类型变大或变小时,整个表达式的类型会如何相应地变化。

  • 协变 (Covariant):若 $R$ 在 $X$ 上是协变的,则 $X$ 的「变大」(成为一个超类型)会导致 $R$ 也「变大」。例如,在 $T \to X$ 中,$X$ 是协变的。若 $\mathbb{Z} \lt: \mathbb{R}$,则 $T \to \mathbb{Z} \lt: T \to \mathbb{R}$。
  • 逆变 (Contravariant):若 $R$ 在 $X$ 上是逆变的,则 $X$ 的「变大」会导致 $R$ 「变小」(成为一个子类型)。例如,在 $X \to T$ 中,$X$ 是逆变的。若 $\mathbb{Z} \lt: \mathbb{R}$,则 $\mathbb{R} \to T \lt: \mathbb{Z} \to T$。
  • 不变 (Invariant):若 $R$ 在 $X$ 上是不变的,则只有当 $X$ 保持不变时,$R$ 才保持不变。任何改变都会导致不可比较的结果。例如,在 $X \to X$ 中,$X$ 是不变的。
  • 常量 (Constant):若 $R$ 在 $X$ 上是常量的,则 $X$ 的变化不会影响 $R$ 的类型。常量类型通常是指那些不包含变量的类型,如基本类型或具体的类。

现在我们对极性的考虑主要集中在函数类型之上, 只需要关注变量在函数类型中的位置即可(在箭头的左边还是右边), 当然,需要考虑到嵌套的函数结构。建议读者在此稍作停顿,考虑一下该算法的设计。 当然你也可以直接展开下面的代码块来查看具体实现。

7.1.1. Variance Code [blog/lti/variance_code]

pub fn variance(self : Type, va : Var) -> Variance {
  letrec go = (ty : Type, polarity : Bool) => match ty {
    TyVar(v) if v == va => if polarity { Covariant } else { Contravariant }
    TyVar(_) | TyTop | TyBot => Constant
    TyFun(_, params, ret) => {
      let param_variance = params
        .map(p => go(p, !polarity))
        .fold(init=Constant, combine_variance)
      combine_variance(param_variance, go(ret, polarity))
    }
  }
  and combine_variance = (v1 : Variance, v2 : Variance) => match (v1, v2) {
    (Constant, v) | (v, Constant) => v
    (Covariant, Covariant) | (Contravariant, Contravariant) => v1
    (Covariant, Contravariant) | (Contravariant, Covariant) => Invariant
    (Invariant, _) | (_, Invariant) => Invariant
  }

  go(self, true)
}

为了最小化返回类型 $R$,我们的选择策略变得显而易见:

  • 若 $X_i$ 在 $R$ 中是协变的,我们应为 $X_i$ 选择其合法区间内的最小值,即其约束的下界
  • 若 $X_i$ 在 $R$ 中是逆变的,我们应为 $X_i$ 选择其合法区间内的最大值,即其约束的上界
  • 若 $X_i$ 在 $R$ 中是不变的,则为了保证结果的唯一性与可比较性,其合法区间必须是一个「点」,即其约束的上下界必须相等

上述策略被形式化为一个计算最小代换(minimal substitution) $\sigma_{CR}$ 的算法。给定一个可满足的约束集 $C$ 和返回类型 $R$:

对于 $C$ 中的每一个约束 $S \lt: X_i \lt: T$:

  • 若 $R$ 在 $X_i$ 上是协变常数的,则 $\sigma_{CR}(X_i) = S$。
  • 若 $R$ 在 $X_i$ 上是逆变的,则 $\sigma_{CR}(X_i) = T$。
  • 若 $R$ 在 $X_i$ 上是不变的,且 $S=T$,则 $\sigma_{CR}(X_i) = S$。
  • 在其他所有情况下(尤其是不变变量的约束区间 $S \neq T$ 时),$\sigma_{CR}$ 未定义

当 $\sigma_{CR}$ 未定义时,算法宣告失败,这精确地对应了 App-InfSpec 中无法找到唯一最优解的情形。

7.2. Solving Constraints [blog/lti/solve_code]

pub fn solve(self : Constraints, ty : Type) -> Map[Var, Type]? {
  let f = (vs : (Var, Subtype)) => {
    let { l, r } = vs.1
    let v = match ty.variance(vs.0) {
      Constant | Covariant => Some(l)
      Contravariant => Some(r)
      Invariant => if l == r { Some(l) } else { None }
    }
    v.map(v => (vs.0, v))
  }
  let i = self.inner().iter().filter_map(f)
  guard i.count() == self.inner().size() else { None }
  Some(Map::from_iter(i))
}

7.3. Proof Sketch [blog/lti/proof_eq]

至此,我们已经完整地定义了一个由「变量消去」、「约束生成」和「参数计算」三步构成的、完全可执行的算法。但我们如何确保这个具体的算法,其行为与那个非构造性的、声明式的 App-InfSpec 规约完全一致?

其等价性的证明,浓缩于一个核心命题中,该命题断言:

  1. 若最小代换 $\sigma_{CR}$ 存在,那么它就是规约所要求的那个最优解。
  2. 若最小代换 $\sigma_{CR}$ 不存在,那么规约所要求的最优解也必然不存在。

证明概要:

  • 第一部分(算法的正确性): 为证明 $\sigma_{CR}$ 是最优的,我们任取另一个满足约束的合法代换 $\sigma'$,并需要证明 $\sigma_{CR}(R) \lt: \sigma'(R)$。 考虑构建一个从 $\sigma_{CR}$ 到 $\sigma'$ 的「代换链」,每一步只改变一个变量的值。例如,$\sigma_0 = \sigma_{CR}$,$\sigma_1 = \sigma_0[X_1 \mapsto \sigma'(X_1)]$,…,$\sigma_n = \sigma'$。 接着,我们证明在此路径的每一步中,结果类型都是单调不减的,即 $\sigma_{i-1}(R) \lt: \sigma_i(R)$。这一步的证明,直接依赖于前述的极性定义。例如,若 $R$ 在 $X_i$ 上是协变的,我们的算法选择了下界 $S$,而 $\sigma'(X_i)$ 必然大于等于 $S$,因此根据协变的定义,结果类型必然「变大」。同理可以证明其他情况下该论断也成立。 最终,通过传递性,我们得出 $\sigma_{CR}(R) \lt: \sigma'(R)$,证明了 $\sigma_{CR}$ 的最优性。

  • 第二部分(算法失败的完备性): 当算法失败时,必然是因为某个不变变量 $X_i$ 的约束区间 $[S, T]$ 中 $S \neq T$。 我们采用反证法:假设此时仍然存在一个最优解 $\sigma$。由于 $S \neq T$,我们总能找到另一个合法的代换 $\sigma'$,使得 $\sigma(X_i)$ 与 $\sigma'(X_i)$ 不同。但由于 $R$ 在 $X_i$ 上是不变的,$\sigma(R)$ 与 $\sigma'(R)$ 将变得不可比较,这与「$\sigma$ 是最优解(即比所有其他解都小)」的假设相矛盾。 因此,在这种情况下,最优解必然不存在。

这一核心命题证明了我们设计的这套具体算法与 App-InfSpec 规约之间的等价性。 它允许我们最终用一个完全算法化的规则 App-InfAlg 来取代那个不可执行的规约:

$$ \frac{ \begin{array}{ccc} \Gamma \vdash f : \forall \overline{X}.\overline{T} \to R & \Gamma \vdash \overline{e} : \overline{S} & |\overline{X}| > 0 \\ \emptyset \vdash_X \overline{S} \lt: \overline{T} \Rightarrow \overline{D} & C = \bigwedge \overline{D} & \sigma = \sigma_{CR} \end{array} }{ \Gamma \vdash f(\overline{e}) : \sigma R } \quad (\text{App-InfAlg}) $$

8. Bidirectional Checking [blog/lti/bidirectional]

至此,我们已经完整地剖析了本文的第一项重点:局部类型参数合成。它通过一套由「变量消去」、「约束生成」和「参数计算」构成的、逻辑严密且完全可执行的算法,完美地解决了由细粒度多命题所引发的类型参数标注繁琐的问题,达成了引言中所述三条设计准则中的第一条。

然而,我们的工具箱尚不完备。回顾引言中基于 ML 代码分析所提出的三条设计准则,仍有两项亟待解决:

  1. 高阶编程的便利性:如何推断匿名函数(如 fun[](x) x+1)中绑定变量 x 的类型?
  2. 纯函数式风格的支持:如何让大量的局部变量绑定(如 let x = ...)无需显式类型标注?

局部类型参数合成机制,其信息流本质上是自下而上(bottom-up)的,它根据函数和参数的既有类型,向上推导出一个最佳的结果类型。这套机制,对于上述两个问题鞭长莫及,因为在类似 fun[](x) x+1 这样的表达式中,没有任何子结构能为 x 的类型提供信息。

我们考虑一种在思想上与前者互补的、功能强大的局部推断技术。它不再仅仅依赖自下而上的信息综合,而是引入了一股自上而下(top-down)的信息流,让表达式所处的语境(context)来指导其内部的类型检查。这便是双向类型检查(Bidirectional Type Checking),其基本思想虽早已是编程语言社区的「民间共识」,并在一些基于属性文法的 ML 编译器中得以应用,本文将其在一个同时包含子类型化与非直谓多态的形式系统中进行严谨的公理化,并将其作为一种独立的局部推断方法,其威力出人意料地强大。

两种模式:综合与检查

  1. 综合模式 (Synthesis Mode, $\Rightarrow$)

    • 在此模式下,类型信息自下而上传播
    • 其目标是根据一个表达式的子表达式的类型,来计算(或「综合」)出该表达式自身的类型。
    • 这对应于传统的类型检查方式,适用于那些我们对其期望类型一无所知的上下文,例如在处理一个顶层表达式或一个应用节点的函数部分时。
  2. 检查模式 (Checking Mode, $\Leftarrow$)

    • 在此模式下,类型信息自上而下传播
    • 其目标是验证(或「检查」)一个表达式是否拥有一个由其所处语境提供的「期望类型」(或者该期望类型的一个子类型)。
    • 当一个表达式所处的语境已经决定了它的类型时,便可切换至此模式。

双向检查的精髓在于两种模式的灵活切换。一个典型的函数应用 f(e) 完美地展示了这一过程:类型检查器会先综合出函数 f 的类型,然后利用该类型提供的信息,切换模式去检查参数 e

8.1. Typing Rules [blog/lti/bidi_rules]

本节是我们类型检查程序的最后一块拼图, 描述了整个双向类型检查的过程。

变量规则 (Variable Rules)

1. 综合 - 变量 (Synthesis-Var)

$$ \frac{}{\Gamma \vdash x \Rightarrow \Gamma(x)} \quad (\text{S-Var}) $$

这是变量的综合规则。如果要在综合模式下推导一个变量 $x$ 的类型,只需在当前的类型上下文 $\Gamma$ 中查找 $x$ 已经声明的类型 $\Gamma(x)$ 即可。这是最直接的类型推导形式:变量的类型就是它被定义时的类型。


2. 检查 - 变量 (Checking-Var)

$$ \frac{\Gamma \vdash \Gamma(x) \lt: T}{\Gamma \vdash x \Leftarrow T} \quad (\text{C-Var}) $$

这是变量的检查规则。要检查变量 $x$ 是否符合预期的类型 $T$,我们需要先从上下文 $\Gamma$ 中查找到 $x$ 的实际类型 $\Gamma(x)$,然后验证这个实际类型 $\Gamma(x)$ 是否是预期类型 $T$ 的一个子类型 (subtype)。这里的子类型关系用 $\Gamma \vdash \Gamma(x) \lt: T$ 表示。


函数抽象规则 (Function Abstraction Rules)

3. 综合 - 抽象 (Synthesis-Abs)

$$ \frac{\Gamma, \overline{X}, \overline{x}:\overline{S} \vdash e \Rightarrow T}{\Gamma \vdash \textbf{fun} [\overline{X}] (\overline{x}:\overline{S}) e \Rightarrow \forall \overline{X}. \overline{S} \rightarrow T} \quad (\text{S-Abs}) $$

这是针对带有完整类型标注的函数抽象综合规则。

  • 一个形式为 fun [X] (x:S) e 的函数,其类型参数 $\overline{X}$ 和值参数 $\overline{x}$ 的类型 $\overline{S}$ 都被明确标注了。
  • 规则的前提是,在将类型变量 $\overline{X}$ 和值变量及其类型 $\overline{x}:\overline{S}$ 加入到当前上下文 $\Gamma$ 后,我们可以综合出函数体 $e$ 的类型为 $T$。
  • 那么,整个函数表达式的类型就可以被综合为多态函数类型 $\forall \overline{X}. \overline{S} \rightarrow T$。

4. 检查 - 无标注抽象 (Checking-Abs-Inf)

$$ \frac{\Gamma, \overline{X}, \overline{x}:\overline{S} \vdash e \Leftarrow T}{\Gamma \vdash \textbf{fun} [\overline{X}] (\overline{x}) e \Leftarrow \forall \overline{X}. \overline{S} \rightarrow T} \quad (\text{C-Abs-Inf}) $$

这是本节的核心规则之一,用于推断无标注匿名函数的参数类型

  • 当我们在检查模式下处理一个没有值参数类型标注的函数 fun [X] (x) e 时,我们可以从预期的函数类型 $\forall \overline{X}. \overline{S} \rightarrow T$ 中获取信息。
  • 具体来说,我们可以从预期的类型中提取出参数类型 $\overline{S}$ 和返回类型 $T$。
  • 然后,我们将类型变量 $\overline{X}$ 和推断出的值参数类型 $\overline{x}:\overline{S}$ 加入上下文,并在这个新上下文中以检查模式验证函数体 $e$ 是否符合返回类型 $T$。如果满足,则整个无标注函数就通过了类型检查。
  • 注意:$\overline{X}$ 在这个系统下是无法省略的。

5. 检查 - 有标注抽象 (Checking-Abs)

$$ \frac{\Gamma, \overline{X} \vdash \overline{T} \lt: \overline{S} \quad \Gamma, \overline{X}, \overline{x}:\overline{S} \vdash e \Leftarrow R}{\Gamma \vdash \textbf{fun} [\overline{X}] (\overline{x}:\overline{S}) e \Leftarrow \forall \overline{X}. \overline{T} \rightarrow R} \quad (\text{C-Abs}) $$

这是在检查模式下处理一个带有类型标注的函数的规则。

  • 当一个有标注的函数 fun [X] (x:S) e 需要被检查是否符合某个预期类型 $\forall \overline{X}. \overline{T} \rightarrow R$ 时:
  • 首先,由于函数类型的参数类型是逆变 (contravariant) 的,我们需要检查预期类型中的参数类型 $\overline{T}$ 是否是函数实际标注的参数类型 $\overline{S}$ 的子类型。
  • 随后我们在扩展后的上下文中,以检查模式验证函数体 $e$ 是否符合预期的返回类型 $R$。

函数应用规则 (Function Application Rules)

6. 综合 - 应用 (Synthesis-App)

$$ \frac{\Gamma \vdash f \Rightarrow \forall \overline{X}. \overline{S} \rightarrow R \quad \Gamma \vdash \overline{e} \Leftarrow [\overline{T}/\overline{X}]\overline{S}}{\Gamma \vdash f [\overline{T}] (\overline{e}) \Rightarrow [\overline{T}/\overline{X}]R} \quad (\text{S-App}) $$

这是函数应用的综合规则。

  • 首先,在综合模式下推断出函数 $f$ 的类型为多态函数 $\forall \overline{X}. \overline{S} \rightarrow R$。
  • 然后,我们将提供的具体类型参数 $\overline{T}$ 替换掉类型变量 $\overline{X}$,得到参数的预期类型 $[\overline{T}/\overline{X}]\overline{S}$。
  • 接下来,我们切换到检查模式,验证实际参数 $\overline{e}$ 是否符合这个预期类型。
  • 如果检查通过,整个应用表达式的类型就被综合为将 $\overline{T}$ 代入后的返回类型 $[\overline{T}/\overline{X}]R$。

7. 检查 - 应用 (Checking-App)

$$ \frac{\Gamma \vdash f \Rightarrow \forall \overline{X}. \overline{S} \rightarrow R \quad \Gamma \vdash [\overline{T}/\overline{X}]R \lt: U \quad \Gamma \vdash \overline{e} \Leftarrow [\overline{T}/\overline{X}]\overline{S}}{\Gamma \vdash f [\overline{T}] (\overline{e}) \Leftarrow U} \quad (\text{C-App}) $$

这是函数应用的检查规则。它与综合规则非常相似,但增加了一个最终的子类型检查。

  • 前两个步骤和 S-App 一样:综合 $f$ 的类型,并检查参数 $\overline{e}$。
  • 在计算出应用的实际返回类型 $[\overline{T}/\overline{X}]R$ 后,我们还必须检查这个实际返回类型是否是整个应用表达式的预期类型 $U$ 的子类型。

结合双向检查和类型参数综合的规则

8. 综合 - 应用 - 推断规格 (Synthesis-App-InfAlg)

$$ \frac{ \begin{array}{ccc} \Gamma \vdash f : \forall \overline{X}.\overline{T} \to R & \Gamma \vdash \overline{e} : \overline{S} & |\overline{X}| > 0 \\ \emptyset \vdash_X \overline{S} \lt: \overline{T} \Rightarrow \overline{D} & C = \bigwedge \overline{D} & \sigma = \sigma_{CR} \end{array} }{ \Gamma \vdash f(\overline{e}) \Rightarrow \sigma R } \quad (\text{S-App-InfAlg}) $$

这是在综合模式下推断函数应用中缺失的类型参数的规则,这正是上文「类型参数计算」一节导出的规则。


9. 检查 - 应用 - 推断规格 (Checking-App-InfAlg)

$$ \frac{\begin{matrix} \Gamma \vdash f \Rightarrow \forall \overline{X}. \overline{T} \rightarrow R \quad \Gamma \vdash \overline{e} \Rightarrow \overline{S} \quad |\overline{X}| \gt 0 \\ \emptyset \vdash \overline{S} \lt: \overline{T} \Rightarrow C \quad \emptyset \vdash R \lt: V \Rightarrow D \quad \sigma \in \bigwedge C \wedge D \end{matrix}}{\Gamma \vdash f(\overline{e}) \Leftarrow V} \quad (\text{C-App-InfAlg}) $$

这是 App-InfAlg 规则的检查版本。

  • 它描述了编译器如何通过约束求解来找到合适的类型参数。
  • 步骤如下:
    1. 综合函数 $f$ 和参数 $\overline{e}$ 的类型。
    2. 生成两组约束:
      • 第一组约束 $C$ 来自于要求参数的实际类型 $\overline{S}$ 必须是函数期望的参数类型 $\overline{T}$ 的子类型。
      • 第二组约束 $D$ 来自于要求函数的实际返回类型 $R$ 必须是整个表达式的预期类型 $V$ 的子类型。
    3. 求解这两组约束的合取($\bigwedge C \wedge D$)。如果能找到一个解(一个替换 $\sigma$),那么类型检查就成功了。 因为这是一条检查规则,因而我们只需要知道解的存在性即可,甚至不需要求解它。

顶类型和底类型

10. 检查 - 顶类型 (Checking-Top)

$$ \frac{\Gamma \vdash e \Rightarrow T}{\Gamma \vdash e \Leftarrow \top} \quad (\text{C-Top}) $$

这是一个从检查模式切换到综合模式的规则。如果一个表达式 $e$ 被要求检查是否符合顶类型 $\top$,由于任何类型都是 $\top$ 的子类型,这个检查总是会成功。$\top$ 类型没有提供任何有用的约束信息,所以规则允许我们直接切换到综合模式,推导出 $e$ 的具体类型 $T$ 即可。


11. 综合 - 应用 - 底类型 (Synthesis-App-Bot)

$$ \frac{\Gamma \vdash f \Rightarrow \bot \quad \Gamma \vdash \overline{e} \Rightarrow \overline{S}}{\Gamma \vdash f [\overline{T}] (\overline{e}) \Rightarrow \bot} \quad (\text{S-App-Bot}) $$

这是处理函数类型为 $\bot$ (底类型) 时的特殊情况。$\bot$ 类型代表那些永不返回的表达式(如抛出异常)。

  • 如果一个函数 $f$ 的类型被推导为 $\bot$,那么无论它被应用于何种参数,整个应用表达式的结果类型也是 $\bot$。
  • 这是因为 $\bot$ 是所有类型的子类型,包括所有函数类型。因此,一个类型为 $\bot$ 的表达式可以被当作任何函数来应用。

12. 检查 - 应用 - 底类型 (Checking-App-Bot)

$$ \frac{\Gamma \vdash f \Rightarrow \bot \quad \Gamma \vdash \overline{e} \Rightarrow \overline{S}}{\Gamma \vdash f [\overline{T}] (\overline{e}) \Leftarrow R} \quad (\text{C-App-Bot}) $$

这是在检查模式下,函数类型为 $\bot$ 的情况。

  • 同样,如果函数 $f$ 的类型为 $\bot$,那么应用的结果类型也是 $\bot$。
  • 因为 $\bot$ 是任何类型 $R$ 的子类型,所以这个检查总是成功的。

最后的实现即是落到 synthesischeck 两个关键函数上, 这是本文最重要的一个练习, 强烈建议读者自己尝试实现这两个函数,来体会双向检查的精妙之处, 并从中学习将类型规则转化为代码的技巧。

8.2. Bidirectional Typing Code [blog/lti/bidi_code]

pub fn synthesis(self : Expr, gamma : Context) -> Type raise {
  match self {
    EVar(v) => gamma[v]
    EAbs(xs, vs, body) => {
      let ctx = gamma.append_vars(xs).append_binds(vs)
      let body_ty = body.synthesis(ctx)
      TyFun(xs, vs.map(xS => xS.1), body_ty)
    }
    EAppI(f, e) => {
      let t = f.synthesis(gamma)
      guard !(t is TyBot) else { TyBot }
      guard t is TyFun(xs, t, r) else { raise SynthesisError }
      // guard !xs.is_empty() else { raise NotFound }
      let es = e.map(_.synthesis(gamma))
      let cs = es.zip(t).map(z => { l: z.0, r: z.1 }.generate(xs, Set::new()))
      let sigma = meets(cs).solve(r)
      sigma.map(s => r.apply_subst(s)).or_error(SynthesisError)
    }
    EApp(f, ty, e) => {
      let t = f.synthesis(gamma)
      guard !(t is TyBot) else { TyBot }
      guard t is TyFun(xs, ss, r) else { raise SynthesisError }
      let subst = mk_subst(xs, ty)
      e.zip(ss).each(es => es.0.check(es.1.apply_subst(subst), gamma))
      r.apply_subst(subst)
    }
    _ => raise SynthesisError
  }
}

pub fn check(self : Expr, ty : Type, gamma : Context) -> Unit raise {
  match (self, ty) {
    (_, TyTop) => ()
    (EVar(v), t) => {
      let gx = gamma[v]
      guard gx.subtype(t) else { raise CheckError }
    }
    (EAbs(xs, vs, body), TyFun(xsP, t, r)) if xs == xsP => {
      let ctx = gamma.append_vars(xs)
      guard t.zip(vs.map(xS => xS.1)).iter().all(z => z.0.subtype(z.1)) else {
        raise CheckError
      }
      body.check(r, ctx.append_binds(vs))
    }
    (EAbsI(xs, vs, body), TyFun(xsP, ss, t)) if xs == xsP &&
      ss.length() == vs.length() =>
      body.check(t, gamma.append_vars(xs).append_binds(vs.zip(ss)))
    (EAppI(f, e), u) => {
      let t = f.synthesis(gamma)
      guard !(t is TyBot && u is TyBot) else { () }
      guard f.synthesis(gamma) is TyFun(xs, t, r) else { raise CheckError }
      guard !xs.is_empty() else { () }
      let es = e.map(_.synthesis(gamma))
      let cs = es.zip(t).map(z => { l: z.0, r: z.1 }.generate(xs, Set::new()))
      let d = { l: r, r: u }.generate(xs, Set::new())
      guard meets(cs).meet(d).satisfiable() else { raise CheckError }
    }
    (EApp(f, ty, e), u) => {
      let t = f.synthesis(gamma)
      guard !(t is TyBot && u is TyBot) else { () }
      guard t is TyFun(xs, ss, r)
      let subst = mk_subst(xs, ty)
      e.zip(ss).each(es => es.0.check(es.1.apply_subst(subst), gamma))
      guard r.apply_subst(subst).subtype(u) else { raise CheckError }
    }
    _ => raise CheckError
  }
}

最后一个关键目标是局部变量绑定的设计, 这要求我们引入新的语法结构 ELet,但其规则是显然的:

$$ \frac{\Gamma \vdash e \Rightarrow S \quad \Gamma, x:S \vdash b \Rightarrow T}{\Gamma \vdash \textbf{let } x = e \textbf{ in } b \Rightarrow T} \quad (\text{S-Let}) $$

$$ \frac{\Gamma \vdash e \Rightarrow S \quad \Gamma, x:S \vdash b \Leftarrow T}{\Gamma \vdash \textbf{let } x = e \textbf{ in } b \Leftarrow T} \quad (\text{C-Let}) $$

实现留作习题。

9. Conclusion and Future [blog/lti/conclusion]

至此,我们循着 Pierce 与 Turner 的足迹,完整地剖析了一套精巧而强大的局部类型推断机制。 通过将局部类型参数合成双向类型检查这两项核心技术相结合, 我们最终完成了一个真正强大的类型检查器的设计。 它不仅能在理论上驾驭如 System $F_{\le}$ 这样兼具子类型化与非直谓多态的强大类型系统, 更在实践中,精准地解决了由多态、 高阶编程与纯函数式风格所引发的最为普遍和恼人的类型标注问题。 这套完全基于「局部性」原则的算法,其行为可预测、 易于实现且错误提示友好,堪称是连接艰深理论与日常编程实践的一座典范之桥。

当然,本文所探讨的,仅是这篇经典论文的核心部分。原文的疆域更为广阔, 其中最重要的扩展,便是对有界量化(Bounded Quantification)的支持。 有界量化,即形如 $\forall (X \lt: T_2, Y \lt: T_2, \cdots) . e$ 的多态, 它允许类型参数本身受到其超类型的约束。这一特性对于精确建模面向对象语言中的继承等高级特性至关重要,是通往更强大表达能力的必由之路。 原文第五章详细阐述了如何将本文介绍的局部推断技术扩展至支持有界量化的系统。该扩展使得算法,尤其是约束的生成与求解,将变得更为精妙和复杂, 特别是在处理 $\bot$ 类型与类型边界的相互作用时, 引入了新的挑战。限于篇幅,对这一部分的深入剖析,或许只能留待未来的文章再行探讨。 此外,本文的另一大遗憾是「形式证明」的缺失。我们关注的重点,在于阐明算法的设计动机与直觉, 并以一种接近实际代码实现的方式来呈现其逻辑。为此,我们省略了诸多严谨的数学证明细节, 例如约束生成算法的可靠性与完备性证明,仅以提要的形式一笔带过。

最后,值得一提的是,本文对类型系统代数性质的倚重——例如,确保任意类型间的最小上界(join)与最大下界(meet)总是存在——揭示了一条深刻的设计原则:一个具有良好代数结构的类型系统,往往更能催生出简洁而强大的类型推断算法。自这篇论文发表以来,寻求更优美的子类型化代数理论的探索从未停止。后续的研究,如 Stephen Dolan 等人提出的「代数子类型」(Algebraic Subtyping), 正是沿着这一思想路径,对子类型关系的形式化进行了更为深刻的抽象与简化, 在笔者看来,它是真正改变子类型研究的天才之作。 当然,对这些更前沿工作的介绍,已然超出了对这篇 2000 年经典论文本身进行解读的范畴, 我们把它留给未来的文章。