[] 确定性:从推导归纳到求值器

⟶ 是“对规则封闭的最小关系”,所以它也有自己的归纳法,和 [定理] 结构归纳原理(structural induction) 的道理完全相同:“最小”保证每个成立的 𝑡⟶𝑡 ′ 都有一棵由规则搭出来的推导,没有别的来路,所以只要性质能沿着每条规则从前提传到结论,它就对所有推导成立。

1 [定理] 对推导归纳 [induction-on-derivations]

设 𝑃( 𝑡,𝑡 ′ ) 是关于一对项的性质。如果对 [定义] 一步归约 的每一条规则都有:“前提里的每个 𝑡 1⟶𝑡 1 ′ 都满足 𝑃( 𝑡 1,𝑡 1 ′ )”能推出“结论满足 𝑃”,那么所有满足 𝑡⟶𝑡 ′ 的 ( 𝑡,𝑡 ′ ) 都满足 𝑃。

证明
令 𝑅={ ( 𝑡,𝑡 ′ )|𝑡⟶𝑡 ′ 且 𝑃( 𝑡,𝑡 ′ ) }。假设恰好说明 𝑅 对十条规则封闭。⟶ 是对规则封闭的最小关系,所以 ⟶⊆𝑅。
∎

直观地说(请对照 [例] 一棵推导 的推导树来理解),就是对推导树的高度做归纳,从上往下:叶子(公理)先成立,每往下一层都保持成立。公理没有前提,对应的情形里没有 [定义] 归纳假设 可用;E-If 这类有一个前提的规则,可以对前提对应的子推导使用这个假设。

为什么不总是对项归纳?因为这里已知的是一棵 𝑡⟶𝑡 ′ 的推导,要跟踪的性质同时涉及左右两项;按最后用的规则拆解,就能得到前提里的子推导以及左右两边怎样拼成结论。确定性和保型都会用到它。若只试了几条归约链,或只处理无前提的计算规则而漏掉 E-If 等同余规则,就不能覆盖嵌套项;例如内层归约保持类型,并不替你证明外层 succ 拼回去以后也保持类型。对推导归纳正是把这个“拼回去”的步骤逐条核实。

在证明确定性之前,要先确认“已经算完的值”确实不能再走一步。这既给求值器一个可靠的停止条件,也用来排除计算规则和同余规则同时适用。若另加 0⟶0 这样的规则,0 虽仍是语法上指定的值,按“不能再走”判断停止的求值器却会永远循环;后面的确定性证明也不能再用“值不能归约”排除重叠。

2 [引理] 值不可归约 [values-do-not-reduce]

采用 [定义] 值与数值 的值定义与 [定义] 一步归约 的十条求值规则。若 𝑣 是值,则不存在 𝑡 ′ 使 𝑣⟶𝑡 ′。

证明

对 [定义] 值与数值 的值做结构归纳。

  • true、false、0:逐条检查十条规则的结论左边,没有一条是这三个常量之一。
  • :结论左边形如 succ … 的规则只有 E-Succ,它的前提要求 。 是更小的值,由 归纳假设 不可能。
∎

为什么要证明下面的确定性?因为我们准备实现一个一次只返回一个后继项的 step step 。它要忠实于整个一步关系,就必须证明关系不会给同一输入两个不同后继;否则 match match 的分支顺序可能偷偷选掉另一条合法路线。 [附注] 为什么 E-PredSucc 要求 会给出放宽一条规则就失去这一保证的具体反例;多路线不必然是语言设计错误,但必须说明是否用策略限制它( [附注] 求值策略、归约策略与合流性 )。

3 [定理] 确定性 [determinism]

对 [定义] 一步归约 定义的关系 ⟶,若 𝑡⟶𝑡 ′ 且 𝑡⟶𝑡 ″,则 𝑡 ′=𝑡 ″。

证明

证明的方法是穷举(case analysis,也叫分情形讨论):把所有可能的情形一个不漏地列出来,逐个证明。穷举法成立的前提是情形确实覆盖了每一种可能,漏掉一种,整个证明就不成立。这里能保证不漏,是因为 ⟶ 是最小关系,任何推导的最后一步只能是十条规则之一,所以下面恰好列十种情形;而在每种情形内部,又要对第二个推导的最后一步再穷举一次十条规则,逐条说明“适用”或“为什么不适用”。

具体地,对 𝑡⟶𝑡 ′ 的推导归纳( [定理] 对推导归纳 ),要证的性质是“对任意 𝑡 ″,𝑡⟶𝑡 ″ 蕴涵 𝑡 ′=𝑡 ″”。“任意 𝑡 ″”必须写进性质里,否则 归纳假设 只能用于某个固定的 𝑡 ″,在 E-If 情形就不够用。每个情形里,再看 𝑡⟶𝑡 ″ 的推导最后一步可能是哪条规则:它的结论左边必须和 𝑡 形状相同。

  • E-IfTrue:𝑡= if  true  then 𝑡 2 else 𝑡 3,𝑡 ′=𝑡 2。第二个推导若是 E-IfTrue,𝑡 ″=𝑡 2。不可能是 E-IfFalse,条件不是 false。不可能是 E-If,那需要 true ⟶…,与 [引理] 值不可归约 矛盾。
  • E-IfFalse:与上一条对称。
  • E-If:前提 𝑡 1⟶𝑡 1 ′。第二个推导不可能是 E-IfTrue 或 E-IfFalse:那时 𝑡 1 是 true 或 false,而 𝑡 1 能归约,与 [引理] 值不可归约 矛盾。所以它是 E-If,前提 𝑡 1⟶𝑡 1 ″。由 归纳假设 𝑡 1 ′=𝑡 1 ″,于是 𝑡 ′=𝑡 ″。
  • E-Succ:左边形如 succ … 的规则只有 E-Succ,由 归纳假设 即得。
  • E-PredZero:𝑡= pred 0。E-PredSucc 要求 pred 后面是 succ …,不适用;E-Pred 要求 0 能归约,与 [引理] 值不可归约 矛盾。所以只能是 E-PredZero,𝑡 ″=0=𝑡 ′。
  • E-PredSucc:。E-PredZero 不适用;E-Pred 要求 能归约,但它是值,矛盾。所以只能是 E-PredSucc,结果相同。
  • E-Pred:前提 𝑡 1⟶𝑡 1 ′,所以 𝑡 1 不是值( [引理] 值不可归约 ),E-PredZero 和 E-PredSucc 都不适用(它们要求 𝑡 1 是 0 或 ,都是值)。剩下 E-Pred,用 归纳假设 。
  • E-IsZeroZero、E-IsZeroSucc、E-IsZero:与 pred 的三条一一对应,论证相同。
∎

这个证明没什么巧思,功夫全花在排除情形上。这正是形式化的用处,下面的附注是一个具体例子。

4 [附注] 为什么 E-PredSucc 要求 [numeric-value-restriction-in-predecessor-reduction]

[定义] 一步归约 的 E-PredSucc 规则左边写的是 ,只允许 succ 里面是 [定义] 值与数值 中的数值。看起来也可以放宽成任意项:

毕竟“先加一再减一”就是原来的数,何必等里面算完?问题在于,放宽以后同一个项会有两种归约方式。取 𝑡= pred ( succ ( pred 0 ) ):

  • 用放宽后的规则,直接把外层的 pred ( succ … ) 消掉:𝑡⟶ pred 0;
  • 用 E-Pred 和 E-Succ 两条同余规则往里找,在最里面用 E-PredZero:𝑡⟶ pred ( succ 0 )。

两个结果不一样, [定理] 确定性 就不再成立。在证明里,这表现为 E-PredSucc 那个情形写不下去:要排除第二个推导是 E-Pred,需要“succ 𝑡 不能归约”,而 𝑡 不一定是值,这一点推不出来。

这个例子里两条路最后都会到达 0,所以这个项的最终结果没有变,但一步关系已不再确定。仅凭这个例子还不能断言整个扩展语言的所有最终结果都不变,那需要另外的证明。我们仍可以写一个按某种策略选后继的函数,但它实现的是受策略限制后的关系,不能再声称它枚举了原关系的全部一步结果(见 [附注] 求值策略、归约策略与合流性 )。这个决定是被证明逼出来的:证明写不下去时,要么调整设计,要么调整所要保证的性质,不能把缺口当作已经证明。 用测试对照证明 所述的确定性测试也抓到了放宽后的反例。

5 从关系到函数

[定理] 确定性 说的是:对每个 𝑡,至多有一个 𝑡 ′。因此 ⟶ 对应一个偏函数(partial function):有后继时返回唯一后继,没有后继时无定义。Rust 用 Option<Term> Option<Term> 把这种偏函数表示成总会返回的函数, None None 表示后继不存在。若一步关系有多个后继,函数仍然可以选择其中一个,但必须写明选择的策略;否则它只实现了关系的一部分,却被误说成实现了整个关系。

5.1 [附注] 求值策略、归约策略与合流性 [evaluation-strategies-reduction-strategies-and-confluence]

归约策略(reduction strategy)规定:当一个项的多个位置都能改写时,选择哪个位置、哪条规则作为下一步。求值策略(evaluation strategy)则规定一门语言怎样求得程序的结果,包括先算哪些子表达式、函数参数何时计算、是否计算函数体内部,以及算到什么形式就停止。文献有时混用这两个词;这里用前者强调“选归约路线”,用后者强调运行时的整体约定。能直接应用一条计算规则的子表达式,称为可归约式(redex,reducible expression)。

先看一个只含精确整数运算、没有副作用的例子。括号固定语法树;允许在任意子表达式里计算一次加、减、乘或整除时,下面的项至少有两条路线:

如果规定“先完整求出左操作数,再求右操作数,最后计算根”,求值器就选蓝色实线路线,右边虚线路线不属于这个受限制的一步关系。策略只限制何时用计算规则,不改它算出的数值。运算符优先级解决的是怎么解析字符串,不是这里的先后顺序:语法树已经由括号固定,两条路线没有重新分组,也没有把浮点加法当成可任意结合的运算。

真实语言里,JavaScript、Python 的普通函数调用会先求实参再执行函数体,通常称为按值调用(call-by-value);JavaScript 还规定实参从左到右求值。Haskell 使用按需调用(call-by-need):需要某个参数的值时才算,并共享这次计算的结果。例如 Haskell 的 const const 满足 const x y = x const x y = x , error error 则用于产生错误; const 1 (error "boom") const 1 (error "boom") 返回 1 1 ,因为第二个参数不会被用到;JavaScript 的 ((x, y) => x)(1, (() => { throw new Error("boom"); })()) ((x, y) => x)(1, (() => { throw new Error("boom"); })()) 会先计算第二个实参而抛错。两门语言都可以有明确的策略,但选的是不同路线和停止条件。

有副作用时,连最后的普通数值也可能不同。设 JavaScript 中 let n = 0 let n = 0 , f = () => ++n f = () => ++n , g = () => n * 10 g = () => n * 10 。 f() + g() f() + g() 按从左到右求值给出 11 11 ;若另一门语言规定先算右边,先得到 g() = 0 g() = 0 ,再得到 f() = 1 f() = 1 ,结果就是 1 1 。所以“随便选一条路线”不是无害的实现细节; [例] 不完整的规格 里没写明的顺序必须在规格里补上。

多条路线能否重新汇合,是另一个性质,叫合流性(confluence)。这里临时把一般归约关系也记成 ⟶,把零步或有限多步记成 ⟶ ∗(正式定义见 [定义] 多步归约 ):若 𝑡⟶ ∗𝑢 且 𝑡⟶ ∗𝑣,总存在 𝑤,使 𝑢⟶ ∗𝑤 且 𝑣⟶ ∗𝑤,就称这个关系合流。上图展示了一个可汇合的分叉;只画出这一个例子并没有证明整套关系合流。

合流不要求“下一步唯一”:𝑢、𝑣 可以不同,只要求它们之后还可以到同一项。若某个项能到达两个范式(不能继续归约的项,正式定义见 [定义] 范式与受阻 ),合流保证这两个范式相同,因为范式已经无法再走向第三个不同的项。若没有合流保证,就不能从“各条路最后都停了”推出结果唯一:假想规则同时允许某个 𝑡 归约到常量 0 和 1,而两个常量都不能再归约,它们就永远无法汇合。选择策略可以挑出其中一个,却不消除原关系里的这种分歧。但合流不保证终止,也不保证任意策略都能找到已经存在的范式。

λ 演算(Lambda Calculus)是只用变量、函数和函数应用描述计算的形式系统。𝜆𝑥.𝑀 表示参数为 𝑥、函数体为 𝑀 的函数;𝑀 𝑁 表示把 𝑀 应用到 𝑁。𝜆 读作 lambda(“兰姆达”)。变量出现的位置若受某个 𝜆 的参数绑定,称为绑定出现;否则称为自由出现。例如 𝜆𝑥.𝑦 中参数 𝑥 只规定绑定范围,函数体里的 𝑦 是自由变量。一般的 β-归约(beta reduction,𝛽 读作 beta)允许在任何子项中使用

( 𝜆𝑥.𝑀 ) 𝑁⟶𝑀[ 𝑁/𝑥 ]

其中 𝑀[ 𝑁/𝑥 ] 是把 𝑀 中自由出现的 𝑥 替换成 𝑁,必要时先把绑定变量改名,以免把 𝑁 的自由变量误捕获。β-归约也允许在函数体内部归约,不要求 𝑁 已是值,所以通常有多条路线。Church–Rosser 定理说,一般 β-归约是合流的(把只差绑定变量名字的项视为同一个项)。这里引用这一经典结果而不展开其证明;它说明一般 β-归约虽然有多条路线,仍不会从同一个项得到两个不同的 β-范式。参见 Church–Rosser 定理。

例如 ( 𝜆𝑥.𝑥 ) ( ( 𝜆𝑦.𝑦 ) 𝑧 ) 可以先归约外层,得到 ( 𝜆𝑦.𝑦 ) 𝑧,也可以先归约实参,得到 ( 𝜆𝑥.𝑥 ) 𝑧;两边都再归约到 𝑧。但令 Ω=( 𝜆𝑥.𝑥 𝑥 ) ( 𝜆𝑥.𝑥 𝑥 )(Ω 读作 omega,“欧米伽”),它一步归约回自己,永远不会算完。( 𝜆𝑥.𝑧 ) Ω 若先算外层就得到范式 𝑧,若坚持先算实参就会在 Ω 上无限归约。一般 β-归约仍然合流,却不能靠合流保证这条策略终止。

因而要分清三件事:确定性管每一步是否唯一,合流性管不同路线能否重新汇合,终止性(termination)管是否可能一直算下去。引入确定的归约策略可以把多路线关系限制成可由单后继函数实现的关系,但它不自动保持所有可达结果;要声称“最终结果不变”或“总能找到范式”,还需要相应的证明。 [定义] 一步归约 的十条求值规则已经规定了策略, [定理] 确定性 证明的正是这套规则的确定性。

先把前面的 Term Term 定义重新贴在这里,方便对照:

#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub enum Term {
    True, False,
    If(Box<Term>, Box<Term>, Box<Term>),  // if t1 then t2 else t3
    Zero,
    Succ(Box<Term>), Pred(Box<Term>), IsZero(Box<Term>),
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub enum Term {
    True, False,
    If(Box<Term>, Box<Term>, Box<Term>),  // if t1 then t2 else t3
    Zero,
    Succ(Box<Term>), Pred(Box<Term>), IsZero(Box<Term>),
}

Term Term 没有派生 Copy Copy 。 Copy Copy 的意思是“按位复制一份就是合法的副本”,而 Box Box 独占堆上的子树,按位复制会得到两个指向同一块内存、都以为自己负责释放它的指针,所以 Rust 不允许含 Box Box 的类型实现 Copy Copy 。下面代码里的 (**a).clone() (**a).clone() 、 a.clone() a.clone() 就是在显式地深拷贝子树: a a 的类型是 &Box<Term> &Box<Term> , *a *a 是 Box<Term> Box<Term> , **a **a 是 Term Term 。

/// nv ::= 0 | succ nv
pub fn is_nv(t: &Term) -> bool {
    match t { Term::Zero => true, Term::Succ(t) => is_nv(t), _ => false }
}

/// v ::= true | false | nv
pub fn is_value(t: &Term) -> bool {
    matches!(t, Term::True | Term::False) || is_nv(t)
}

/// t ⟶ t'。返回 None 表示不存在 t':t 是值,或者受阻了。
pub fn step(t: &Term) -> Option<Term> {
    use Term::*;
    match t {
        If(c, a, b) => match &**c {
            True => Some((**a).clone()),                              // E-IfTrue
            False => Some((**b).clone()),                             // E-IfFalse
            c => Some(If(Box::new(step(c)?), a.clone(), b.clone())),  // E-If
        },
        Succ(t1) => Some(Succ(Box::new(step(t1)?))),                  // E-Succ
        Pred(t1) => match &**t1 {
            Zero => Some(Zero),                                       // E-PredZero
            Succ(n) if is_nv(n) => Some((**n).clone()),               // E-PredSucc
            t1 => Some(Pred(Box::new(step(t1)?))),                    // E-Pred
        },
        IsZero(t1) => match &**t1 {
            Zero => Some(True),                                       // E-IsZeroZero
            Succ(n) if is_nv(n) => Some(False),                       // E-IsZeroSucc
            t1 => Some(IsZero(Box::new(step(t1)?))),                  // E-IsZero
        },
        True | False | Zero => None,                                  // 值不可归约
    }
}
/// nv ::= 0 | succ nv
pub fn is_nv(t: &Term) -> bool {
    match t { Term::Zero => true, Term::Succ(t) => is_nv(t), _ => false }
}

/// v ::= true | false | nv
pub fn is_value(t: &Term) -> bool {
    matches!(t, Term::True | Term::False) || is_nv(t)
}

/// t ⟶ t'。返回 None 表示不存在 t':t 是值,或者受阻了。
pub fn step(t: &Term) -> Option<Term> {
    use Term::*;
    match t {
        If(c, a, b) => match &**c {
            True => Some((**a).clone()),                              // E-IfTrue
            False => Some((**b).clone()),                             // E-IfFalse
            c => Some(If(Box::new(step(c)?), a.clone(), b.clone())),  // E-If
        },
        Succ(t1) => Some(Succ(Box::new(step(t1)?))),                  // E-Succ
        Pred(t1) => match &**t1 {
            Zero => Some(Zero),                                       // E-PredZero
            Succ(n) if is_nv(n) => Some((**n).clone()),               // E-PredSucc
            t1 => Some(Pred(Box::new(step(t1)?))),                    // E-Pred
        },
        IsZero(t1) => match &**t1 {
            Zero => Some(True),                                       // E-IsZeroZero
            Succ(n) if is_nv(n) => Some(False),                       // E-IsZeroSucc
            t1 => Some(IsZero(Box::new(step(t1)?))),                  // E-IsZero
        },
        True | False | Zero => None,                                  // 值不可归约
    }
}

每个分支旁边标了它实现的规则。同余规则对应的分支都用到了 ? ? ,它的作用见下面的附注。

5.2 [附注] Rust 的 ? ? 运算符 [rust-question-mark-operator]

Option<T> Option<T> 是 Rust 里表示“可能有值、可能没有”的类型,只有两种取值: Some(x) Some(x) 和 None None 。在返回 Option Option 的函数里,表达式 e? e? 的意思是:

  • 如果 e e 是 Some(x) Some(x) ,整个 e? e? 的值就是 x x ,继续往下执行;
  • 如果 e e 是 None None ,函数立刻返回 None None ,后面的代码不再执行。

在 Rust 求值器 step step 中, Succ(t1) Succ(t1) 匹配一个后继项, step(t1) step(t1) 返回 Option<Term> Option<Term> ,表示子项是否存在一步后继。这个分支可以写成两种等价形式:

// 用 ?
Succ(t1) => Some(Succ(Box::new(step(t1)?))),

// 不用 ?,展开写
Succ(t1) => match step(t1) {
    Some(t1_) => Some(Succ(Box::new(t1_))),
    None => return None,
},
// 用 ?
Succ(t1) => Some(Succ(Box::new(step(t1)?))),

// 不用 ?,展开写
Succ(t1) => match step(t1) {
    Some(t1_) => Some(Succ(Box::new(t1_))),
    None => return None,
},

例如,读取字符串的首字符,并把 ASCII 小写字母转成大写:

fn first_char_upper(s: &str) -> Option<char> {
    let c = s.chars().next()?;   // 空字符串时这里直接返回 None
    Some(c.to_ascii_uppercase())
}
assert_eq!(first_char_upper("rust"), Some('R'));
assert_eq!(first_char_upper(""), None);
fn first_char_upper(s: &str) -> Option<char> {
    let c = s.chars().next()?;   // 空字符串时这里直接返回 None
    Some(c.to_ascii_uppercase())
}
assert_eq!(first_char_upper("rust"), Some('R'));
assert_eq!(first_char_upper(""), None);

对照 [定义] 一步归约 中的同余规则:E-Succ 说“若 𝑡 1⟶𝑡 1 ′,则 succ 𝑡 1⟶ succ 𝑡 1 ′”。 step(t1)? step(t1)? 先去找 𝑡 1 ′,找到了就拿来搭结论;找不到(前提不成立),结论就推不出来,整个函数返回 None None 。这正好是“子项不能归约,整个项也不能沿这条规则归约”。 类型检查器 type_of type_of 里的 type_of(t1)? type_of(t1)? 也是同样的意思:按照 [定义] 类型与类型判断 的规则,子项没有类型,就无法满足相应构造的定型前提,检查器返回 None None 。

5.3 [附注] 分支顺序里藏着证明 [proofs-behind-pattern-matching-order]

Rust 求值器 step step 实现 [定义] 一步归约 的规则。Rust 的 match match 按顺序尝试分支,这本身是一种“优先级”,而求值规则没有写这种优先级。为什么这样写不会改变语义?因为 [定理] 确定性 的证明已经说明,对每个 𝑡 至多一条规则适用,所以按什么顺序试结果都一样。没有那条定理,“先试 E-PredSucc 再试 E-Pred”就是一个偷偷加进来的、规则里没有的决定。

References

[定理] 结构归纳原理(structural induction) [structural-induction]

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

[定义] 归纳假设 [induction-hypothesis]

[] 范式、受阻与多步归约 [normal-forms]

[例] 一棵推导 [reduction-derivation]

[] 求值:用推导规则定义运行 [evaluation]

Backlinks

Based on Typsite