[] 规则与算法:可靠性与完备性

1 规则不是程序

[定义] 类型与类型判断 定义的是一个关系:哪些 ( 𝑡,𝑇 ) 有推导。它没有告诉我们怎么找推导。真正写出来的类型检查器是一个函数:

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Type { Bool, Nat }

/// 类型检查算法:返回 Some(T) 表示“t 的类型是 T”,None 表示拒绝。
pub fn type_of(t: &Term) -> Option<Type> {
    use Term::*;
    match t {
        True | False => Some(Type::Bool),                  // T-True / T-False
        Zero => Some(Type::Nat),                           // T-Zero
        Succ(t1) | Pred(t1) => {                           // T-Succ / T-Pred
            (type_of(t1)? == Type::Nat).then_some(Type::Nat)
        }
        IsZero(t1) => {                                    // T-IsZero
            (type_of(t1)? == Type::Nat).then_some(Type::Bool)
        }
        If(c, a, b) => {                                   // T-If
            let ta = type_of(a)?;
            (type_of(c)? == Type::Bool && type_of(b)? == ta).then_some(ta)
        }
    }
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Type { Bool, Nat }

/// 类型检查算法:返回 Some(T) 表示“t 的类型是 T”,None 表示拒绝。
pub fn type_of(t: &Term) -> Option<Type> {
    use Term::*;
    match t {
        True | False => Some(Type::Bool),                  // T-True / T-False
        Zero => Some(Type::Nat),                           // T-Zero
        Succ(t1) | Pred(t1) => {                           // T-Succ / T-Pred
            (type_of(t1)? == Type::Nat).then_some(Type::Nat)
        }
        IsZero(t1) => {                                    // T-IsZero
            (type_of(t1)? == Type::Nat).then_some(Type::Bool)
        }
        If(c, a, b) => {                                   // T-If
            let ta = type_of(a)?;
            (type_of(c)? == Type::Bool && type_of(b)? == ta).then_some(ta)
        }
    }
}

x.then_some(y) x.then_some(y) 的意思是“条件 x x 成立就返回 Some(y) Some(y) ,否则返回 None None ”。

这个函数和规则之间隔着一道缝。写代码的人会觉得“显然一样”,可这正是 [例] 不完整的规格 之后一直在提防的那个词。比如有人把 If If 分支写成只检查 a a 、不检查 b b ,大多数手写测试照样能过。要把“一样”说清楚,得拆成两个方向。

1.1 [定义] 可靠与完备 [soundness-and-completeness]

以 [定义] 类型与类型判断 的关系 𝑡:𝑇 为规格,以 Rust 类型检查算法 type_of type_of 为实现。 Some(T) Some(T) 表示算法报告类型 𝑇, None None 表示拒绝;Rust 的 Bool Bool 、 Nat Nat 分别对应对象语言的 Bool、Nat。

  • 算法相对规则可靠(sound):若 type_of(t) = Some(T) type_of(t) = Some(T) ,则 𝑡:𝑇。
  • 算法相对规则完备(complete):若 𝑡:𝑇,则 type_of(t) = Some(T) type_of(t) = Some(T) 。

可靠说算法不乱接受,完备说算法不乱拒绝。两者合起来,算法恰好判定了这个关系。

1.2 [附注] 两个“可靠” [type-soundness-and-algorithm-soundness]

“可靠”这个词在 PLT 里有两种常见用法,容易混:

两条串起来才是我们真正关心的:检查器接受的程序,运行时不会受阻。只有一条,链条就是断的;两条的组合见 [推论] 检查器接受的程序不会受阻 。

2 证明

先确认一个隐含前提: type_of type_of 对每个输入都会返回,不会无限递归。它是结构递归(每次递归调用的参数都是 [定义] 直接子项与真子项 中的直接子项),由 [定义] 大小与深度 后面那段讨论,这样的函数总会终止。

首先证明可靠性,因为类型安全定理说的是“规则能推出类型的项”,不是“某个函数返回 Some Some 的项”。若检查器在 If If 分支里忽略 else 分支,就可能接受 succ ( if  false  then 0 else  true );程序下一步变成受阻的 succ  true。类型规则本身没有错,错的是算法冒称规则已接受它。下面的定理就是防止这种“假阳性”。

2.1 [定理] 算法可靠性 [type-checking-algorithm-soundness]

取 Rust 类型检查算法 type_of type_of ,类型关系采用 [定义] 类型与类型判断 。Rust 的 Bool Bool 、 Nat Nat 分别对应 Bool、Nat。对任意项 𝑡 和类型 𝑇:若 type_of(t) = Some(T) type_of(t) = Some(T) ,则 𝑡:𝑇。

证明

对 𝑡 结构归纳( [约定] 归纳证明的写法 )。 type_of type_of 本身就是按项的形状分支的,所以每个情形对应一个分支。

  • 常量: True True 、 False False 分支返回 Bool Bool , Zero Zero 分支返回 Nat Nat ,分别是 T-True、T-False、T-Zero 的结论。
  • succ 𝑡 1:返回 Some(Nat) Some(Nat) 只有一种可能, type_of(t1) = Some(Nat) type_of(t1) = Some(Nat) 。由 归纳假设 𝑡 1: Nat,用 T-Succ 得 succ 𝑡 1: Nat。pred、iszero 同理,分别用 T-Pred、T-IsZero。
  • if 𝑡 1 then 𝑡 2 else 𝑡 3:返回 Some(ta) Some(ta) 说明 type_of(c) = Some(Bool) type_of(c) = Some(Bool) 、 type_of(a) = Some(ta) type_of(a) = Some(ta) 、 type_of(b) = Some(ta) type_of(b) = Some(ta) 。三次使用 归纳假设 分别给出 𝑡 1: Bool、𝑡 2:𝑇、𝑡 3:𝑇(𝑇 即 ta ta ),正是 T-If 的三个前提。
∎

可靠性还不够描述“忠实实现规则”:一个对所有输入都返回 None None 的检查器也是可靠的,却连 0 都不接受。完备性排除这种“假阴性”,保证规则认可的程序不会被实现漏掉。例如把 T-Pred 对应的代码分支误写成直接返回 None None ,不会放进坏程序,却会冤枉 pred 0 这样的合法程序;下面这条定理能发现它。

2.2 [定理] 算法完备性 [type-checking-algorithm-completeness]

取 Rust 类型检查算法 type_of type_of ,类型关系采用 [定义] 类型与类型判断 。Rust 的 Bool Bool 、 Nat Nat 分别对应 Bool、Nat。对任意项 𝑡 和类型 𝑇:若 𝑡:𝑇,则 type_of(t) = Some(T) type_of(t) = Some(T) 。

证明

对 𝑡:𝑇 的推导归纳,按最后一步的规则分情形。

  • T-True、T-False、T-Zero:直接计算 type_of type_of 即得。
  • T-Succ:前提 𝑡 1: Nat。由 归纳假设 type_of(t1) = Some(Nat) type_of(t1) = Some(Nat) ,于是 Succ Succ 分支返回 Some(Nat) Some(Nat) 。T-Pred、T-IsZero 同理。
  • T-If:三个前提由 归纳假设 给出 type_of type_of 在三个子项上分别返回 Bool Bool 、 T T 、 T T 。代入 If If 分支: ta = T ta = T ,两个比较都成立,返回 Some(T) Some(T) 。
∎

两个证明各自只有几行,但方向不同、归纳的对象也不同:可靠性跟着算法走(对项归纳,因为算法按项递归),完备性跟着推导走(对推导归纳,因为前提给的是推导)。

最后要把两个不同的保证接起来:算法符合类型规则,类型规则又与运行规则协调。少了第一段,错误检查器可能乱接受;少了第二段,类型系统可能认可运行时受阻的项。这个端到端结论才是使用者真正需要的“检查通过以后能相信什么”,也说明只证明某个局部函数正确不足以替整条链作保证。

2.3 [推论] 检查器接受的程序不会受阻 [accepted-programs-do-not-get-stuck]

取 Rust 类型检查算法 type_of type_of ,以及 [定义] 多步归约 的关系 ⟶ ∗。若 type_of(t) = Some(T) type_of(t) = Some(T) 且 𝑡⟶ ∗𝑡 ′,则 𝑡 ′ 不会处于 [定义] 范式与受阻 定义的受阻状态。

证明
由 [定理] 算法可靠性 得 𝑡:𝑇,再由 [推论] 类型安全 即得。
∎

这一条就是 [附注] 两个“可靠” 说的那条完整的链。完备性没有出现在这里:因为完备性保证的是检查器“不冤枉好程序”,而非“安全”。

3 为什么这里这么顺利

这一节的证明顺利,靠的是 [引理] 反演 依赖的那个性质:规则是语法导向的,而且每条规则前提里出现的类型,都能从子项算出来。T-If 里的 𝑇 由 𝑡 2 算出,然后拿来比较 𝑡 3,没有哪一步需要凭空猜一个类型。

3.1 [附注] 不是所有规则都能直接照抄成算法 [turning-rules-into-algorithms]

如果某条规则的前提里出现了一个结论里看不到、子项也算不出的类型,照着规则写函数就走不通,函数不知道该填什么。函数类型的规则就是典型例子:给 𝜆𝑥.𝑡(记号见 [附注] 求值策略、归约策略与合流性 )定型时,参数 𝑥 的类型在项里找不到。那时规则仍然定义了一个清清楚楚的关系,但从关系到算法的那一步,需要额外的设计和额外的证明。 [定义] 类型与类型判断 的布尔值/自然数语言足够小,避开了这个问题;但区分“规则”和“算法”、并分别证明可靠与完备的习惯,在更大的语言里同样适用。

3.2 [注记] 语法与语义,证明与真 [syntax-semantics-proof-and-truth]

逻辑学里也有一对“可靠 / 完备”:一个证明系统是可靠的,如果能证明的都是真的;是完备的,如果真的都能证明。哥德尔 1929 年证明一阶逻辑的证明系统是完备的;两年后的不完备定理则说,足够强的算术理论里,总有真而不可证的命题。

[定义] 可靠与完备 描述的类型检查算法与类型规则之间的关系,与此平行: type_of type_of 扮演“机械的证明过程”,类型规则扮演“什么才算对”的标准。差别在于,这里的标准本身也是一组规则,而且足够简单,两个方向分别由 [定理] 算法可靠性 与 [定理] 算法完备性 证明。语言再大一些,“完备”就常常要附加条件,甚至干脆不成立,设计者必须决定放弃哪一边。

4 练习:规则与算法
本节练习(4题)

练习6.1:可靠与完备守的是不同方向

三个终止的检查器分别这样实现:A 总返回 None None ;B 总返回 Some(Nat) Some(Nat) ;C 只接受 true、false、0 并返回正确类型,其余返回 None None 。判断每个检查器相对原类型规则是否可靠、完备;凡不成立的方向,都给出反例。
提示按照 [定义] 可靠与完备 对具体的返回类型作判断,不要把“返回了某个类型”混同于“返回了规则给出的类型”。
参考答案

对所有输入返回 None None 的 A 是可靠的:可靠性的前提从不成立;但它不完备,因为 0: Nat 却被拒绝。

对所有输入返回 Some(Nat) Some(Nat) 的 B 既不可靠也不完备。它为 true 报告 Nat,规则只能给出 Bool,所以不可靠;true : Bool 成立却没有返回 Some(Bool) Some(Bool) ,所以也不完备。它还会接受根本没有类型的 succ  true。

C 只接受三个常量并返回它们正确的类型,其余返回 None None 。它可靠,但不完备:pred 0: Nat 就是被漏掉的合法项。可靠性允许少接受,完备性不允许漏掉规则认可的判断。

练习6.2:漏掉条件种类的检查

若 type_of type_of 的 If If 分支写成下面这样,其余分支不变,找出一个错误接受的项,指出漏了 T-If 的哪个前提,并修复代码。这个修改是否也必然破坏完备性?

If(c, a, b) => {
    let ta = type_of(a)?;
    type_of(c)?;
    (type_of(b)? == ta).then_some(ta)
}
If(c, a, b) => {
    let ta = type_of(a)?;
    type_of(c)?;
    (type_of(b)? == ta).then_some(ta)
}
提示让两个分支都是 0,却把条件也写成 0;再区分放进坏项与漏掉好项。
参考答案

错误检查器会给 if 0 then 0 else 0 返回 Some(Nat) Some(Nat) :两个分支相同,条件也能得到某个类型,所以三个调用都成功。但 T-If 要求条件为 Bool,0 只能为 Nat,因此这个项没有类型,算法可靠性失效;运行时它也受阻。

修复是恢复条件种类的比较:

If(c, a, b) => {
    let ta = type_of(a)?;
    (type_of(c)? == Type::Bool && type_of(b)? == ta).then_some(ta)
}
If(c, a, b) => {
    let ta = type_of(a)?;
    (type_of(c)? == Type::Bool && type_of(b)? == ta).then_some(ta)
}

这处遗漏本身不会破坏完备性:对原规则认可的项,条件确实为 Bool Bool ,修改前后的检查都会通过;对子项按推导归纳,仍得到正确的输出类型。但“好项仍被接受”不抵消“坏项也被接受”。

练习6.3:检查顺序不同,会改变程序的运行吗?

本文 type_of type_of 在 If If 分支先检查 then 分支。能否改成先检查条件,同时保持接受的项与输出类型不变?给出代码并论证。这样的改动会不会让对象语言先运行 then 分支?
提示本文的 type_of type_of 没有副作用,总会终止,失败只返回 None None 。它的调用顺序不是对象语言的求值顺序。
参考答案

可以在保持全部条件检查的前提下,先检查条件,再检查两个分支。例如:

If(c, a, b) => {
    if type_of(c)? != Type::Bool { return None; }
    let ta = type_of(a)?;
    (type_of(b)? == ta).then_some(ta)
}
If(c, a, b) => {
    if type_of(c)? != Type::Bool { return None; }
    let ta = type_of(a)?;
    (type_of(b)? == ta).then_some(ta)
}

原版与此版恰好都要求:条件为 Bool Bool 、两个分支都有类型且相等。每次调用都终止且没有副作用,所以最终 Some(T) Some(T) 或 None None 相同。条件不合适时,此版只是更早返回 None None 。

这不会改变 step step 的顺序:检查器在元语言中分析整个项,求值器则按对象语言的 E-规则运行。若检查器改成返回详细错误,检查顺序可能改变首先报告哪个错误;那是诊断接口的额外行为,不是本文 Option<Type> Option<Type> 结果的区别。

练习6.4:实现一个带期望类型的检查接口

写出 check(t, expected) -> bool check(t, expected) -> bool ,使它当且仅当原规则能推出 𝑡:𝑇 时返回真,其中 expected expected 对应 𝑇。证明两个方向及终止性,给出成功、期望类型不符和项本身不良类型三个例子。
提示不必新增类型规则;比较 type_of type_of 的结果与 Some(expected) Some(expected) ,然后分别使用算法可靠与完备。
参考答案
pub fn check(t: &Term, expected: Type) -> bool {
    type_of(t) == Some(expected)
}
pub fn check(t: &Term, expected: Type) -> bool {
    type_of(t) == Some(expected)
}

若 check(t, T) check(t, T) 为真, type_of(t) type_of(t) 等于 Some(T) Some(T) ,由 [定理] 算法可靠性 得 𝑡:𝑇。若 𝑡:𝑇,由 [定理] 算法完备性 得 type_of(t) = Some(T) type_of(t) = Some(T) ,比较为真。两方向合起来就是 check(t, T) = true check(t, T) = true 当且仅当 𝑡:𝑇。∎

type_of type_of 结构递归并终止,最后一次类型比较也终止,所以 check check 终止。 check(true, Bool) check(true, Bool) 为真, check(true, Nat) check(true, Nat) 为假, check(pred 0, Nat) check(pred 0, Nat) 为真;没有类型的 succ  true 对两个期望类型都返回假。这只是原算法的接口包装,没有增加一种对象语言的判断或求值策略。

References

[] 类型安全:典范形式、进展与保型 [type-safety]

[定义] 大小与深度 [size-and-depth]

[定义] 直接子项与真子项 [immediate-and-proper-subterms]

[引理] 反演 [typing-inversion]

[例] 不完整的规格 [incomplete-specifications]

[] 用测试对照证明 [test-and-proof]

[定义] 类型与类型判断 [types-and-typing-judgments]

[] 形式化入门:从 BNF 到类型安全 [index]

Backlinks

Based on Typsite