h5i
本中文版由作者借助 AI 翻译而成。作者并非中文母语者,译文未经人工逐句校对,可能存在错误。如发现问题,请在 GitHub 上提交 issue。

第 12 章

归约与转换

第 11 章处理的是名字,本章处理的是计算:三种归约步骤、定义相等的定义,以及算法 5.1 留下未定的三个算法 Whnf、Normalize 和 IsDefEq。本章的核心设计要点是:归纳类型的计算规则以数据的形式存放在签名中,所以一个归约函数就能服务于所有曾经声明过的归纳类型。

归约

把 λ\lambda 抽象应用到参数上,其计算方式是把参数替换进约束变量:

(λ(x:A). t)  s  →β  t[x↦s].(\tLam{x}{A}{t})\;s \;\betared\; \subst{t}{x}{s}.

这一步是 β 归约(beta-reduction),形如 (λ(x:A). t)  s(\tLam{x}{A}{t})\;s 的子项是 β 可归约式(beta-redex)。第二部分又加入了两种步骤:δ 归约(delta-reduction),展开一个定义(第 7 章);ι 归约(iota-reduction),在构造子上运行递归子(图 7.1、图 9.1,以及第 8 章的通用规则)。每种步骤都允许在项内部的任何位置进行,我们用 →\to 表示任意一种的单步归约。没有任何步骤可以应用的项处于范式(normal form)。例如,令 Id=λ(X:Sort 0). X\mathit{Id} = \tLam{X}{\Sort{0}}{X},则

λ(h:Id  P). (λ(y:P). y)  h  →β  λ(h:P). (λ(y:P). y)  h  →β  λ(h:P). h,\tLam{h}{\mathit{Id}\;P}{(\tLam{y}{P}{y})\;h} \;\betared\; \tLam{h}{P}{(\tLam{y}{P}{y})\;h} \;\betared\; \tLam{h}{P}{h},

最后一个项处于范式,而先归约内层的可归约式也会得到同样的结果。

转换

定义相等(definitional equality),又称转换(conversion),把计算结果相同的项视为同一个,差别只允许出现在约束变量的名字上。

定义 12.1(转换)。 s≡ts \conv t 是包含 =α\alphaeq 和 →\to、且在所有项构造器下封闭的最小等价关系。

要判定 s≡ts \conv t,内核把两个项都规范化,然后用 =α\alphaeq 比较结果。这一做法依赖两个事实。

  • 归约是合流的(confluent):不同的归约顺序不会得到不同的范式。所以 s≡ts \conv t 成立,恰当 ss 和 tt 的范式 α 等价;不需要去寻找某个巧妙的步骤序列。
  • 每个良类型的项都有范式,所以规范化会终止。这对我们的系统成立,但它是一个深刻的定理(强规范化,第 14 章)[Barendregt 1992]。

实用的内核还会限制归约步数,以免某个 bug 让它无限循环;我们的实现在 20,000 步后停下,并报错“reduction fuel exhausted”。

作为数据的计算规则

图 7.1 和图 9.1 把每条 ι 规则写成单独的等式,这似乎暗示每个新的归纳类型都需要在内核里添加新的归约代码。其实不然:内核把 ι 规则作为数据存放在签名中,紧挨着递归子,由一段代码统一地为所有归纳类型应用它们。

每个递归子条目记录了它的大前提(major premise)的位置,也就是由其构造子决定适用哪条规则的那个参数,以及它所属归纳类型的各个构造子。对每个构造子,内核知道开头有多少个参数是参数(parameter,规则忽略它们),有多少个是字段(field,规则把它们传下去),以及哪些字段是递归的。对我们的三个递归子,这些数据及其产生的右侧如下;λ\lambda 上的类型标注已省略。

RecursorMajorConstructorParams, fieldsRight-hand sideNat.recu4thzero0,  0λM z s.  zsucc0,  1λM z s n.  s  n  (Nat.recu  M  z  s  n)Eq.recu,v6threflu2,  0λA a M m b.  mTree.recv5thleaf0,  0λM m1 m2 m3.  m1node0,  1λM m1 m2 m3 t.  m2  t  (Tree.recv  M m1 m2 m3  t)branch0,  2λM m1 m2 m3 l r.  m3  l  r  (Tree.recv  M m1 m2 m3  l)  (⋯  r)\begin{array}{llcll} \textbf{Recursor} & \textbf{Major} & \textbf{Constructor} & \textbf{Params, fields} & \textbf{Right-hand side} \\[2pt] \NatRec{u} & \text{4th} & \zero & 0,\;0 & \lambda M\,z\,s.\; z \\[2pt] & & \suc & 0,\;1 & \lambda M\,z\,s\,n.\; s\;n\;(\NatRec{u}\;M\;z\;s\;n) \\[2pt] \EqRecDep{u,v} & \text{6th} & \refl{u} & 2,\;0 & \lambda A\,a\,M\,m\,b.\; m \\[2pt] \mathsf{Tree.rec}_v & \text{5th} & \mathsf{leaf} & 0,\;0 & \lambda M\,m_1\,m_2\,m_3.\; m_1 \\[2pt] & & \mathsf{node} & 0,\;1 & \lambda M\,m_1\,m_2\,m_3\,t.\; m_2\;t\;(\mathsf{Tree.rec}_v\;M\,m_1\,m_2\,m_3\;t) \\[2pt] & & \mathsf{branch} & 0,\;2 & \lambda M\,m_1\,m_2\,m_3\,l\,r.\; m_3\;l\;r\;(\mathsf{Tree.rec}_v\;M\,m_1\,m_2\,m_3\;l)\;(\cdots\;r) \end{array}

右侧先接收大前提之前的那些参数,再接收构造子的字段。例如,Nat.recu  M  z  s  (succ  n)\NatRec{u}\;M\;z\;s\;(\suc\;n) 选中第二条规则,变成

(λM z s n.  s  n  (Nat.recu  M  z  s  n))  M  z  s  n,(\lambda M\,z\,s\,n.\; s\;n\;(\NatRec{u}\;M\;z\;s\;n))\;M\;z\;s\;n,

它经过 β 归约后就是图 7.1 第二个等式的右侧。类似地,Eq.recu,v\EqRecDep{u,v} 唯一的规则在 Eq.recu,v  A  a  M  m  b  (reflu  A′  c)\EqRecDep{u,v}\;A\;a\;M\;m\;b\;(\refl{u}\;A'\;c) 上触发。构造子 reflu\refl{u} 有两个参数 A′A' 和 cc,没有字段,所以右侧只接收 A  a  M  m  bA\;a\;M\;m\;b,并返回 mm。

弱头范式与规范化

算法 12.1 定义了算法 5.1 留下未定的三个函数。Whnf\Whnf 和 Normalize\Normalize 都把项看成一条脊(spine)h  a1⋯anh\;a_1 \cdots a_n:一个不是应用的头部 hh,施加于 n≥0n \ge 0 个参数(第 2 章),而每个项恰有一种这样的分解。

算法 12.1。带 β、δ、ι 的弱头规范化;完全规范化;以及定义相等。

Whnf(t):
  loop:
    write t as a spine h a_1 ... a_n
    if h = λ(x : A). b and n ≥ 1:                               -- beta
        t ← (b[x ↦ a_1]) a_2 ... a_n
    else if h = c and (c : C := d) ∈ Σ:                         -- delta
        t ← d a_1 ... a_n
    else if h = c is a recursor with major position p ≤ n:      -- iota
        k b_1 ... b_j ← Whnf(a_p)
        if k is a constructor of c's inductive type with q parameters:
            t ← rhs_k a_1 ... a_{p-1} b_{q+1} ... b_j a_{p+1} ... a_n
        else return t                                           -- stuck, e.g. on a variable
    else return t

Normalize(t):
  t ← Whnf(t)
  if t = Π(x : A). B:  return Π(x : Normalize(A)). Normalize(B)
  if t = λ(x : A). b:  return λ(x : Normalize(A)). Normalize(b)
  else write t as a spine h a_1 ... a_n and return h Normalize(a_1) ... Normalize(a_n)

IsDefEq(s, t):
  return  · ; · ⊢ Normalize(s) =α Normalize(t)                  -- Figure 11.1

Whnf\Whnf 只在项的头部归约,直到那里没有规则可用为止;结果就是弱头范式(weak head normal form)。它从不深入 λ\lambda、Π\Pi 的内部,也不深入卡住的头部的参数。当调用者只需要类型的最外层形状时(例如 Infer\Infer 的应用情形需要一个 Π\Pi),这就足够了,而且比完全规范化便宜。Normalize\Normalize 先计算弱头范式,然后规范化每一个直接子项。例如,令 t=λ(h:Id  P). ht = \tLam{h}{\mathit{Id}\;P}{h},Id\mathit{Id} 同上,则

Whnf(t)=t,Normalize(t)=λ(h:P). h,\Whnf(t) = t, \qquad \Normalize(t) = \tLam{h}{P}{h},

因为 tt 的头部是一个不带参数的 λ\lambda,而 Normalize\Normalize 会继续进入标注 Id  P\mathit{Id}\;P。类似地,Whnf(Id  (Id  P→P))=Id  P→P\Whnf(\mathit{Id}\;(\mathit{Id}\;P \to P)) = \mathit{Id}\;P \to P,它已经显露出一个 Π\Pi,尽管其定义域还不是范式。下面可以同时看到这两点:头部没有任何步骤可用,范式却不同。

Whnf\Whnf 的三种情形有如下性质。

  • ι 情形完全不提及 Nat\Nat、Eq\Eq{} 或 Tree\mathsf{Tree},而是从签名中读取大前提的位置和构造子数据。这段代码只写一次;此后每个新的归纳类型增加的是数据行,而不是代码。
  • 大前提在查询规则之前先被化为弱头范式,因为它可能要经过计算才会变成构造子。对第 7 章的 2+12 + 1,大前提是数字 1=succ  zero1 = \suc\;\zero,已经是构造子;对 double  (1+1)\mathit{double}\;(1 + 1),大前提是 1+11 + 1,Whnf\Whnf 先把它变成 succ  (1+0)\suc\;(1 + 0)。
  • 如果大前提没有变成构造子,比如它是一个变量,那么这个项就是卡住的(stuck),保持原样;这就是 0+n0 + n 不归约的原因(第 9 章)。

下面是 Whnf\Whnf 处理 double  (1+1)\mathit{double}\;(1 + 1) 的实时演算,double\mathit{double} 的定义同第 7 章。归约只发生在头部,但归约大前提会迫使一次内层计算。

def add : Nat -> Nat -> Nat := fun (m : Nat) => fun (n : Nat) => Nat.rec1 (fun (_n : Nat) => Nat) m (fun (k : Nat) => fun (r : Nat) => Nat.succ r) n
def double : Nat -> Nat := fun (n : Nat) => Nat.rec1 (fun (_n : Nat) => Nat) Nat.zero (fun (k : Nat) => fun (r : Nat) => Nat.succ (Nat.succ r)) n

标为 whnf 的那一步就是内层计算:递归子需要它的大前提是构造子,所以 1+11 + 1 先在头部被归约。最终的弱头范式里仍含有一个卡住的内层递归子;它下方的范式则是 Normalize\Normalize 继续向内归约得到的结果。

为简化实现,本书的 IsDefEq\IsDefEq 会将两边完全规范化,而实用的内核则采用惰性比较:先把两边化为弱头范式并比较头部,只在必要时才检查参数,从而避免对头部已经不同的大项做完整规范化。强规范化保证这三个函数在良类型项上都会终止。

代码中

normalize_with_fuel 中有一处细节值得一提。在规范化应用的函数部分和参数部分之后,它会对重建后的项再调用一次 whnf,因为规范化参数可能在头部暴露出新的可归约式:对于一个卡住的递归子应用,如果它的大前提要等自身的参数规范化之后才变成构造子,那么头部必须再归约一次。少了这次复查,is_def_eq 会漏掉某些相等关系;src/proof.rs 中的测试 conversion_renormalizes_exposed_redex 守护着这一点。

练习

给出 (λ(f:Nat→Nat). f)  (λ(x:Nat). x)  zero(\tLam{f}{\Nat \to \Nat}{f})\;(\tLam{x}{\Nat}{x})\;\zero 的弱头范式和范式。

解答

这条脊的头部是 λ(f:Nat→Nat). f\tLam{f}{\Nat \to \Nat}{f},带两个参数。对第一个参数做 β 归约得到 (λ(x:Nat). x)  zero(\tLam{x}{\Nat}{x})\;\zero,它的头部又是一个 β 可归约式,归约为 zero\zero。所以弱头范式和范式都是 zero\zero。(这正是测试 conversion_renormalizes_exposed_redex 中的项。)

为什么 Whnf\Whnf 不在 λ\lambda 之下归约?如果 Whnf\Whnf 返回的 λ(x:A). b\tLam{x}{A}{b} 中 bb 已经规范化,Infer\Infer 会出什么问题?

解答

不会出任何错误:结果仍然可转换为输入,但这是浪费工作。Infer\Infer 调用 Whnf\Whnf 只是为了知道最外层的形状,而头部一旦是 λ\lambda,形状就已经可见。向内归约可能代价任意大,比如在一个从不与任何东西比较的标注里。让 Whnf\Whnf 保持浅层,才能让大项上的类型推断保持廉价。

要点回顾

  • 三种归约步骤:β(应用 λ\lambda)、δ(展开定义)、ι(在构造子上运行递归子)。转换是它们与 α 等价一起生成的等价关系。
  • 转换的判定方法是把两边规范化,再在 α 等价意义下比较;其正确性来自合流性,其终止性来自强规范化。
  • ι 规则是数据:大前提的位置加上构造子的元数据。一个 whnf 服务于所有归纳类型。
  • Whnf 只在头部归约,是 Infer 所需要的;Normalize 深入每个角落,是 IsDefEq 所使用的。步数预算防范 bug。