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

上一节回答的是项是什么(What):它由哪些符号、按什么结构搭成。这一节回答项怎么运行(How):一个项会一步一步变成什么。前者叫语法(syntax),后者叫语义(semantics)。这里给出语义的方式是直接描述程序运行的每一步,称为操作语义(operational semantics)。 [例] 不完整的规格 里“先算哪边”那类问题,在这一节都要有确定的答案。

1 [注记] 存在与生成 [being-and-becoming]

庸俗来说,哲学里研究“究竟什么是存在的?存在者又有哪些最基本的形式与范畴?”的分支叫本体论(ontology)。语法就是对象语言的本体论: [定义] 项的集合 列出了这门语言里有哪些东西,而“最小”保证除此之外什么也没有。

但只有“是什么”还不够。古希腊哲学最早的争论之一,就是巴门尼德(Parmenides)与赫拉克利特(Heraclitus)之争:前者认为真正存在的东西不生不灭、不会变化,后者认为万物皆流,“人不能两次踏进同一条河流”。亚里士多德的调和办法是区分潜能(dynamis)与现实(energeia):变化,就是事物把它潜在的可能变成现实。

这个区分在操作语义里有一个很贴切的对应。按 [定义] 一步归约 ,项 pred ( succ 0 ) 作为语法对象,就是它自己,不会变;但它有一种“潜能”,可以变成 0。 [定义] 值与数值 所划出的值,可以比作已经完全成为现实、再没有这种归约潜能的项:它不能再变成别的东西( [引理] 值不可归约 )。语法管“存在”,语义管“生成”。

2 值

先划出“已经算完”的项。

2.1 [定义] 值与数值 [values-and-numeric-values]

在 [定义] 项的集合 的项中,用以下 BNF 划出值。⩴ 读作“定义为”,| 读作“或者”; 是数值的元变量,不是对象语言的关键字。

形如 𝑣 的项称为值(value),形如 的项称为数值(numeric value)。和 [定义] 项的集合 一样,这两行 BNF 定义的是满足相应封闭条件的最小集合。

数值就是 0、succ 0、succ ( succ 0 )……,分别代表 0,1,2,…。几个例子:

  • true、0、succ ( succ 0 ) 是值;
  • succ  true 是项,但不是值:succ 后面必须是数值,而 true 不是;
  • pred 0 也不是值,值的定义里根本没有 pred。直观上,它还能再算。
3 推导规则怎么读

先说“归约”这个词。归约(reduction)指把一个项改写成另一个更接近结果的项,就像中学代数里把 ( 1+2 )×3 改写成 3×3,再改写成 9。每次改写只动一处,叫一步归约(one-step reduction);连续改写若干次,叫多步归约(multi-step reduction)。程序“运行”,在这里就是指一步接一步地归约,直到不能再归约为止。

3.1 [约定] 箭头 ⟶ 的读法与用法 [reading-the-reduction-arrow]

以下读法使用 [定义] 项的集合 中的项,以及 [定义] 一步归约 定义的关系 ⟶。

  • 𝑡⟶𝑡 ′ 读作“𝑡 一步归约到 𝑡 ′”,英文常读作 “𝑡 steps to 𝑡 ′” 或 “𝑡 reduces to 𝑡 ′”。
  • 它是一个命题,可以成立也可以不成立,就像 1<2 成立、2<1 不成立一样。例如 pred 0⟶0 成立;0⟶ pred 0 不成立。
  • 箭头是有方向的:左边是归约前的项,右边是归约后的项。
  • 它不是函数调用,也不是赋值。𝑡⟶𝑡 ′ 不会“改变” 𝑡,它只是断言 𝑡 与 𝑡 ′ 之间有这样一种关系。
  • 写 𝑡⟶𝑡 ′ 的时候没有说 𝑡 ′ 是唯一的;唯一性是要证明的( [定理] 确定性 )。
  • ⟶ ∗ 读作“多步归约到”,其含义由 [定义] 多步归约 给出。

数学上,⟶ 是一个二元关系(binary relation):它是由一些 ( 𝑡,𝑡 ′ ) 对组成的集合,𝑡⟶𝑡 ′ 就是 ( 𝑡,𝑡 ′ )∈⟶ 的简写。我们用推导规则(inference rule)来定义它。一条规则长这样:

前提  1⋯ 前提 𝑛 结论

读作:如果横线上的前提(premise)全都成立,那么横线下的结论(conclusion)成立。没有前提的规则叫公理(axiom),它的结论无条件成立。

横线右边可以标上规则的名字来方便引用。

3.2 [约定] 元变量 [metavariables]

在 [定义] 一步归约 与 [定义] 类型与类型判断 的规则里,𝑡 1,𝑡 2,𝑡 1 ′ 等字母是元变量(metavariable):它们是元语言里的变量,可以代换成 [定义] 项的集合 中的任意项; 只能代换成 [定义] 值与数值 中的数值。同一条规则里同一个字母必须代换成同一个东西。一条规则因此代表无穷多条实例(instance)。

3.3 [定义] 一步归约 [one-step-reduction]

设 𝑡、𝑡 ′ 属于 [定义] 项的集合 的项集合, 只取 [定义] 值与数值 中的数值。关系 𝑡⟶𝑡 ′ 是对下列十条规则封闭的最小关系。

每条横线之上的判断是前提,横线之下的是结论;前提全部成立,结论才成立。没有前提的规则可以直接使用。同一条规则中相同的元变量必须替换成同一个项,见 [约定] 元变量 。

这里的“最小”和 [定义] 项的集合 里的“最小”是同一个意思:𝑡⟶𝑡 ′ 成立,当且仅当能用这十条规则的实例搭出一棵以它为根的有限树。这棵树叫推导(derivation)。

规则没有提到的情形,例如 succ  true 该归约到哪里,那就是没有定义。“没有定义”在这里有精确的含义:十条规则里没有一条的结论能匹配 succ  true ⟶…(E-Succ 的结论的形状对得上,但它的前提却要求 true 能归约,而没有规则能做到),所以不存在任何 𝑡 ′ 使 succ  true ⟶𝑡 ′。

那么遇到没有定义的情形该怎么办?形式化的回答是:不要假装它有定义,而是把它当作一个明确的状态来对待。具体有三种常见做法:

  1. 承认它是错误状态。把“不是值、却又不能归约”的项单独命名(下文 [定义] 范式与受阻 的“受阻”),然后用类型系统证明良类型的程序永远到不了这种状态。本文采用这种做法。
  2. 显式加上错误规则。扩充语法,增加终止计算的错误项 wrong,再把原来受阻的运算规定为“产生错误”,并规定错误如何向外传播。Python 对 1 + "a" 1 + "a" 抛出 TypeError TypeError 是类似的运行时处理;Kotlin 的例子以及它与底类型的关系见 [附注] 显式错误规则与 Kotlin 的底类型 。
  3. 补上一个约定的结果。例如规定布尔值先转成数,true 对应 1、false 对应 0,于是 succ  true ⟶ succ ( succ 0 )。JavaScript 的 true + 1 === 2 true + 1 === 2 就是这种做法。它给这类运算规定了普通结果,但不代表所有程序都会正常返回;隐式转换也可能掩盖本想发现的错误,见 [例] JavaScript 隐式转换与相等三角图 。

3.4 [附注] 显式错误规则与 Kotlin 的底类型 [explicit-errors-and-kotlin-bottom-type]

以 [定义] 项的集合 的语法和 [定义] 一步归约 的十条求值规则为基础,可以另行加入错误项 wrong,并规定错误传播。以下讨论的是这门扩展语言,不把新增规则当作未扩展语言的规则。只写 succ  true ⟶ wrong 还不完整:还要处理 succ  false、pred  true、非布尔条件,以及嵌套位置里的错误。

记 𝑏 为 true 或 false, 为 [定义] 值与数值 中的数值。可以补上这些错误规则,其中 op 分别取 succ、pred、iszero:

原同余规则继续向正在求值的子项走。于是 succ ( if  true  then  false  else 0 ) 先归约到 succ  false,再归约到 wrong;pred ( succ  true ) 先变成 pred  wrong,再向外传播成 wrong。wrong 本身不再归约,应被识别为一种异常终点,而非原来定义的普通值。未选中的分支仍不会求值,例如 if  true  then 0 else  wrong 得到 0,不能不分位置地把整项都传播成错误。

Kotlin 可以显式写出这种异常控制流:

fun succ(x: Any): Int = when (x) {
    is Int -> x + 1
    else -> throw IllegalArgumentException("succ expects an Int")
}

fun fail(message: String): Nothing =
    throw IllegalArgumentException(message)

fun requireNat(n: Int): Int =
    if (n >= 0) n else fail("negative number")

// succ(true) 会抛出异常;requireNat(-1) 也会抛出异常。
// 这里借用 Kotlin 的 Int 演示错误处理,不把机器整数当作无界自然数。
fun succ(x: Any): Int = when (x) {
    is Int -> x + 1
    else -> throw IllegalArgumentException("succ expects an Int")
}

fun fail(message: String): Nothing =
    throw IllegalArgumentException(message)

fun requireNat(n: Int): Int =
    if (n >= 0) n else fail("negative number")

// succ(true) 会抛出异常;requireNat(-1) 也会抛出异常。
// 这里借用 Kotlin 的 Int 演示错误处理,不把机器整数当作无界自然数。

requireNat requireNat 的 else 分支为什么能放在需要 Int Int 的位置? fail(...) fail(...) 的类型是 Nothing Nothing ,表示它不会正常返回一个值。Kotlin 的 类型系统规格把 Nothing Nothing 定义成底类型(bottom type),通常写作 ⊥、读作 bottom。它是任何类型的子类型(subtype),即 ⊥<:𝑇 对任意 𝑇 成立。这里 𝑆<:𝑇 的意思是:𝑆 类型的表达式可以放进要求 𝑇 类型的上下文。因此不返回的失败分支可以和返回 Int Int 的成功分支放在同一个 if if 里。

必须区分三个对象:wrong 是对象语言里的错误项, IllegalArgumentException IllegalArgumentException 是 Kotlin 的异常对象,⊥ 是描述表达式不会正常产出值的类型。异常对象本身不是 Nothing Nothing ; Nothing Nothing 没有正常的值,也不是 null null ( Nothing? Nothing? 是另一回事)。抛异常的表达式可以具有底类型,永远循环的表达式也可以不返回,所以不能把“错误”与 ⊥ 当作同义词。

仅仅补上运行时错误规则,不会自动得到这样的子类型系统。若把显式异常加入 [定义] 类型与类型判断 的定型系统,也必须写明异常如何定型,并相应重述 [定理] 进展 与 [定理] 保型 ;不能直接套用未扩展语言的证明就声称“良类型程序永不抛异常”。 [推论] 类型安全 采用的则是未扩展语言:不加入错误项,让类型规则排除受阻。

3.5 [例] JavaScript 隐式转换与相等三角图 [javascript-coercion-and-equality-triangle]

JavaScript 没有把 true + 1 true + 1 留作未定义,而是明确规定转换后得到 2 2 。同样,宽松相等(loose equality) == == 规定了按两边种类进行转换的规则;严格相等(strict equality) === === 则不做这种转换,不同种类的操作数直接判为不相等。把两种比较放在一起看:

true + 1;    // 2:true 转换为 1

[] == 0;     // true
[] === 0;    // false
0 == "0";    // true
0 === "0";   // false
[] == "0";   // false
[] === "0";  // false
true + 1;    // 2:true 转换为 1

[] == 0;     // true
[] === 0;    // false
0 == "0";    // true
0 === "0";   // false
[] == "0";   // false
[] === "0";  // false

图中每条边同时列出 == == 和 === === 的结果,不是归约箭头。绿色实线表示 == == 为真,红色虚线表示 == == 为假;第二行单独记录 === === ,这里三条边都为假。

[] == 0 [] == 0 会先把空数组转换成空字符串 "" "" ,再因另一边是数字而把 "" "" 转成 0 0 ; 0 == "0" 0 == "0" 会把字符串 "0" "0" 转成数字 0 0 ,所以这两条边都为真。但 [] == "0" [] == "0" 把数组转成 "" "" 后,两边已经都是字符串,只比较 "" "" 与 "0" "0" ,结果为假。具体转换规则见 MDN 的 == == 文档。

传递性(transitivity)要求:𝑎 与 𝑏 相等、𝑏 与 𝑐 相等,就能推出 𝑎 与 𝑐 相等。这个三角图正好展示 == == 不满足传递性,不能像数学等号那样用于替换或推理。 === === 不会做上述转换:数组、数字、字符串种类不同,所以这三个比较都为假。它回答的是另一个问题,而不是“同一套转换做得更严格”。

但 === === 也不等于完整的数学等价关系。自反性(reflexivity)要求每个值都与自身相等,而 NaN === NaN NaN === NaN 为 false false 。对对象, === === 比较的是是否为同一个对象,不是内容是否相同: const a = []; a === a const a = []; a === a 为 true true , [] === [] [] === [] 却为 false false ,因为后者创建了两个不同的数组。严格相等的完整规则见 MDN 的 === === 文档。

3.6 [附注] 相等关系的“地狱”给语言设计什么教训 [equality-and-language-design]

[例] JavaScript 隐式转换与相等三角图 展示了 [] == 0 [] == 0 与 0 == "0" 0 == "0" 为真、 [] == "0" [] == "0" 为假的三角关系。它让人觉得“地狱”,不是因为结果随机:每一条比较都能按规格算出来。难受之处在于,名字叫“相等”,却不能沿用相等最基本的推理。我们原本希望“𝑎 等于 𝑏、𝑏 等于 𝑐”能让第三次比较省下来,现在却必须重新跑一遍转换规则。定义得精确与设计得容易理解,是两件事;形式化能让问题无处藏身,却不会自动把一个糟糕的约定变成好约定。

隐式转换确实能少写一些代码,例如让输入得到的字符串 "0" "0" 直接和数字 0 0 比较。但 == == 并不是先把每个值各自转换成某个统一表示,再比较这个表示:一个值怎样参与比较,还取决于另一边是什么种类。三角图里,同一个 [] [] 遇到数字时走到 0 0 ,遇到字符串时却停在 "" "" 。这份便利的代价,是读者不能只看一个值就知道比较会怎样进行,必须同时记住另一边以及两者触发的规则。

这会变成实际的算法问题。假如自己写一个去重函数:按输入顺序扫描,只要新值与某个已保留的值满足 == == ,就把新值丢掉。输入 [[], 0, "0"] [[], 0, "0"] 时,先保留 [] [] ,丢掉与它“相等”的 0 0 ,再保留与它“不相等”的 "0" "0" ,结果留下两个值;换成 [0, [], "0"] [0, [], "0"] ,后两个值都与 0 0 “相等”,结果只留一个值。普通去重也可能因顺序不同保留不同的代表,但若依据的真是等价关系,不应连分成几类都随顺序改变。这里说的是这个自定义的 == == 算法,不是 JavaScript 的 Set Set ; Set Set 使用的是另一套比较规则。

一种更容易推理的设计,是把“验证输入”“转换表示”和“比较”分开:在需要数字的边界先检查哪些输入可接受,再显式转成数字,最后使用不做隐式转换的比较。仅仅把 == == 换成 Number(a) === Number(b) Number(a) === Number(b) 也不够, Number("") Number("") 与 Number([]) Number([]) 都是 0 0 ;如果空输入或数组本来就是错误,仍应拒绝它们,而不是转换后假装正常。相等操作也要讲清楚是在比较对象身份、结构内容,还是领域中的某个键,并且检查算法需要的自反性、对称性与传递性。不同任务可以有不同规则,但不能只靠一个“相等”的名字暗示它们全都成立。

对新语言,这意味着不要只问“这个常见例子能不能少写一次转换”,还要问“加了这条便利规则以后,原有的推理性质是否还在,和其他规则组合会怎样”。这不等于所有隐式转换都不可取;需要判断的是转换保留了什么信息,以及它是否会掩盖应当暴露的错误。对已经部署的语言,直接改掉旧规则又可能破坏依赖它的代码,因而常常需要显式提供更清楚的操作,并用工具约束旧操作的使用。少写一个转换的局部便利,可能变成整个语言长期承担的理解与兼容成本。

所以“补上约定结果”不是“随便猜一个结果”:必须完整规定转换顺序,并检查这些约定保留了哪些性质、放弃了哪些性质。这也是 [附注] 显式错误规则与 Kotlin 的底类型 中显式报错方案与隐式转换方案的真正取舍:有时拒绝一次操作,比给它一个出乎意料却合法的结果更有帮助。

无论选哪种,关键是写下来。C 语言标准里的“未定义行为”(undefined behavior)是典型的反面教材:标准明确说某些情形没有定义,却没有要求实现报错,于是编译器可以假设它们永远不会发生,并据此做出让程序员意外的优化。

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

使用 [定义] 一步归约 的规则,推导树的每条横线都是一条规则的实例:横线上方的子推导满足前提,下方给出结论。以下三层合起来只证明整项的一步归约,不是三步运行。

从下往上读:要说明最下面那一步成立,用 E-Pred,它要求里面的 if 能一步归约;这又用 E-If,它要求条件 iszero 0 能一步归约;最后 E-IsZeroZero 是公理,无条件成立。

4 计算规则与同余规则

来讲讲上面那十条规则,它们分成两类,作用完全不同。

计算规则(computation rule)真正改写项:E-IfTrue、E-IfFalse、E-PredZero、E-PredSucc、E-IsZeroZero、E-IsZeroSucc。它们都没有关于 ⟶ 的前提,左边是一个具体的“可以计算”的形状,右边是算完的结果。例如:

  • if  true  then  succ 0 else 0⟶ succ 0(E-IfTrue,取 𝑡 2= succ 0,𝑡 3=0);
  • pred ( succ ( succ 0 ) )⟶ succ 0(E-PredSucc,取 );
  • iszero ( succ 0 )⟶ false(E-IsZeroSucc,取 )。

同余规则(congruence rule)不执行基本运算,而是负责找位置、把子项的归约带到整体:E-If、E-Succ、E-Pred、E-IsZero。它们的前提是“某个子项能归约”,结论是“整个项在那个位置归约”。“同余”这个名字的意思是:关系 ⟶ 和项的构造子(constructor)相容,子项归约了,包着它的项也跟着归约。(这和数论里的同余没有关系。)例如:

  • 因为 pred 0⟶0(E-PredZero),所以 succ ( pred 0 )⟶ succ 0(E-Succ);
  • 因为 iszero 0⟶ true(E-IsZeroZero),所以 if ( iszero 0 ) then 0 else  succ 0⟶ if  true  then 0 else  succ 0(E-If)。

每一步归约的推导都是同样的结构:底下若干次同余规则,一路往里找到要算的地方,最顶上恰好一次计算规则,在那里真正改写。 [例] 一棵推导 就是两次同余(E-Pred、E-If)加一次计算(E-IsZeroZero)。

上图中 pred pred 和 if if 节点是同余规则经过的路径, iszero 0 iszero 0 节点是计算规则改写的位置,虚线连着的分支不参与这一步。

规则的细节里藏着设计决定,读的时候要留意:

  • E-If 只允许在条件里归约,没有规则允许在分支里归约。所以先求条件,再选择一个分支,不会提前计算未选中的分支。下文 [附注] 求值策略、归约策略与合流性 会把这样的顺序规定称为求值策略,并与一般的归约策略区分。
  • 计算规则要求正在检查的操作数已经是合适的值,但不要求未选中的分支是值。例如 E-PredSucc 的左边是 ,只有 succ 里面已经是数值时才能用;否则就只能先用同余规则把里面算完。这保证了“先算里面,再算外面”的顺序,下面的 [附注] 为什么 E-PredSucc 要求 会说明为什么这一点很重要。

4.1 [注记] 意义即使用 [meaning-as-use]

[定义] 一步归约 不用自然语言的直觉解释来决定 succ “是什么”,而是规定它在计算里怎样被使用。后期维特根斯坦(Wittgenstein)在《哲学研究》里提出,一个词的意义在很多情况下就是它在语言中的用法;逻辑学里的推理主义(inferentialism,根岑(Gentzen)、普拉维茨(Prawitz)、达米特(Dummett)、布兰顿(Brandom)一脉)更进一步,主张逻辑联结词的意义由它的推理规则给出。

操作语义正是这种立场的工程版本:一个构造的意义,就是关于它的规则的全体。这种立场有一个好处,它把“意义”变成了可以逐条核对的东西。

References

[定义] 项的集合 [set-of-terms]

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

[定义] 范式与受阻 [normal-forms-and-stuck-terms]

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

[] 语法:BNF、最小集合与结构归纳 [syntax]

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

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

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

Backlinks

Based on Typsite