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

研究程序语言时,我们关心的问题往往很朴素:这段程序会算出什么?它会不会在运行时崩溃?编译器说它“没有类型错误”,这句话能信吗?这些问题本身都能用自然语言问出来。可一旦想确定地回答它们,自然语言就不够用了。

这组文章做两件事。前半部分解释为什么要换一种语言来谈论程序语言,后半部分用一门很小的语言(布尔值加自然数),从零开始把这种“新语言”的用法完整演示一遍,这个过程叫形式化(formalization):怎样定义语法,怎样定义“运行”,怎样定义“类型”,最后怎样证明“通过类型检查的程序不会卡住”。

路线依次经过自然语言与元语言、语法、求值、确定性、范式与受阻、类型、类型安全、检查算法与测试,附带练习。

1 [] 为什么形式化:自然语言与元语言 [natural-and-formal]

1.1 [约定] 阅读约定 [reading-conventions]

这些约定适用于 形式化入门:从 BNF 到类型安全 的文章与条目。

  • 不需要任何 PLT 背景。需要的只是中学程度的“集合”概念,以及愿意一行一行读下去的耐心。用到的每个概念都会先定义再使用。
  • 定义、定理、引理、例、附注、注记等都是可以单独打开的页面;嵌入正文时由 typsite 按当前层级编号,标题前的“[种类]”说明它的用途。正文引用不显示随嵌入位置改变的编号,例如 [定义] 一步归约 ;点击引用,目标已在当前页中时就地展开,否则打开它的独立页面。各种方框的用途与系列的行文习惯,见 序章 。
  • 每个证明都写全所有情形,以 ∎ (Q.E.D.)结尾。第一次读可以跳过证明,只看定理说了什么。
  • “注记”方框把文中的概念和哲学、历史上的相关讨论联系起来。它们不参与论证,跳过不影响主线推理。“附注”方框补充技术细节,比如容易忽略的前提、常见的误解。网页中的注记与附注默认折叠,点击标题展开。
  • Rust 代码只用来说明“规则可以直接变成程序”。不会 Rust 也不影响理解,代码旁边都有注释。
  • 形式化入门的七个主题末尾附有练习,题号的第一位表示主题,第二位表示本组题目。先独立作答,再展开提示或参考答案;标为“扩展”的题目会明确改动哪些规则,其结论不能直接用于 [定义] 一步归约 与 [定义] 类型与类型判断 规定的未扩展语言。网页里的整组练习默认折叠,展开后可看到所有题目,再分别展开提示与答案;PDF 中直接显示。

1.2 为什么不用自然语言

日常交流里,自然语言(natural language)足够好用,无论是汉语还是英语。说话的人和听话的人共享大量背景,含糊的地方靠语境补上,补错了再问一句就行。描述程序语言时,这几条都不成立:读规格(specification)的可能是其它国家的编程语言理论爱好者(PLer),可能是十年后的自己,也可能是一台机器。它们当然没法回到10年前问你“你当时**到底什么意思?”。下面几个例子分别展示自然语言在这件事上的一种弊端。

1.2.1 弊端一:一句话有多种结构

1.2.1.1 [例] 结构歧义 [structural-ambiguity]

“咬死了猎人的狗”可以是“(咬死了猎人)的狗”,一条狗;也可以是“咬死了(猎人的狗)”,一个事件。两种读法用的字完全相同,差别只在怎样分组。

程序里同样的事随处可见。 1 + 2 * 3 1 + 2 * 3 是 ( 1+2 )×3=9 还是 1+( 2×3 )=7? if a then if b then x else y if a then if b then x else y 里的 else else 属于哪个 if if ?

人读到这些句子时会凭常识选一种,常识不同的人就会选不同的那一种。机器没有常识,只能靠写死的规则。

1.2.2 弊端二:没说到的情形

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

一份规格写道:“ if if 的条件必须是布尔值,两个分支的类型相同。”读起来没什么问题,但它完全没有涉及下面的情况:

  1. 如果条件恒为真,else 分支永远不会执行,那我们还检查 else 分支吗?
  2. “类型相同”是字面上一样,还是可以自动转换?
  3. 检查器拒绝一个程序时,能不能指出它违反的是哪一条?

再如“ f() + g() f() + g() 先算两边再相加”。如果 f f 和 g g 都会打印东西,先打印谁?(也就是说没规定求值顺序)

[附注] 不完整规格中的类型检查问题 按一套明确的类型规则回答前三个问题;求值顺序的影响见 [附注] 求值策略、归约策略与合流性 。

规格的作者心里多半有答案,只是觉得“显然”而没写下来。问题在于,不同的人眼里显然的东西不一样。C 语言标准就是用英文写的,几十年来,关于某些条款到底允许什么的争论一直没停,后来还出现了专门把它的含义精确化的研究项目(例如 Cerberus)。

1.2.3 弊端三:“所有都不”?“不是所有”?

1.2.3.1 [例] 量词的范围 [quantifier-scope]

“所有测试都没通过”可以是“每个测试都失败了”,也可以是“并非所有测试都通过了”,后者只要有一个失败就成立。两种读法的差别在于“所有”和“没”哪个管着哪个。

把量词的作用范围明确写出来:前者是“对每个 𝑥,𝑥 都没通过”,后者是“并非(对每个 𝑥,𝑥 都通过)”。写出来之后,两句话长得就不一样了。

讲程序语言的性质时,这类句子到处都是。“每个良类型的程序都不会出错”和“存在一个类型,使得每个程序都有这个类型”,量词顺序一换,意思就完全不同。

1.2.4 弊端四:自指问题

1.2.4.1 [例] 贝里悖论 [berry-paradox]

下面是贝里悖论(Berry paradox)的一个汉语版本。

考虑这个短语:“不能用少于二十个字来定义的最小正整数”。

汉字只有有限多个,少于二十个字的短语也只有有限多个,它们最多定义有限多个正整数。所以确实存在不能用少于二十个字定义的正整数,其中有一个最小的,记为 𝑛。可上面那个短语本身只有十八个字,它恰好定义了 𝑛。于是 𝑛 能用少于二十个字定义,矛盾。

附:毕的二阶导 —— 迷惑的贝里悖论:20个汉字真的能表达所有自然数吗?

问题出在“定义”这个词上。短语在定义数,同时又在谈论“什么算定义”,一句话里混了两个层次。自然语言允许这样随意地自我指涉,这极大地提升了自然语言的可表达性和便捷性,代价是有些句子没有任何一致的意思。后文会看到,形式化的做法是把“被谈论的语言”和“用来谈论的语言”严格分开( [定义] 对象语言与元语言 )。

1.2.5 弊端五:“显然”没法检查

前面四个例子讲的是说清楚有多难。还有一个问题更根本:我们想要的结论是关于所有程序的。“这门语言里,通过类型检查的程序都不会在运行时出错”,这句话谈论的是无穷多个程序。

无穷多个程序没法一个一个试。剩下的办法只有论证,而用自然语言写的论证,读者很难判断它有没有漏掉情形。“其余情形类似”“显然成立”这些话,写的人常常是真心相信的,但这恰恰是错误最爱藏的地方。

1.2.6 形式化带来了什么

把上面五点反过来,就是形式化要做到的事。

“反过来”的意思是:每一种弊端,都对应自然语言允许了某件事,而形式化的做法是把这件事禁止掉,或者让它必须写明。

  • 自然语言允许一串字有多种分组,形式语言就规定每个对象只有一种结构;
  • 自然语言允许“没说到”的情形靠常识补上,形式语言就规定没写进规则的情形一律不成立;
  • 自然语言允许量词的范围靠语气和语序暗示,形式语言就让范围由符号的位置唯一确定;
  • 自然语言允许一句话谈论它自己所在的语言,形式语言就把这两层拆成两门语言;
  • 自然语言允许“显然”充当论证的一步,形式语言就要求每一步都写出它用的是哪条规则。

换句话说,形式化不是给自然语言添了什么新能力,而是拿走了它的一部分自由。正是这些被拿走的自由,让写的人和读的人、人和机器,能对同一段文字得到同一个理解。代价也很明显:形式语言啰嗦、死板,写一句“显然”的话可能要好几行。下文会看到,这份啰嗦恰恰是有用的,很多设计上的问题就是在把“显然”展开的时候暴露出来的。

逐条对应如下:

  1. 结构唯一:语言的语法写成树,一个程序只有一种分组方式(对应 [例] 结构歧义 )。
  2. 情形穷尽:语言的含义写成有限条规则,规则没有覆盖的情形就是没有定义,不靠“显然”去补(对应 [例] 不完整的规格 )。
  3. 形状即意思:命题用固定的符号写出,量词的范围由写法决定(对应 [例] 量词的范围 )。
  4. 层次分开:被研究的语言和研究它的语言是两门语言(对应 [例] 贝里悖论 )。
  5. 可以检查:证明的每一步都是某条规则的一次应用,别人可以逐步核对,原则上机器也可以(对应上一小节)。

要强调一点:形式化不是把自然语言赶出去。下文的证明仍然用自然语言写,因为人读证明需要自然语言的解释。改变的是“到底在说什么”的最终决定权:自然语言负责讲解,有分歧时以符号写成的定义为准。

1.2.6.1 [注记] 莱布尼茨与弗雷格 [leibniz-and-frege]

把思想写成精确符号、使推理能够逐步核对的愿望很老。17 世纪末,莱布尼茨(Leibniz)设想过一种“普遍文字”(characteristica universalis),以及配套的推理演算(calculus ratiocinator):思想写成符号,推理变成计算,争论的双方只需说一句“我们来算一算”(Calculemus)。

两百年后,弗雷格(Frege)在 1879 年的《概念文字》(Begriffsschrift)里真的造出了这样一门语言,它是现代逻辑的起点之一。他在序言里说,日常语言的推理里,常有没被察觉的前提悄悄混进来;他想要的是一条没有缝隙的推理链,每一步都看得见用了什么。 [约定] 阅读约定 也采用了这种证明写法:每个隐含的前提都要摆到台面上。

莱布尼茨的梦想没有完全实现。20 世纪的哥德尔(Gödel)和图灵(Turing)证明了,有些问题原则上就不能靠“算一算”来裁决。在程序语言理论中, [定义] 一步归约 与 [定义] 类型与类型判断 用规则精确定义运行和类型, [推论] 类型安全 则从这些规则证明良类型程序不受阻,是这种可核对推理的一个小例子。

接下来要用“树”描述程序的结构,先把相关术语说清楚。

1.2.6.2 [定义] 树的基本术语 [tree-terminology]

这里约定的树(tree)是由节点(node)和连接节点的边(edge)组成的有限结构,有一个指定的根(root)。除根以外,每个节点恰有一个父节点(parent);从根沿父子方向走到任意节点的路径唯一,不会绕回原处。每个节点可以带一个符号作为标签。

  • 一条边连接父节点与它的子节点(child),也叫“孩子”。同一父节点的孩子有顺序,因此这是有序树;例如 [定义] 项的集合 中条件、then 分支、else 分支的位置不能互换。
  • 没有孩子的节点称为叶子(leaf);至少有一个孩子的节点称为内部节点(internal node)。根也可能是叶子:只有一个节点的树就是如此。
  • 从某个节点沿父子方向走一步或多步能到达的节点,叫它的后代(descendant)。以一个节点为根,连同它的所有后代和连接它们的边,构成一棵子树(subtree)。整棵树也算以原根为根的子树;排除它自己时,称为真子树。

例如表示 pred ( succ 0 ) 的树有三个节点。根标着 pred,它的孩子标着 succ,后者的孩子标着 0。前两个是内部节点,0 是叶子;以 succ 为根的子树表示 succ 0。这里“子节点”指一个节点,“子树”指从那个节点开始的整块结构。

1.2.7 练习:自然语言弊端
本节练习(5题)

练习1.1:分组和执行顺序是同一件事吗?

一份规格只写了“先乘除,后加减”。它是否同时规定了 2 + 3 * 4 2 + 3 * 4 的分组,以及 f() + g() f() + g() 的调用顺序?设两个函数都会打印一个字符,请给出两份分组相同、打印顺序不同的完整顺序规定。
提示分别问“这个表达式怎样分组”和“哪一次函数调用先发生”。参照 [例] 结构歧义 与 [例] 不完整的规格 。
参考答案

“先乘除,后加减”把 2 + 3 * 4 2 + 3 * 4 规定为 2 + (3 * 4) 2 + (3 * 4) ,结果是 14,但没有规定 f() + g() f() + g() 先调用谁。设 f f 打印 F F 后返回 1, g g 打印 G G 后返回 2:先左后右打印 FG FG ,先右后左打印 GF GF ,两者的数值结果都为 3。

可以分别补上“先完整求出左操作数,再求右操作数,最后相加”与相反顺序的规定。分组解决哪个运算包着哪个运算,执行顺序解决哪件事先发生;一条优先级约定不能替代后一条规定。

练习1.2:把量词的范围写出来

检查器接受了甲、乙两个程序,拒绝了丙。“所有提交的程序都没有被接受”有哪两种读法?分别写成不含糊的句子,判断真假,并写出每种读法的否定。
提示区分“每一个都被拒绝”和“至少一个没被接受”,不要只在原句里换一个近义词。
参考答案

若把“所有提交的程序都没有被接受”理解为“每个提交的程序都被拒绝”,那么它为假:甲、乙已被接受。若理解为“并非所有提交的程序都被接受”,那么它为真:丙被拒绝。

第一种说法的否定是“至少一个提交的程序被接受”;第二种说法的否定是“每个提交的程序都被接受”。它们的否定也不同,因此原句不能靠读者自行猜范围。这里约定每个提交最终只有“接受”或“拒绝”两种结果。

练习1.3:补全一份除法规格

“ a / b a / b 返回它们的商”漏掉了哪些情况?请自行设计一个整数除法操作,完整规定输入范围和错误处理,并写出三个输入及其预期结果。答案可以有不同设计,但不能把异常情形留空。
提示至少说明可接受的输入、非整除时的结果、除数为零的处理;再给能区分不同规定的例子。
参考答案

一种规定是:两边都必须是非负整数,且除数 𝑏 必须大于 0;返回唯一的非负整数 𝑞,满足 𝑞𝑏≤𝑎<( 𝑞+1 )𝑏。不满足输入条件时明确报错,不自动把字符串转成整数。

于是 7 / 2 7 / 2 返回 3, 7 / 0 7 / 0 报错, 7 / "2" 7 / "2" 也报错。第一例排除了“保留小数商”的规定,后两例分别排除了“零除返回某个普通数”和“字符串自动转换”的规定。这只是一个可选设计,关键是不能只写“返回商”却把这些决定留给实现者。

练习1.4:谈论一个名字,不等于增加一个名字

一门小语言只允许三个整数名字:“一”“二”“三”,分别表示 1、2、3。那么“这门语言里不能被命名的最小正整数”描述的是哪个数?这句话是否自动成为该语言的第四个名字?说明理由。
提示先检查规定的命名表里有没有那个短语,再问是否偷偷扩充了语言。参照 [例] 贝里悖论 。
参考答案

按命名表,4 确实是不能被命名的最小正整数,但长短语不在命名表里,所以不是这门语言的名字。我们能在解释规格的自然语言里描述 4,不意味着那门小语言也能命名它。

如果正式把这个短语加进命名表,讨论的就不再是原来那门语言:可命名的数已经改变,原先关于“最小不能被命名的数”的结论必须重新检查。贝里悖论式的混淆,恰恰是把外部描述悄悄算作语言内部的名字,却继续沿用扩充前的判断。

练习1.5:找出“其余显然”的缺口

设 𝑃( 𝑛 ) 表示关于正整数 𝑛 的一个判断。某人说:“我验证了 𝑃( 1 ) 到 𝑃( 100 ),所以每个正整数都满足 𝑃,其余显然同理。”请给出一个使前一百次验证全部通过、普遍结论却为假的具体 𝑃,并指出这个论证究竟缺了什么。
提示构造一个只对前一百个正整数成立的性质,就能检查有限验证到底证明了什么。
参考答案

取 𝑃( 𝑛 ) 为“𝑛≤100”。前一百个正整数都满足它,101 却不满足。因此“检查了一百次”只能支持这一百个具体实例,不能独自推出普遍结论。

要证明所有正整数都满足某个性质,还要给出覆盖所有情况的推理方法;“剩下的显然同理”若没有说明相同的前提和推理步骤,只是在重复待证结论,而不是补完证明。

1.3 两个层次的语言

动手之前,先把 [例] 贝里悖论 留下的教训落实成一条约定。

1.3.1 [定义] 对象语言与元语言 [object-language-and-metalanguage]
  • 对象语言(object language):被研究的那门语言。
  • 元语言(metalanguage):用来谈论对象语言的语言。

例如, [定义] 项的集合 、 [定义] 一步归约 与 [定义] 类型与类型判断 规定的布尔值/自然数语言是对象语言;用来写这些定义和证明的自然语言与数学符号属于元语言。用 Rust 实现这门小语言时,Rust 也充当元语言,见 [附注] Rust 也是元语言 。

例如“succ 0 是一个值”是元语言里的一句话,它在谈论对象语言里的 succ 0。对象语言自己说不出这句话,它里面根本没有“值”这个词。反过来,元语言里的“所有”“如果……那么”,也不是对象语言的一部分。只要始终分清一句话属于哪一层,贝里悖论那种“一句话同时在两层说话”的情形就不会出现。

1.3.2 [附注] Rust 也是元语言 [rust-as-a-metalanguage]

用 Rust 实现 [定义] 项的集合 的项和 [定义] 一步归约 的运行规则时,Rust 充当 [定义] 对象语言与元语言 中的元语言:Rust 的 enum Term enum Term 描述对象语言的项,Rust 的函数描述对象语言的运行。不要把 Rust 自己的类型( bool bool 、 Option Option )和 [定义] 类型与类型判断 中的对象语言类型(Bool、Nat)混为一谈,它们分属两层。实现示例见 Rust 求值器 与 Rust 类型检查器 。

1.3.3 [注记] 塔斯基的层级 [tarski-language-hierarchy]

“对象语言 / 元语言”这对术语来自逻辑学家塔斯基(Tarski)。1930 年代他研究“真”这个概念时发现,像说谎者悖论(liar paradox,“这句话是假的”)这样的困境,根源在于一门语言试图谈论自身句子的真假。他的方案是分层:关于对象语言 𝐿 的句子是否为真,只能在更高一层的元语言里说。

程序语言理论中的 [定义] 对象语言与元语言 采用同样的区分:被研究的语言和研究它的语言分属两个层次。后来者在这基础上发展出了更精细的做法(比如允许一门语言有限度地谈论自身的“反射”(reflection)),但出发点都是先把层次分清。

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

2.1.1 [约定] 抽象语法 [abstract-syntax]

抽象语法(abstract syntax)把程序视为语法树(syntax tree),即节点标签表示程序构造的树(树的术语见 [定义] 树的基本术语 ),而不是一串字符。从字符串到树的那一步叫解析(parsing)。采用这条约定时,假定解析已经完成,而且已经消除了 [例] 结构歧义 那样的歧义;解析器本身不在这条约定的研究范围内。书写时为了能在一行里写下一棵树,会用括号表示分组:pred ( succ 0 ) 表示根标着 pred,它的唯一孩子标着 succ;这个孩子及其后代构成表示 succ 0 的子树。

这条约定是第一个“隐含前提”:本文所有的定理都是关于树的。至于怎样把字符串可靠地变成树,那是另一个话题。这种只关心树形结构、不关心字符怎么排的语法,叫抽象语法(abstract syntax)。

2.2 BNF

描述语法树长什么样,最常用的写法是 BNF,全称巴科斯–诺尔范式(Backus–Naur Form,参见维基百科)。它得名于巴科斯(John Backus)和诺尔(Peter Naur):1960 年的 ALGOL 60 报告第一次用这种写法给一门程序语言写出了完整的语法,此后几乎所有语言的规格都沿用了它的某种变体。

BNF 的一行叫一条产生式(production),形如

名字 ⩴ 选项  1| 选项  2|⋯

  • ⩴ 读作“定义为”或“可以是”;
  • | 读作“或者”,把几种选项隔开;
  • 每个选项是一串符号,其中有些是写死的关键字(如 if、succ),有些是名字本身,表示“这里放一个同类的东西”。

名字出现在自己的右边,就形成了递归。例如

𝑛⩴0| succ 𝑛

说的是:𝑛 可以是 0,也可以是 succ 后面跟着另一个 𝑛。于是 0、succ 0、succ ( succ 0 ) 都是 𝑛。BNF 本身只是一种记号,它的准确含义要靠下面的“最小集合”来说清楚。

2.3 项

对象语言的程序称为项(term),用字母 𝑡 表示。项的写法用一行 BNF 给出:

𝑡⩴ true | false | if 𝑡 then 𝑡 else 𝑡|0| succ 𝑡| pred 𝑡| iszero 𝑡

竖线读作“或者”。succ 𝑡 是“𝑡 的后继”(successor),可以理解为加一;pred 𝑡 是“前驱”(predecessor),可以理解为减一;iszero 𝑡 问 𝑡 是不是零。例如 if ( iszero ( pred ( succ 0 ) ) ) then 0 else  succ 0 是一个项,它对应的语法树如下:

图中的 if 是根,有三个孩子;iszero、pred、succ 是只有一个孩子的内部节点;三个标着 0 的节点是不同位置上的叶子。

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

对 [定义] 项的集合 中的项,采用 [定义] 树的基本术语 的树术语。根节点的每个孩子所对应的整棵子树,称为它的直接子项(immediate subterm)。具体地:

  • true、false、0 没有直接子项;
  • succ 𝑡 1、pred 𝑡 1、iszero 𝑡 1 的唯一直接子项是 𝑡 1;
  • if 𝑡 1 then 𝑡 2 else 𝑡 3 的三个直接子项按顺序是 𝑡 1、𝑡 2、𝑡 3。

沿着“取直接子项”走一步或多步得到的项,称为真子项(proper subterm)。例如 pred ( succ 0 ) 的直接子项只有 succ 0,0 也是它的真子项,但不是直接子项;整项本身不是自己的真子项。同一文本可以出现在树的不同位置,谈子项时还要看它所在的位置。

这一行 BNF 看起来是在描述“项长什么样”,但严格来说它是在定义一个集合。

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

把项视为 [约定] 抽象语法 中的有限语法树,常量 true、false、0 是叶子,succ、pred、iszero 是一元构造,if 有条件、then 分支和 else 分支三个有序子项。

项的集合 是满足下面三条封闭条件(closure conditions)的最小(least)集合:

  1. ,,;
  2. 若 ,则 succ 𝑡 1、pred 𝑡 1、iszero 𝑡 1 都属于 ;
  3. 若 ,则 。

    “最小”的意思是:如果另一个集合 𝑆 也满足这三条,那么 。

注: 这个符号是字母T的花体, 可以读作 script T script T 。

“封闭”说的是:集合里有了 𝑡 1,就必须也有 succ 𝑡 1 等等,用这几条规则造不出集合外的东西。

为什么还要加“最小”?因为满足封闭条件的集合有很多。比如允许额外的叶子 null null ,并且把所有包含这个新叶子的 succ、if 等树也一起加进来,得到的更大集合仍然满足封闭条件。但 null null 不是本文的项,因为三条生成条件没有给出它。注意只加 null null 而不加 succ  null 等树,反而会破坏封闭性。

取最小的那个,就是在说:项只有用上面三条规则、在有限步内搭出来的东西,别的一概不算。

2.3.3 [附注] 最小集合确实存在 [existence-of-the-least-set]

[定义] 项的集合 有一个存在性前提:满足那三条封闭条件的集合中真的有一个最小的。先固定一个背景集合 𝑈,包含节点标签取自 true、false、0、succ、pred、iszero、if 的所有有限有序树(暂不限制每个标签的孩子数量)。𝑈 本身满足三条封闭条件,所以满足条件的 𝑈 的子集至少有一个,取交集不是在对空的一族集合操作。

把所有满足封闭条件的 𝑈 的子集取交集,记为 𝐼:

  • 每个集合都含 true、false、0,所以 𝐼 也含这三个常量;
  • 若 𝑡 1∈𝐼,则 𝑡 1 在每个集合里。每个集合都对三个一元构造封闭,所以 succ 𝑡 1、pred 𝑡 1、iszero 𝑡 1 也在每个集合里,因而在 𝐼 里;
  • 若 𝑡 1,𝑡 2,𝑡 3∈𝐼,它们在每个集合里。每个集合都对 if 构造封闭,所以 if 𝑡 1 then 𝑡 2 else 𝑡 3 也在交集里。

因此 𝐼 满足全部封闭条件,而且按交集的定义,它包含在每个满足条件的集合里。这就是所需的最小集合 ,不是从一堆集合里凭直觉挑一个。

由此可以得到两件事。第一,每个项都是一棵有限的树:叶子是 true、false、0,内部节点是 succ、pred、iszero(各有一个孩子)或 if(有三个孩子)。第二,项只管形状,不管有没有意义。succ  true 和 if 0 then 0 else  true 都是合法的项。它们“有没有意义”,要等后面的类型系统来判断。

Rust 里,这个集合就是一个枚举。 enum enum 的值只能由这几个构造子有限次地组合出来,“最小”由语言本身保证:

// 对象语言的项(定义 “项的集合”)
#[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>),
}

Box<Term> Box<Term> 是一个指向堆上 Term Term 的指针,并且独占它指向的那块内存。为什么不直接写 Succ(Term) Succ(Term) ?因为 Rust 要在编译时知道每个类型占多少字节。如果 Succ Succ 里直接装一个 Term Term ,那个 Term Term 里又可能装一个 Term Term ……大小就成了无穷大,编译器会报错(“recursive type has infinite size”)。换成 Box Box 以后, Succ Succ 里装的只是一个固定大小的指针,真正的子树放在堆上。

从树的角度看, Box Box 正好对应语法树里的一条边:父节点通过它“拥有”自己的子树,子树不和别的节点共享。也正因为独占, Box Box 不能按位复制,复制一个项要用 .clone() .clone() 把整棵子树深拷贝一遍,后面的代码里会看到这一点。

2.4 结构归纳

“最小”不只是为了排除例外,它还直接送给我们一条证明方法。我们常常想证明“所有项都有某个性质 𝑃”。项有无穷多个,不能一个个检查;但项的集合是用三条规则“搭”出来的,所以只要性质能跟着这三条规则一起“搭”上去就行。

2.4.1 [附注] 什么是归纳 [meaning-of-induction]

“归纳”(induction)这个词在不同领域里意思不一样,先分清楚。

  • 在经验科学和日常推理里,归纳推理是从有限个观察得出普遍结论:见过的天鹅都是白的,于是“所有天鹅都是白的”。与它对立的是演绎推理(deduction):从前提出发,按逻辑规则推出结论,前提为真则结论必真。归纳推理的结论可能出错(澳大利亚有黑天鹅),休谟(Hume)在 18 世纪指出,归纳推理没有逻辑上的保证,这就是归纳问题。
  • 数学里的数学归纳法名字里虽然有“归纳”,却是一种演绎:证明 𝑃( 0 ),再证明“𝑃( 𝑛 ) 蕴涵 𝑃( 𝑛+1 )”,就能断定所有自然数都满足 𝑃,没有任何“可能出错”的余地。 [定理] 结构归纳原理(structural induction) 与 [定理] 对推导归纳 使用的也是这种演绎证明方法。

数学归纳法的历史很长。古希腊的欧几里得(Euclid)证明素数有无穷多个时,已经有了它的影子;16 世纪的莫罗利科(Maurolico)、17 世纪的帕斯卡(Pascal)在讨论二项式系数时比较明确地用了它;“数学归纳法”这个名字是 19 世纪德摩根(De Morgan)起的。19 世纪末,戴德金(Dedekind)和皮亚诺(Peano)把它写成了自然数的公理之一,并指出它其实来自“自然数是包含 0、对后继封闭的最小集合”。 [定理] 结构归纳原理(structural induction) 把这个想法从自然数推广到由规则搭起来的有限树。

在程序语言理论里,和归纳对立的是余归纳(coinduction)。归纳对应“最小”的集合,处理有限的、搭得完的对象;余归纳对应“最大”的集合,处理可以无限展开的对象,比如永不停机的程序、无穷长的数据流。 [定义] 项的集合 的有限项与 [定义] 一步归约 的有限推导都属于归纳定义的对象。

先让我们来想想“最小”为什么能推出归纳。

设 𝑆 是所有满足 𝑃 的项组成的集合。

如果 𝑃 能“跟着规则搭上去”,意思就是:𝑆 也满足那三条封闭条件,它也是一个“规则搭不出去”的集合。

而 是这样的集合里最小的那个,即是每一个这样的集合的子集。

所以当然也是 𝑆 的子集,于是的每个项都在 𝑆 里,都满足 𝑃。

如果没有“最小”, 里可能混进 null null 这样的例外,它不是任何上面那三条规则搭出来的,规则对这种例外什么也没说,𝑃 对它成不成立也就无从谈起。

所以“最小”保证了 里只有规则搭出来的东西,所以“对每条规则检查一遍”就等于“对每个项检查一遍”。

下面把这段话写成定理。

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

设 𝑃 是关于 [定义] 项的集合 中有限项的一个性质。如果下面三条都成立:

  1. 𝑃( true )、𝑃( false )、𝑃( 0 );
  2. 对任意项 𝑡 1,由 𝑃( 𝑡 1 ) 能推出 𝑃( succ 𝑡 1 )、𝑃( pred 𝑡 1 )、𝑃( iszero 𝑡 1 );
  3. 对任意项 𝑡 1,𝑡 2,𝑡 3,由 𝑃( 𝑡 1 )、𝑃( 𝑡 2 )、𝑃( 𝑡 3 ) 能推出 𝑃( if 𝑡 1 then 𝑡 2 else 𝑡 3 ),

那么对所有项 𝑡,𝑃( 𝑡 ) 成立。

证明
令 ,即满足 𝑃 的项组成的集合。三条假设恰好说明 𝑆 满足 [定义] 项的集合 的三条封闭条件。由于 是满足封闭条件的最小集合,。也就是说每个项都在 𝑆 里,都满足 𝑃。
∎

在这里,结构归纳不是额外请来的对象语言公理:在元语言的集合论背景下,它由“最小”这两个字推出。这里不是说一切数学基础都不需要公理,而是说定义了这个最小集合以后,不必再另加一条关于它的归纳公理。

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

归纳假设(induction hypothesis)是在一个归纳步骤中,对归纳原理许可的更小对象暂时假定的性质。它是用来证明“如果这些更小对象满足 𝑃,那么当前对象也满足 𝑃”的前提,不是把“所有对象都满足 𝑃”或当前待证结论预先当真。

在 [定理] 结构归纳原理(structural induction) 的第 2 条中,归纳假设是 𝑃( 𝑡 1 );第 3 条中是 𝑃( 𝑡 1 )、𝑃( 𝑡 2 )、𝑃( 𝑡 3 )。这些对象恰好是 [定义] 直接子项与真子项 中列出的直接子项。常量没有子项,所以基础情形没有归纳假设,必须直接证明。

为什么这种暂时假定合法?我们首先证明的是一个条件命题;每个实际项都由有限次构造得到,从已经验证的叶子出发,逐层应用这个条件命题,就能把性质传到根。假设只沿着更小的结构使用,不会绕回当前待证结论。

2.4.4 [例] 合法的归纳假设与循环论证 [induction-hypotheses-and-circular-reasoning]

取 [定义] 项的集合 中的有限项,树的术语见 [定义] 树的基本术语 ,归纳方法采用 [定理] 结构归纳原理(structural induction) 。

取 𝑃( 𝑡 ) 为“𝑡 至少含有一个叶子”。常量本身就是叶子,基础情形成立。证明 𝑃( pred 𝑡 1 ) 时, 归纳假设 𝑃( 𝑡 1 ) 给出 𝑡 1 中有一个叶子;加上 pred 根以后,那个叶子仍然存在。证明 𝑃( if 𝑡 1 then 𝑡 2 else 𝑡 3 ) 时,任取一个孩子中的叶子即可。对于 pred ( succ 0 ),先验证 𝑃( 0 ),再推出 𝑃( succ 0 ),最后推出整项的性质,没有一步用到尚未证明的结论。

反过来,试图证明错误命题 𝑄( 𝑡 ):“𝑡 不含 pred”,然后在 pred 𝑡 1 的情形里说“假定 𝑄( pred 𝑡 1 ),所以它不含 pred”,就是循环论证(circular reasoning):用待证结论本身支持待证结论。它至多证明了 𝑄( pred 𝑡 1 )⟹𝑄( pred 𝑡 1 ),没有证明归纳步骤要求的 𝑄( 𝑡 1 )⟹𝑄( pred 𝑡 1 )。实际取 𝑡 1=0,前者 𝑄( 0 ) 为真,后者 𝑄( pred 0 ) 为假,反例立刻出现。

循环也可以藏在两步里:“为了证明父项满足 𝑃,先用父项满足 𝑃 来证明子项满足 𝑃,再由子项推出父项”。两句话合起来仍然没有独立的起点。

2.4.5 [附注] 归纳假设不只限于直接子项 [scope-of-induction-hypotheses]

“只能对直接子项使用”是 [定理] 结构归纳原理(structural induction) 的直接表述,不是一切归纳法的限制。若改用强归纳,先证明某个自然数度量严格下降,就可以对度量更小的任意对象使用 归纳假设 ;对真子项的归纳也可以成立。一般的要求叫良基性(well-foundedness):不存在无限地向更小对象下降的链。

[定义] 直接子项与真子项 的直接子项关系在 [定义] 项的集合 的有限树上是良基的,但“归约后的项”并不因此自动是直接子项。例如按 [定义] 一步归约 ,succ ( pred 0 ) 一次计算变成 succ 0,后者不是前者的直接子项。要在证明中对归约结果使用 归纳假设 ,必须另证合适的度量下降,或选择相应的归纳原理。用在一个不被当前归纳原理许可的对象上,证明就是缺了一步;若这个缺口依赖当前结论来填,就构成循环论证。

下图是一个例子。要证 𝑃 对整棵树成立,只需要:叶子处直接验证(第 1 条),每个内部节点处假定以它的各个孩子为根的子项都满足 𝑃,再推出以当前节点为根的项满足 𝑃(第 2、3 条)。这里 𝑃 是项的性质,不是单个节点标签的性质。箭头表示“由孩子所在的子树推出父节点所在的子树”,信息自下而上流动:

2.4.6 [注记] “最小 + 归纳”的历史 [history-of-least-sets-and-induction]

“先把东西定义成满足某些规则的最小对象,再沿着这些规则归纳”,这个方法在许多理论里反复出现:

  • 自然数:戴德金 1888 年的《数是什么,应该是什么?》把自然数定义为包含 1、对后继封闭的所有集合的交(他称为“链”),由此证明了数学归纳法,而不是把它当作公理。皮亚诺 1889 年的公理系统则把同一件事写成了第五条公理。
  • 递归函数论:克莱尼(Kleene)等人在 1930 年代把“可计算函数”定义为包含基本函数、对复合和递归封闭的最小函数类。
  • 不动点定理:克纳斯特–塔斯基定理(1928 / 1955)说,完备格上的单调函数有最小不动点。 [定义] 项的集合 的“最小集合”正是“由封闭条件确定的函数”的最小不动点,这是归纳定义最一般的数学基础。
  • 形式语言:BNF 定义的语言,就是满足产生式的最小字符串集合。
  • 类型论:马丁-洛夫(Martin-Löf)的直觉主义类型论(1970 年代)把“归纳类型”作为基本构造:一个类型由它的构造子给出,并自动附带一个归纳原理。Coq、Lean、Agda 里的 Inductive Inductive / inductive inductive / data data 就是这个想法的直接实现,Rust 的 enum enum 也是它的一个简化版。
  • 程序语义:普洛特金(Plotkin)1981 年的结构化操作语义讲义,把“程序怎么运行”写成推导规则定义的最小关系, [定义] 一步归约 使用的就是这种做法。

这些理论研究的对象虽然各不相同,用的方法是同一个。

2.4.7 [约定] 归纳证明的写法 [writing-inductive-proofs]

写“对 𝑡 结构归纳”时,意思是套用 [定理] 结构归纳原理(structural induction) :按项的形状逐个情形讨论,每个情形里可以对 [定义] 直接子项与真子项 所列的直接子项使用 [定义] 归纳假设 中说明的假设。采用 [定理] 对推导归纳 对推导归纳时,更小对象则是规则前提对应的子推导,不是任意一个看起来有关的项。

先拿一个简单的性质练手。下面两个函数分别数一棵树有多少个节点、有多深:

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

对 [定义] 项的集合 的有限语法树,大小 size( 𝑡 ) 数节点,深度 depth( 𝑡 ) 数从根到最远叶子的路径上的边。函数按 [定义] 直接子项与真子项 的直接子项递归定义。

size( true  )=size( false  )=size( 0 )=1size( succ 𝑡 1 )=size( pred 𝑡 1 )=size( iszero 𝑡 1 )=size( 𝑡 1 )+1size( if 𝑡 1 then 𝑡 2 else 𝑡 3 )=size( 𝑡 1 )+size( 𝑡 2 )+size( 𝑡 3 )+1

深度数的是从根到最远叶子的路径上的边数,不是节点数。完整定义为:

depth( true  )=depth( false  )=depth( 0 )=0depth( succ 𝑡 1 )=depth( pred 𝑡 1 )=depth( iszero 𝑡 1 )=depth( 𝑡 1 )+1depth( if 𝑡 1 then 𝑡 2 else 𝑡 3 )=max( depth( 𝑡 1 ),depth( 𝑡 2 ),depth( 𝑡 3 ) )+1

例如 pred ( succ 0 ) 有三个节点、两条边,所以大小是 3,深度是 2。if  true  then 0 else ( succ 0 ) 的大小是 5,深度也是 2:大小把三个分支都算进去,深度只取最长的那条路。

这里又有一个隐含前提:按项的形状逐个情形写等式,真的定义出了一个函数吗?答案是肯定的,因为每个项恰好属于一种形状( [约定] 抽象语法 已经保证没有歧义),而右边只用到直接子项的函数值,子项又更小,一路往下总会落到常量上。这种定义方式称为结构递归(structural recursion),它和结构归纳是同一枚硬币的两面:结构归纳沿着项的构造过程证明性质,结构递归则沿着同样的结构定义函数。(熟悉 Haskell 的读者应该能认出,这正是许多基于代数数据类型(algebraic data type)和模式匹配(pattern matching)的递归函数所采用的方式;当然Haskell也允许非结构递归甚至利用惰性求值定义的无限结构,这属于共递归(corecursion)的典型形式,而不是通常意义上对有限归纳数据的结构递归。)

接下来这条引理检查“大小”和“深度”两个定义是否协调:最长路径用到的节点不会超过整棵树的节点数。它也给递归的资源估计一个上界,例如遍历语法树时,递归栈深度不超过节点总数。没有这个证明,这只是直觉;如果误把 if 的深度写成三个分支深度之和加一,就不再是在数最长路径了。例如三个分支各有深度 1 时,这种错误写法给出 4,但实际最长路径只有 2 条边。要证明递归终止,还需另查每次调用的参数是否严格变小,不能只引用一个数值上界。

2.4.9 [引理] 深度小于大小 [depth-is-less-than-size]

对 [定义] 项的集合 中的所有有限项 𝑡,采用 [定义] 大小与深度 的函数定义,有 depth( 𝑡 )<size( 𝑡 )。

证明

按 [定理] 结构归纳原理(structural induction) 对 𝑡 进行结构归纳。

  • 𝑡 是常量:depth( 𝑡 )=0<1=size( 𝑡 )。
  • 𝑡= succ 𝑡 1(pred、iszero 完全相同): 归纳假设 是 depth( 𝑡 1 )<size( 𝑡 1 ),两边加一即得 depth( 𝑡 )<size( 𝑡 )。
  • 𝑡= if 𝑡 1 then 𝑡 2 else 𝑡 3:设 𝑡 𝑖 是三个子项中深度最大的那个。由 归纳假设 ,

    depth( 𝑡 )=depth( 𝑡 𝑖 )+1<size( 𝑡 𝑖 )+1≤size( 𝑡 1 )+size( 𝑡 2 )+size( 𝑡 3 )+1=size( 𝑡 )

    其中 ≤ 用到了每个子项的大小都至少为 1。
∎

形式化证明大致就是这个样子:按定义的情形逐个过,每个情形里只用定义和 归纳假设 。

2.4.10 [注记] 潜无穷与实无穷 [potential-and-actual-infinity]

亚里士多德(Aristotle)区分过两种无穷:潜无穷(potential infinity)是一个可以无限进行下去的过程,比如数数总能再数一个;实无穷(actual infinity)是一个已经完成的无穷整体。项的集合 两种读法都可以:按 [定义] 项的集合 的写法,它是一个现成的无穷集合(实无穷);按“用规则在有限步内搭出来”的读法,每个项都是一个有限过程的产物(潜无穷)。

[定理] 结构归纳原理(structural induction) 的巧妙之处在于,它让我们对一个无穷集合下结论,却只需检查有限多条规则。这也是数学基础里构造主义者(constructivist)认可归纳定义的原因:每个对象都有一个有限的构造历史。

2.5 练习:语法
本节练习(5题)

练习2.1:把项还原成树

画出 if ( iszero ( pred 0 ) ) then ( succ ( succ 0 ) ) else ( pred ( succ 0 ) ) 的树,标出根、叶子与内部节点,列出直接子项,计算大小和深度。0 是否是整项的直接子项?依据 [定义] 树的基本术语 、 [定义] 直接子项与真子项 与 [定义] 大小与深度 作答。
提示根是 if;三个直接子项要按条件、then、else 的顺序列出。大小数节点,深度数最长路径上的边。
参考答案
if
├── iszero
│   └── pred
│       └── 0
├── succ
│   └── succ
│       └── 0
└── pred
    └── succ
        └── 0
if
├── iszero
│   └── pred
│       └── 0
├── succ
│   └── succ
│       └── 0
└── pred
    └── succ
        └── 0

三个直接子项依次是 iszero ( pred 0 )、succ ( succ 0 )、pred ( succ 0 ),大小都为 3,深度都为 2。所以整项大小为 1+3+3+3=10,深度为 1+max( 2,2,2 )=3。

三个标着 0 的节点都没有孩子,都是叶子;根 if 和其余六个一元节点都是内部节点。0 是整项的真子项,但不是直接子项。不能因为三个叶子的标签相同,就把它们合并成同一个位置。

练习2.2:语法合法,不等于有意义

判断下面四种写法是否描述本文的项;是项的写出构造过程,不是项的说明生成条件缺了什么:succ  false、if 0 then  true  else 0、单独的 pred pred 、字面记号 1 1 。不要用“它运行时会出错”作为不属于项集合的理由。
提示这里仅使用 [定义] 项的集合 的生成条件,不提前使用后面的求值或类型规则。
参考答案

succ  false 是项:先由第一条生成 false,再由第二条包上 succ。if 0 then  true  else 0 也是项:三个常量都由第一条生成,再用第三条组合。

单独的 pred pred 不是项,因为这个构造必须带一个子项。字面记号 1 1 也不在这套抽象语法里;后面会用 succ 0 表示自然数一,但不能未经约定就把 1 1 当成已有的构造。前两个项的语法合法,并不保证运算能正常进行。

练习2.3:封闭与最小各负责什么?

设 ,只比原项集合多一个新叶子。𝑆 是否满足原来的三条封闭条件?若不满足,应当怎样扩充才封闭?扩充后的集合为什么仍不是 [定义] 项的集合 定义的集合?
提示集合里一旦有 null null ,封闭条件对 succ 会提出什么要求?
参考答案

不封闭:null ∈𝑆,但 succ  null ∉𝑆。它既不是原语言的项,也不是单独补进去的 null null 。

要得到含 null null 的封闭集合,必须同时加入所有由原构造子和这个额外叶子在有限步内搭出的树,包括 succ  null、if  null  then 0 else  true 等。这个集合满足原来的封闭条件,却严格大于 。“封闭”要求构造后不能跑到集合外,“最小”则排除规则没有生成的额外东西。

练习2.4:证明边数比节点数少一

令 𝐸( 𝑡 ) 表示项 𝑡 的语法树中的边数。写出 𝐸 的完整结构递归定义,并用 [定理] 结构归纳原理(structural induction) 证明 𝐸( 𝑡 )=size( 𝑡 )−1。每个归纳步骤都要写清 归纳假设 用在哪个子项上。
提示常量有零条边;一元构造增加一条边;if 根连向三个孩子,增加三条边。
参考答案

完整的结构递归定义为:

𝐸( true  )=𝐸(  false  )=𝐸( 0 )=0𝐸( succ 𝑡 1 )=𝐸( pred 𝑡 1 )=𝐸( iszero 𝑡 1 )=𝐸( 𝑡 1 )+1𝐸( if 𝑡 1 then 𝑡 2 else 𝑡 3 )=𝐸( 𝑡 1 )+𝐸( 𝑡 2 )+𝐸( 𝑡 3 )+3

对 𝑡 结构归纳。常量有 𝐸( 𝑡 )=0=1−1。一元情形由子项上的 归纳假设 得

𝐸( succ 𝑡 1 )=𝐸( 𝑡 1 )+1=size( 𝑡 1 )=size( succ 𝑡 1 )−1

,pred、iszero 的等式相同。

对 if 𝑡 1 then 𝑡 2 else 𝑡 3,三个子项上的 归纳假设 分别是 𝐸( 𝑡 𝑖 )=size( 𝑡 𝑖 )−1。三棵子树的边加上根的三条边,得到

𝐸( 𝑡 )=𝐸( 𝑡 1 )+𝐸( 𝑡 2 )+𝐸( 𝑡 3 )+3=( size( 𝑡 1 )−1 )+( size( 𝑡 2 )−1 )+( size( 𝑡 3 )−1 )+3=size( 𝑡 1 )+size( 𝑡 2 )+size( 𝑡 3 )=size( 𝑡 )−1

。 全部构造情形都覆盖了,因此结论对所有项成立。∎

练习2.5:两种不同的错误“归纳证明”

令 𝑃( 𝑡 ) 为“size( 𝑡 )≤3”。有人分别写出两段归纳步骤:“假定 𝑃( succ 𝑡 1 ),所以 𝑃( succ 𝑡 1 )”;“假定 𝑃( 𝑡 1 ),大小加一仍不超过 3,所以 𝑃( succ 𝑡 1 )”。它们各错在哪里?给出具体反例,并说明为什么第二段不能简单称为“假设用错对象”。
提示第一段用了哪个对象上的假设?第二段虽然取了子项,还要检查不等式是否真的能传给父项。
参考答案

第一段是循环论证:它假定的是当前待证的 𝑃( succ 𝑡 1 ),不是结构归纳许可的 𝑃( 𝑡 1 )。它只证明“结论蕴涵自身”。

第二段使用子项上的假设本身合法,但推理错误:size( 𝑡 1 )≤3 只能给出 size( succ 𝑡 1 )≤4,不能给出 ≤3。取 𝑡 1= succ ( succ 0 ),子项大小为 3,包上一层后为 4。所以 𝑃 是假命题,不是把措辞修好就能证明的。if  true  then 0 else 0 也是大小为 4 的反例。

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

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

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

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

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

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

3.2 值

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

3.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.3 推导规则怎么读

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

3.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.3.2 [约定] 元变量 [metavariables]

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

3.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.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.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.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.3.7 [例] 一棵推导 [reduction-derivation]

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

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

3.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 要求 会说明为什么这一点很重要。

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

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

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

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

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

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

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

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

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

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

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

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

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

证明

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

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

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

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

4.5 从关系到函数

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

4.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,                                  // 值不可归约
    }
}

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

4.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 。

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

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

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

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

采用 [定义] 一步归约 的关系 ⟶。若不存在 𝑡 ′ 使 𝑡⟶𝑡 ′,称 𝑡 是范式(normal form)。按 [定义] 值与数值 判断,不是值的范式称为受阻的(stuck)。

“范式”这个词的意思是“标准的、最终的形式”:一个项归约到不能再归约,就到了它的范式,好比把 ( 1+2 )×3 化简到 9 就化简不下去了。注意范式是按能不能归约定义的,和项“好不好”无关。范式分成两种:

  • 值:算完了,而且是一个合法的结果。由 [引理] 值不可归约 ,值都是范式。例如 true、succ 0。
  • 非值(受阻的项):不能再归约,但也不是值,计算停在了一个没有意义的地方。例如:

    • succ  true:唯一可能的规则 E-Succ 要求 true 能归约,而它不能;它又不是数值,所以不是值;
    • if 0 then  true  else  false:E-IfTrue、E-IfFalse 要求条件是 true 或 false,E-If 要求 0 能归约,三条都用不上;
    • iszero  false:E-IsZeroZero、E-IsZeroSucc 要求参数是数值,E-IsZero 要求 false 能归约,都不行。

5.2 [附注] 为什么译作“受阻” [translation-of-stuck]

stuck 字面是“卡住”,TAPL 的一些中文译本也这样译。 [定义] 范式与受阻 采用“受阻”这个译名,理由有两点。第一,“卡住”在日常语言里也指程序“卡死、没反应”,即无限循环或死锁,而 stuck 恰恰不是这个意思:受阻的项已经停下来了,只是停错了地方。第二,“受阻”点出了停下来的原因:计算想往前推进,却被一个没有定义的情形挡住了。例如按 [定义] 一步归约 的规则,succ  true 既不是值,也没有可用的归约步骤。英文原名始终写在括号里,读文献时对得上即可。

受阻对应真实语言里的错误(error):程序要做一件语义里没有定义的事,比如把布尔值加一,目前我们只能把这个错误拖到运行时,也就是运行时错误(runtime error)。下一节的类型系统要做的,就是在不运行程序的前提下,提前把会走到受阻状态的程序找出来,可以让它们成为编译期错误(compiletime error)。

5.3 多步归约

一步归约只描述“一次改写”。程序真正运行时要连续改写很多次,所以需要把若干步串起来。

5.3.1 [定义] 多步归约 [multi-step-reduction]

以 [定义] 一步归约 的关系 ⟶ 为基础,𝑡⟶ ∗𝑡 ′ 读作“𝑡 多步归约到 𝑡 ′”,表示从 𝑡 出发经过零步或有限多步一步归约到达 𝑡 ′。精确地说,它是对下面两条规则封闭的最小关系:

M-Refl 说“零步归约”永远成立(自反),M-Step 说在一步后面接上若干步还是若干步。由于取的是最小关系,𝑡⟶ ∗𝑡 ′ 成立当且仅当存在一条有限的链 𝑡=𝑡 0⟶𝑡 1⟶⋯⟶𝑡 𝑛=𝑡 ′(𝑛≥0)。⟶ ∗ 上角的星号来自正则表达式里的 Kleene 星号,意思是“重复零次或多次”,数学上称 ⟶ ∗ 为 ⟶ 的自反传递闭包(reflexive transitive closure)。

5.3.2 [例] 多步归约到值 [multi-step-reduction-to-a-value]

采用 [定义] 一步归约 的十条规则,⟶ ∗ 表示 [定义] 多步归约 的零步或有限多步归约;终点是否为值按 [定义] 值与数值 判断。

 if ( iszero ( pred ( succ 0 ) ) ) then  succ 0 else 0⟶ if ( iszero 0 ) then  succ 0 else 0 E-If + E-IsZero + E-PredSucc ⟶ if  true  then  succ 0 else 0 E-If + E-IsZeroZero ⟶ succ 0 E-IfTrue

每行右边列出了这一步推导用到的规则,从外层的同余规则写到最里层的计算规则。三步之后到达值 succ 0,所以

if ( iszero ( pred ( succ 0 ) ) ) then  succ 0 else 0⟶ ∗ succ 0

由 [定理] 确定性 ,每一步都别无选择,所以这条链是唯一的。⟶ ∗ 也包括中间每一站:这个项也多步归约到第二行、第三行的项,以及它自己(零步)。

5.3.3 [例] 归约到受阻 [reduction-to-a-stuck-term]

按 [定义] 一步归约 的规则,取 𝑡= succ ( if  true  then  false  else 0 )。是否为值采用 [定义] 值与数值 ,是否受阻采用 [定义] 范式与受阻 。

第一步。𝑡 的最外层是 succ。结论左边形如 succ … 的规则只有 E-Succ,它要求里面的 if  true  then  false  else 0 能归约。这个 if 的条件是 true,E-IfTrue 适用,得到 false。于是推导是:

第二步。现在的项是 succ  false。仍然只有 E-Succ 可能适用,它要求 false ⟶𝑡 1 ′ 对某个 𝑡 1 ′ 成立。但 false 是值,由 [引理] 值不可归约 不能归约,于是 E-Succ 的前提搭不上,没有任何规则可用:succ  false 是范式。它又不是值(succ 后面必须是数值),所以它受阻了。

第一步完全合法,问题出在第二步。所以一个项“现在还能归约”并不说明它“永远不会出错”。

5.4 练习:求值
本节练习(5题)

练习3.1:值、范式与受阻别混在一起

对下列五个项分别判断:是否为值、是否为范式、是否受阻。能够归约的,还要给出下一项和规则名:succ ( succ 0 )、pred ( succ 0 )、succ  true、if  true  then 0 else ( succ  true )、if 0 then 0 else 0。
提示先用 [定义] 值与数值 判断值,再问是否存在一步归约;“范式”与“受阻”按 [定义] 范式与受阻 判断。
参考答案
  • succ ( succ 0 ) 是数值,也是值和范式,不受阻。
  • pred ( succ 0 ) 不是值,也不是范式:E-PredSucc 给出后继 0,所以现在不受阻。
  • succ  true 不是值,是受阻的范式:唯一候选 E-Succ 的前提无法成立。
  • if  true  then 0 else ( succ  true ) 不是值,也不是范式;E-IfTrue 直接给出 0。未选中的坏分支不参与求值。
  • if 0 then 0 else 0 不是值,是受阻的范式:条件不是布尔值,也不能归约。

因此“不是值”不等于“受阻”,“有坏的子项”也不等于“现在受阻”。

练习3.2:一条链与第一步的推导树

写出 pred ( if ( iszero ( pred 0 ) ) then  succ ( succ 0 ) else 0 ) 到范式的完整归约链,标明每一步用到的规则,并画出第一步的推导树。使用 [定义] 一步归约 ,不要把两次计算并成一步。
提示第一步不是消去最外层 pred,而是经由三条同余规则进入条件中的 pred 0。
参考答案

 pred ( if ( iszero ( pred 0 ) ) then  succ ( succ 0 ) else 0 )⟶ pred ( if ( iszero 0 ) then  succ ( succ 0 ) else 0 )⟶ pred ( if  true  then  succ ( succ 0 ) else 0 )⟶ pred ( succ ( succ 0 ) )⟶ succ 0

四步所用的规则依次是 E-Pred + E-If + E-IsZero + E-PredZero,E-Pred + E-If + E-IsZeroZero,E-Pred + E-IfTrue,E-PredSucc。第一步的完整推导是:

最后 succ 0 是值,没有后继。多步归约还包括链的中间各站与起点自身,不只是最终结果。

练习3.3:扩展:函数选一条路,不等于关系只有一条路

只把 E-PredSucc 的 放宽为任意项,其余规则不变。为 pred ( succ ( iszero 0 ) ) 找出两个不同的一步后继,并分别继续归约。这个例子证明了什么、没有证明什么?如果 step step 用 match match 只返回其中一个,能否据此声称扩展关系仍确定?
提示使用 [附注] 为什么 E-PredSucc 要求 的放宽规则时,可以消去外层,也可以先在 iszero 0 里计算。
参考答案

把 E-PredSucc 改成允许任意 𝑡 的 pred ( succ 𝑡 )⟶𝑡 后,取 𝑠= pred ( succ ( iszero 0 ) )。直接消去外层得到 𝑠⟶ iszero 0;先用同余规则进入内部,得到 𝑠⟶ pred ( succ  true )。

两个后继不同,所以一步关系不确定。第一条路线再用 E-IsZeroZero 到 true,第二条路线再用放宽的 E-PredSucc 到 true;因此这个分叉可以汇合。但一个可汇合的例子不证明整套扩展关系合流。

Rust 函数即使按分支优先级选出唯一返回值,也没有消除关系的另一条合法路线。要么实现“所有后继”的接口,要么明确说这个单后继函数实现的是选定策略限制后的关系。注意扩展规则下 pred ( succ  true ) 能归约,不能照搬它在原语言中受阻的判断。

练习3.4:换一个相等三角形

对 "" "" 、 0 0 、 "0" "0" 两两计算 == == 与 === === 的结果,画出另一个相等三角形。再按“新值与任何已保留值满足 == == 就丢弃”的算法,分别去重 ["", 0, "0"] ["", 0, "0"] 与 [0, "", "0"] [0, "", "0"] 。它们为什么连保留下来的数量都可能不同?
提示比较空字符串 "" "" 、数字 0 0 和字符串 "0" "0" 。同类字符串的 == == 不会再把两边都转成数字。
参考答案

"" == 0 "" == 0 和 0 == "0" 0 == "0" 为 true true , "" == "0" "" == "0" 为 false false ;三组 === === 都为 false false 。前两次把字符串转为数字,第三次只比较两个不同的字符串。

使用 [附注] 相等关系的“地狱”给语言设计什么教训 的自定义 == == 去重算法,输入 ["", 0, "0"] ["", 0, "0"] 留下 ["", "0"] ["", "0"] ,输入 [0, "", "0"] [0, "", "0"] 只留下 [0] [0] 。规则明确仍可能失去算法所依赖的传递性;改用 === === 后这三种输入彼此不同,不会在这个例子中被合并,但仍要留意对象身份和 NaN NaN 的特殊处理。

练习3.5:扩展:错误只沿实际求值的位置传播

比较原语言与显式错误扩展对 if  true  then 0 else ( succ  true ) 和 pred ( succ  false ) 的处理,写出每一步。最后说明 wrong、Kotlin 的异常对象与 ⊥ 为什么不能当作同一个东西。
提示使用 [附注] 显式错误规则与 Kotlin 的底类型 的错误扩展;不要把未选中的分支当作正在求值的操作数。
参考答案

对 if  true  then 0 else ( succ  true ),原语言与错误扩展都用 E-IfTrue 直接得到 0,不计算 else 分支。

原语言中的 pred ( succ  false ) 没有后继,是受阻的范式。错误扩展先在内部用 E-OpBool,并由 E-Pred 提升到整项,得到 pred  wrong;再用 E-OpWrong 到 wrong。它是明确的异常终点,不是普通数值。

wrong 是错误项, IllegalArgumentException IllegalArgumentException 是异常对象,⊥ 是底类型。Kotlin 的抛异常表达式可以具有 Nothing Nothing 类型,不意味着异常对象是底类型的值,也不意味着未选中的异常分支必须先执行。

6 [] 类型:语法导向、反演与唯一性 [typing]
6.1 类型判断

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

设 𝑡 是 [定义] 项的集合 中的项。类型(type)只有两种:𝑇⩴ Bool | Nat,分别对应布尔值和自然数。类型判断(typing judgment)𝑡:𝑇 读作“𝑡 具有类型 𝑇”,它是对下列七条规则封闭的最小关系。

横线上的判断是前提,横线下的是结论;没有前提的规则直接给出结论。𝑇、𝑡 1 等符号按 [约定] 元变量 代换,T-If 两个分支里的 𝑇 必须代换成同一个类型。

6.1.2 [定义] 良类型 [well-typed-terms]

对 [定义] 类型与类型判断 的类型关系,若存在某个类型 𝑇,使得 𝑡:𝑇 有一棵由这些规则搭成的有限推导,就称项 𝑡 是良类型的(well-typed);否则称 𝑡 是不良类型的(ill-typed)。

这个定义有三处值得逐一拆开:

  • “存在某个 𝑇”:良类型只要求有一个类型,不要求事先指定是哪一个。0 是良类型的,因为 0: Nat;它不是 Bool 并不影响这一点。
  • “有一棵推导”:和 ⟶ 一样,𝑡:𝑇 成立当且仅当能用 [定义] 类型与类型判断 中七条规则的实例搭出一棵以它为根的有限树。所以说一个项良类型,就是说能写出这样一棵树;说它不良类型,就是说无论怎样搭都搭不出来。后者是一个关于所有可能推导的否定命题,不能仅凭尝试构造某棵推导树失败就断言它不良类型。要证明它不良类型,我们需要说明任何合法的推导都不可能成立。在这里,可以通过分析类型规则以及它们所要求的前提来完成证明,下面的例子会演示。
  • 良类型是一个静态性质:在我们目前讨论的语言中, [定义] 类型与类型判断 中的类型判断只依赖项的结构与类型规则,不需要执行 [定义] 一步归约 中的 ⟶ 归约。换句话说,我们不必运行一个项,就能判断它是否良类型。这正是静态类型检查(static type checking)的基础(与动态检查的对照见 [附注] 静态类型与动态类型 )。不过,类型判断与求值虽然是两套不同的规则,它们之间是否协调一致,还需要通过 [推论] 类型安全 的类型安全性定理来保证。

读法和 [定义] 一步归约 完全一样:横线上是前提,横线下是结论,𝑇、𝑡 1 是元变量( [约定] 元变量 )。所以“对类型推导归纳”也照样成立,证明与 [定理] 对推导归纳 相同,不再重复。

6.1.3 [例] 良类型与不良类型 [well-typed-and-ill-typed-terms]

使用 [定义] 类型与类型判断 的七条类型规则,良类型与不良类型的含义见 [定义] 良类型 。

良类型的例子。要说明 if ( iszero 0 ) then  succ 0 else 0: Nat,从结论往上搭推导:

  1. 项的最外层是 if,结论形如 if …:𝑇 的规则只有 T-If。取 𝑇= Nat,它要求三个前提:iszero 0: Bool、succ 0: Nat、0: Nat。
  2. 第一个前提 iszero 0: Bool:只有 T-IsZero 的结论形如 iszero …,结论类型恰好是 Bool,它要求 0: Nat。T-Zero 给出这一点。
  3. 第二个前提 succ 0: Nat:用 T-Succ,它要求 0: Nat,再用 T-Zero。
  4. 第三个前提 0: Nat:直接用 T-Zero。

每个前提都搭上了,合起来就是一棵完整的推导:

不良类型的例子。要说明 succ  true 没有类型,得证明对任何 𝑇 都搭不出 succ  true :𝑇 的推导。我们可以直接穷举七条规则:

  1. 结论左边形如 succ … 的规则只有 T-Succ,其余六条的结论形状对不上,直接排除。所以如果有推导,最后一步只能是 T-Succ,此时 𝑇= Nat,前提是 true : Nat。
  2. 再看 true : Nat。结论左边是 true 的规则只有 T-True,而它的结论是 true : Bool,类型是 Bool 不是 Nat,搭不上。没有别的规则可选。

唯一可能作为最后一步的 T-Succ 无法满足它的前提,因此不存在合法的推导树,succ  true 是不良类型的。

6.1.4 [附注] 不完整规格中的类型检查问题 [answers-to-incomplete-specification-questions]

采用 [定义] 类型与类型判断 的七条规则, [例] 不完整的规格 中的三个类型检查问题可以得到明确回答:

  1. T-If 的三个前提都必须成立,所以 else 分支即使永远不执行也要检查。
  2. 两个分支的类型用的是同一个元变量 𝑇,所以“类型相同”就是字面相同,没有自动转换。
  3. 检查器拒绝 𝑡,意思是不存在 𝑡:𝑇 的推导。具体是哪条规则的哪个前提搭不上,可以从搭建推导的过程中读出来,方法见 [附注] 从失败的推导里读出错误位置 。

6.1.5 [附注] 从失败的推导里读出错误位置 [locating-errors-in-failed-typing-derivations]

为 [定义] 类型与类型判断 中的类型判断寻找推导时,从要证的结论出发,选一条结论形状对得上的规则,再去搭它的每个前提。这七条规则是语法导向的,每种项形状只对应一条规则,见 [引理] 反演 ,所以每一步都只有一条规则可选,整个过程是一条确定的路径。路径断掉的地方,就是错误所在,它由三样东西确定:

  • 位置:断在哪个子项上。搭推导时每进入一个前提,就进入了项的一个直接子项,所以断点对应语法树上的一个节点。
  • 规则与前提:断点上方最后一次成功选用的规则,以及它的哪一个前提搭不上。
  • 期望与实际:那个前提要求的类型,和子项实际能得到的类型。

以 if  true  then 0 else ( iszero 0 ) 为例。最外层选 T-If;第一个前提 true : Bool 由 T-True 搭上;第二个前提要求 0:𝑇,T-Zero 给出 𝑇= Nat;第三个前提于是要求 iszero 0: Nat,但 T-IsZero 的结论类型是 Bool。断点因此是:T-If 的第三个前提,子项 iszero 0,期望 Nat,实际 Bool。编译器的报错信息“expected Nat Nat , found Bool Bool ”,内容就是这三样东西。

Rust 检查器 type_of type_of 在失败时只返回 None None ,把这些信息丢掉了。要保留它们,只需把返回类型从 Option<Type> Option<Type> 换成 Result<Type, Error> Result<Type, Error> ,在每个 ? ? 和比较失败处记下当时的规则、子项和两个类型。

6.1.6 [附注] 类型系统是保守的 [conservative-type-systems]

按 [定义] 一步归约 的 E-IfTrue,if  true  then 0 else  false 运行一步就得到值 0,不会受阻;但 [定义] 类型与类型判断 无法给它类型,因为 T-If 要求两个分支类型相同。这不是 bug。类型系统不运行程序,只看形状,它只能做近似。 [推论] 类型安全 只保证一个方向:良类型的程序不受阻;它没有保证“不受阻的程序都良类型”。

6.2 反演:从结论倒推前提

先看这组规则的结论。每条规则的结论都形如“某个项 : 某个类型”,结论左边的项有一个最外层构造子(true、if、succ 等),这就是它的形状。把七条规则按结论左边的形状列出来:

项的形状结论里出现这个形状的规则条数
trueT-True1
falseT-False1
0T-Zero1
if 𝑡 1 then 𝑡 2 else 𝑡 3T-If1
succ 𝑡 1T-Succ1
pred 𝑡 1T-Pred1
iszero 𝑡 1T-IsZero1

“每种项的形状恰好出现在一条规则的结论里”指的就是这张表:第三列全是 1。既不是 0(否则那种形状的项永远没有类型),也不大于 1(否则同一个项可能有好几种定型方式)。这样的规则称为语法导向的(syntax-directed):项的语法形状直接指定了该用哪条规则,不需要猜,也不需要回溯。

作为对照,下面两种情形都不是语法导向的:

  • 如果再加一条规则 𝑡: Nat 𝑡: Bool(“自然数可以当布尔值用”),它的结论左边是任意项 𝑡,不看形状。这时 0: Bool 可以由它推出,0 这一行就有了两条规则可选。
  • 如果把 T-If 拆成两条,一条要求条件是 true,一条要求条件是 false,那么 if ( iszero 0 )… 这种条件不是常量的项就一条规则都没有,这一行变成 0。

语法导向带来一个很方便的推理方式:知道了 𝑡:𝑇 成立,就能断定推导的最后一步用的是哪条规则,于是那条规则的前提也都成立。例如,知道 succ 𝑡 1:𝑇 成立,查表可知最后一步必然是 T-Succ,于是立刻得到 𝑇= Nat 且 𝑡 1: Nat,不用知道推导的其余细节。 [例] 良类型与不良类型 里的两个例子也是这样做的:每一步都只有一条规则可选。

6.2.1 [引理] 反演 [typing-inversion]

以下结论只针对 [定义] 类型与类型判断 的七条类型规则;𝑡:𝑇 表示能由这些规则搭出有限推导,Bool、Nat 分别是布尔类型与自然数类型。

  1. 若 true :𝑇 或 false :𝑇,则 𝑇= Bool。若 0:𝑇,则 𝑇= Nat。
  2. 若 if 𝑡 1 then 𝑡 2 else 𝑡 3:𝑇,则 𝑡 1: Bool,𝑡 2:𝑇,𝑡 3:𝑇。
  3. 若 succ 𝑡 1:𝑇 或 pred 𝑡 1:𝑇,则 𝑇= Nat 且 𝑡 1: Nat。
  4. 若 iszero 𝑡 1:𝑇,则 𝑇= Bool 且 𝑡 1: Nat。
证明
每一条都看推导的最后一步。以第 3 条中的 succ 为例:七条规则里,结论左边形如 succ 𝑡 1 的只有 T-Succ。所以推导的最后一步就是 T-Succ,它的结论类型是 Nat,前提是 𝑡 1: Nat。其余各条同理,每次都只有一条规则可能适用。
∎

反演看起来平淡无奇,却是后面所有证明的发动机。它把“存在一棵推导”这种不知道细节的事实,拆成关于子项的具体信息。保型证明例如从 开始,必须先知道 𝑇= Nat、,再继续取出 ,才能证明删去外层构造以后类型还在。没有反演,就不能凭外观擅自断言子项有哪些类型;增加子类型规则时这个推理尤其需要重证,下面的附注会给出原因。

6.2.2 [附注] 反演依赖语法导向 [inversion-and-syntax-directed-rules]

[引理] 反演 的逐形状证明依赖 [定义] 类型与类型判断 的语法导向性:每种项形状只对应一条规则。如果将来加一条不看项形状的规则(例如子类型里常见的“若 𝑡:𝑆 且 𝑆 是 𝑇 的子类型,则 𝑡:𝑇”),那么 succ 𝑡 1:𝑇 的最后一步就可能是这条新规则,“最后一步必然是 T-Succ”的推理就断了。到那时反演需要重新陈述、重新证明。

语法导向并不单凭“每种形状只有一条规则”就让输出类型自动唯一:T-If 的 𝑇 还要从分支中确定。下面的定理补上这个保证,说明 type_of type_of 可以忠实地返回一个类型,而不必返回类型集合。没有唯一性,单个返回值可能只挑出了众多类型之一。例如带子类型的 Kotlin 中,一个 Int Int 表达式也可以出现在需要 Number Number 或 Any Any 的位置;那样的系统通常要另外区分推断出的类型和它能被接受为的所有类型,不能照搬本文的结论。

6.2.3 [定理] 类型唯一 [uniqueness-of-types]

对 [定义] 类型与类型判断 的七条类型规则,若 𝑡:𝑇 且 𝑡:𝑇 ′,则 𝑇=𝑇 ′。

证明

对 𝑡 结构归纳( [约定] 归纳证明的写法 ),对 𝑇、𝑇 ′ 一般化。

  • 常量、succ 𝑡 1、pred 𝑡 1、iszero 𝑡 1:由 [引理] 反演 ,类型被项的形状直接定死(Bool 或 Nat),所以 𝑇=𝑇 ′。
  • if 𝑡 1 then 𝑡 2 else 𝑡 3:由 [引理] 反演 第 2 条,𝑡 2:𝑇 且 𝑡 2:𝑇 ′。𝑡 2 是直接子项,由 归纳假设 𝑇=𝑇 ′。
∎

6.3 练习:类型
本节练习(4题)

练习4.1:从结论搭起一棵类型推导

为 if ( iszero ( pred ( succ 0 ) ) ) then  succ 0 else  pred 0 写出完整的类型推导树,并指出推导中有没有哪一步需要先运行这个项。使用 [定义] 类型与类型判断 。
提示最外层用 T-If;条件从 T-IsZero 开始,两个分支都应得到 Nat。
参考答案

每个叶子都是 T-Zero,两个分支具有同一个类型 Nat。定型过程中没有执行任何 ⟶ 归约;条件最后会变成什么,不是搭这棵推导的前提。

练习4.2:证明没有类型,而不是只说“检查失败”

证明以下三项对任何 𝑇 都不能得到 𝑡:𝑇:succ ( if  true  then 0 else  false )、if ( pred 0 ) then 0 else 0、iszero ( iszero 0 )。每项给出无法满足的具体前提;其中有没有一项实际能归约到值?
提示先用 [引理] 反演 固定最后一条规则,再追到无法成立的前提。
参考答案
  • succ ( if  true  then 0 else  false ) 若有类型,最后只能用 T-Succ,要求内部 if 为 Nat。T-If 随即要求 false : Nat,与 T-False 的结论不符。
  • if ( pred 0 ) then 0 else 0 若有类型,T-If 要求条件为 Bool,但 pred 0 的类型只能是 Nat。
  • iszero ( iszero 0 ) 若有类型,外层 T-IsZero 要求内层为 Nat,但内层 T-IsZero 的结论只能是 Bool。

这三种分析排除了所有可能的最后规则,所以是不存在推导的论证。第一个项却可以归约到 succ 0:实际没有执行坏分支。这再次说明“不良类型”并不等于“这次运行必定出错”。

练习4.3:反演不需要看见整棵推导

设 𝑐、𝑡 1、𝑢 是任意项。只知道 if 𝑐 then ( pred ( succ 𝑡 1 ) ) else 𝑢:𝑇,却没有拿到它的推导树。你仍能确定 𝑇、𝑐、𝑡 1、𝑢 的哪些类型信息?逐次标明使用 [引理] 反演 的哪一条。
提示先反演 T-If,再连续反演 T-Pred 和 T-Succ;整项的 𝑇 会被其中一个分支确定。
参考答案

T-If 给出 𝑐: Bool、pred ( succ 𝑡 1 ):𝑇、𝑢:𝑇。T-Pred 给出 𝑇= Nat 以及 succ 𝑡 1: Nat;T-Succ 再给出 𝑡 1: Nat。代回得到 𝑢: Nat。

因而一定有 𝑇= Nat、𝑐: Bool、𝑡 1: Nat、𝑢: Nat。这只提取已有推导必须满足的前提,并没有假定 𝑐 或 𝑡 1 已经求值,也不需要猜原推导的其他细节。

练习4.4:扩展:多一条转换规则会动到哪些结论?

只在类型规则中增加 𝑡: Nat 𝑡: Bool,求值规则暂不改动。给出一个具有两个类型的项,指出原来的类型唯一、反演和语法导向分析各在哪一步失效。
提示保留七条旧规则,再添加“由 𝑡: Nat 推出 𝑡: Bool”。首先检查 0。
参考答案

T-Zero 给出 0: Nat,新规则再给出 0: Bool,所以 [定理] 类型唯一 不再成立。 [引理] 反演 中“若 0:𝑇,则 𝑇= Nat”也失效。

更根本地,新规则的结论左边是任意项,不由最外层形状唯一决定。现在 0: Bool 的推导最后一步不是 T-Zero,因此原来的语法导向表和逐形状反演不能照搬。扩展语言有自己的规则,必须重新陈述相应性质,而不是把这看成原定理的反例。

7 [] 类型安全:典范形式、进展与保型 [type-safety]
7.1 先把命题写下来

“良类型的程序不会出错”这句话,米尔纳(Milner)在 1978 年说成 well-typed programs cannot go wrong。现在终于可以把它写成一个精确的命题了。“出错”在这门语言里的意思就是“受阻”( [定义] 范式与受阻 )。

7.1.1 [定义] 类型安全 [type-safety]

采用 [定义] 类型与类型判断 的类型关系、 [定义] 多步归约 的运行关系,以及 [定义] 范式与受阻 的受阻判定。称这套语言规则是类型安全(type safety)的,如果对任意项 𝑡、𝑡 ′ 和类型 𝑇:若 𝑡:𝑇 且 𝑡⟶ ∗𝑡 ′,则 𝑡 ′ 没有受阻。

注意这句话里的量词:对所有良类型的 𝑡,对所有它能多步归约到的 𝑡 ′。这正是 [例] 量词的范围 提醒过的地方,写成符号以后,范围就不会有歧义。

直接证这个命题不好下手,因为 ⟶ ∗ 可以归约任意多步。赖特(Wright)与费莱森(Felleisen)在 1994 年给出的办法,是把它拆成两条只涉及一步的定理:

  • 进展(progress):良类型的项要么是值,要么还能一步归约。它保证“现在不受阻”。
  • 保型(preservation,也叫 subject reduction):良类型的项一步归约,得到的项类型不变。它保证“归约一步以后,进展还能接着用”。

两条缺一不可。 [例] 归约到受阻 里的 succ ( if  true  then  false  else 0 ) 现在能归约,一步归约之后就受阻了。只有进展,挡不住这种情况;只有保型,挡不住一开始就受阻的项。(这个例子本身没有类型,所以它不是两条定理的反例,只是说明为什么两条都需要。)

7.2 典范形式

证明进展时需要知道:某个类型的值长什么样。

7.2.1 [引理] 典范形式(canonical forms) [canonical-forms]

值与数值使用 [定义] 值与数值 的定义,类型关系使用 [定义] 类型与类型判断 。

  1. 若 𝑣 是值且 𝑣: Bool,则 𝑣 是 true 或 false。
  2. 若 𝑣 是值且 𝑣: Nat,则 𝑣 是数值。
证明

由 [定义] 值与数值 ,值只有三种形状:true、false、数值。

  1. 若 𝑣 是数值,它是 0(由 [引理] 反演 第 1 条,类型只能是 Nat)或 (由第 3 条,类型只能是 Nat)。由 [定理] 类型唯一 ,它不可能同时是 Bool。所以类型为 Bool 的值只能是另两种。
  2. 同理,由 [引理] 反演 第 1 条,true、false 的类型只能是 Bool,所以类型为 Nat 的值只能是数值。
∎

这条引理是静态和动态之间的一座桥:左边是类型的信息(不运行就知道的),右边是形状的信息(运行时真正看到的)。进展的证明需要靠它把“条件是 Bool 类型的值”变成“条件确实是 true 或 false”,才能选择 E-IfTrue 或 E-IfFalse。若随意加上 0: Bool 而保持求值规则不变,if 0 then  true  else  false 就会被定型却受阻:静态分类不再保证运算要求的运行时形状。动态语言在运算时做的种类检查,正是在现场检查这类形状条件。

7.2.2 [附注] 静态类型与动态类型 [static-and-dynamic-typing]

“静态”(static)指不运行程序就能知道的东西,“动态”(dynamic)指运行时才能看到的东西。按类型检查发生在什么时候,程序语言大致分成两类:

  • 静态类型语言(statically typed):在运行之前检查类型,通常在编译阶段完成;不通过检查的程序被拒绝。Rust、Haskell、OCaml、Java、C 都属于这一类。 [定义] 类型与类型判断 的类型系统也是:𝑡:𝑇 只看项的形状,不执行 [定义] 一步归约 的 ⟶ 归约。
  • 动态类型语言(dynamically typed):程序先运行,每次真正做运算时再检查参与运算的值是不是合适的种类,不合适就抛出错误。Python、JavaScript、Ruby 属于这一类。

用 [定义] 项的集合 的布尔值/自然数语言作类比,一种动态检查的设计是不预先构造 𝑡:𝑇 的推导,而是在 ⟶ 里给不合适的运行时操作补上报错规则( [附注] 显式错误规则与 Kotlin 的底类型 ),把受阻换成可预期的异常。另一种操作可以采用转换规则( [例] JavaScript 隐式转换与相等三角图 )。真实语言往往同时有这两类处理;JavaScript 的条件还会采用真假值转换,不要求参数字面上就是布尔值。

对这门小语言的运算,类型规则、 [引理] 典范形式(canonical forms) 与 [定理] 保型 合起来保证:参数若被要求为 Nat,求值为值时就一定是数值,所以不必再检查它是否为布尔值。这不等于真实静态类型语言可以省掉所有运行时检查;例如 Kotlin 仍会抛出数组越界异常,Rust 的数组索引也可能需要边界检查。两者的取舍是:静态检查更早排除它所覆盖的错误,但会拒绝一些实际运行不会出错的程序( [附注] 类型系统是保守的 );动态检查更灵活,但错误通常要等到运行到那一行才暴露。

另外,“静态 / 动态”和“强 / 弱”是两个不同的维度。C 是静态类型,但允许随意强制转换指针,类型系统不可靠;Python 是动态类型,但不会把字符串当整数用。 [推论] 类型安全 证明的“类型安全”,指的是这套静态类型系统对受阻错误的可靠性,不是由“强/弱”这一称呼作出的保证。

对这个话题感兴趣的可以看看帝球的类型 vs. 类型检查

7.3 进展

进展回答的是“类型检查通过以后,下一步会不会无规则可用”。没有它,检查器即使给出了类型,也未必挡住 succ  true 这样的错误。具体地,若把 T-Succ 的前提错误地放宽成 𝑡 1: Bool,这个项就会被赋予 Nat,却既不是值也不能归约。进展还必须允许“已经是值”这个分支,否则 0 这种合法结果反而会被误判为失败。对于有异常的真实语言,则要先明确哪些异常是许可的结果,不能把本文的进展原封不动当作“永不抛异常”。

7.3.1 [定理] 进展 [progress]

采用 [定义] 类型与类型判断 的类型关系、 [定义] 值与数值 的值定义,以及 [定义] 一步归约 的归约关系。若 𝑡:𝑇,则 𝑡 是值,或存在 𝑡 ′ 使 𝑡⟶𝑡 ′。

证明

对 𝑡:𝑇 的推导归纳(类型推导版本的 [定理] 对推导归纳 ),按最后一步的规则分情形。

  • T-True、T-False、T-Zero:𝑡 是值。
  • T-If:𝑡= if 𝑡 1 then 𝑡 2 else 𝑡 3,前提里有 𝑡 1: Bool。对 𝑡 1 用 归纳假设 :

  • T-Succ:前提 𝑡 1: Nat。若 𝑡 1 能归约,用 E-Succ。若 𝑡 1 是值,由 [引理] 典范形式(canonical forms) 第 2 条它是数值 ,于是 本身也是数值,因而是值。
  • T-Pred:前提 𝑡 1: Nat。若 𝑡 1 能归约,用 E-Pred。若 𝑡 1 是值,它是数值:是 0 就用 E-PredZero;否则形如 ,用 E-PredSucc。
  • T-IsZero:与 T-Pred 相同,分别用 E-IsZero、E-IsZeroZero、E-IsZeroSucc。
∎

7.3.2 [例] 进展 [progress]

采用 [定义] 类型与类型判断 的类型规则和 [定义] 一步归约 的求值规则。取良类型的项 𝑡= pred ( if  false  then 0 else  succ 0 ): Nat,跟着 [定理] 进展 的证明走一遍:

  1. 𝑡 的推导最后一步是 T-Pred,前提 if  false  then 0 else  succ 0: Nat。对这个子项用 归纳假设 。
  2. 子项的推导最后一步是 T-If,前提里有 false : Bool。false 是值,由 [引理] 典范形式(canonical forms) 它是 true 或 false,这里是 false,于是用 E-IfFalse:子项 ⟶ succ 0。
  3. 回到 𝑡:子项能归约,所以用 E-Pred,𝑡⟶ pred ( succ 0 )。

证明不只说“能归约”,还构造出了这一步的推导。对 pred ( succ 0 ) 再用一次进展:子项 succ 0 是值,由 [引理] 典范形式(canonical forms) 它是数值,且形如 ,于是用 E-PredSucc 得到 0。对 0 再用一次进展:它是值,停止。

看 succ  true 为什么会受阻,再对照证明:要让它不受阻,要么 true 能归约,要么 true 是数值,两者都不成立。证明之所以没遇到这个麻烦,是因为 T-Succ 要求 𝑡 1: Nat,再经由典范形式把 𝑡 1 限定为数值。类型系统正是在这里把坏程序挡在门外的。

7.4 保型

进展只能保证当前状态能运行,保型则让这个保证在运行后仍然可用。若修改 E-PredZero 为 pred 0⟶ true,原有类型规则仍会给 succ ( pred 0 ) 类型 Nat,第一步也可以执行;但它会变成没有类型、且受阻的 succ  true。所以一次正确的类型检查必须能跨越每次运行时改写,不能“第一步安全就算安全”。编译器的优化、解释器的执行规则也要维持相应的不变量,否则即使前端检查器正确,后端仍能制造坏状态。

7.4.1 [定理] 保型 [preservation]

采用 [定义] 类型与类型判断 的类型关系和 [定义] 一步归约 的归约关系。若 𝑡:𝑇 且 𝑡⟶𝑡 ′,则 𝑡 ′:𝑇。

证明

对 𝑡⟶𝑡 ′ 的推导归纳( [定理] 对推导归纳 ),对 𝑇 一般化。每个情形先对 𝑡:𝑇 使用 [引理] 反演 。

  • E-IfTrue:𝑡= if  true  then 𝑡 2 else 𝑡 3,𝑡 ′=𝑡 2。反演得 𝑡 2:𝑇。
  • E-IfFalse:对称,反演得 𝑡 3:𝑇。
  • E-If:𝑡 ′= if 𝑡 1 ′ then 𝑡 2 else 𝑡 3,前提 𝑡 1⟶𝑡 1 ′。反演得 𝑡 1: Bool,𝑡 2:𝑇,𝑡 3:𝑇。对前提用 归纳假设 (取类型为 Bool)得 𝑡 1 ′: Bool,再用 T-If。
  • E-Succ:反演得 𝑇= Nat,𝑡 1: Nat。 归纳假设 给出 𝑡 1 ′: Nat,用 T-Succ。
  • E-PredZero:𝑡 ′=0。反演得 𝑇= Nat,用 T-Zero。
  • E-PredSucc:,。反演两次:先得 𝑇= Nat 且 ,再得 。
  • E-Pred:同 E-Succ,最后用 T-Pred。
  • E-IsZeroZero、E-IsZeroSucc:𝑡 ′ 是 true 或 false。反演得 𝑇= Bool,用 T-True 或 T-False。
  • E-IsZero:反演得 𝑇= Bool,𝑡 1: Nat。 归纳假设 给出 𝑡 1 ′: Nat,用 T-IsZero。
∎

7.4.2 [例] 保型 [preservation]

对 [例] 多步归约到值 的归约链,按 [定义] 类型与类型判断 在每一项旁边写上类型。归约关系使用 [定义] 一步归约 ,保型性质及其证明见 [定理] 保型 :

 if ( iszero ( pred ( succ 0 ) ) ) then  succ 0 else 0: Nat ⟶ if ( iszero 0 ) then  succ 0 else 0: Nat ⟶ if  true  then  succ 0 else 0: Nat ⟶ succ 0: Nat

类型始终是 Nat。以第一步为例看保型的证明怎样工作:这一步的推导是 E-If,前提是 iszero ( pred ( succ 0 ) )⟶ iszero 0。由 [引理] 反演 对 𝑡: Nat 反演,得到条件 : Bool、两个分支 : Nat;对前提用 归纳假设 (取类型 Bool),得到 iszero 0: Bool;两个分支原封不动,再用 T-If 拼回去,得到新项 : Nat。

注意项本身变了很多,从一个 if 变成了 succ 0,但类型这个“静态描述”一直成立。保型说的就是:运行不会让类型说过的话失效。

7.4.3 [附注] 为什么要“对 𝑇 一般化” [generalizing-the-type-in-preservation-proofs]

在 [定理] 保型 的证明中,E-If 是 [定义] 一步归约 的条件归约规则,类型关系采用 [定义] 类型与类型判断 。处理这个情形时, 归纳假设 用在 𝑡 1 上,而 𝑡 1 的类型是 Bool,不一定是 𝑡 的类型 𝑇。如果要证的性质写成“对这个固定的 𝑇,𝑡 ′:𝑇”, 归纳假设 就只能谈论这个 𝑇,在这里用不上。所以要证的性质必须写成“对任意 𝑇,若 𝑡:𝑇 则 𝑡 ′:𝑇”。这个细节在自然语言的证明里很容易被略过, [定理] 确定性 的证明里对 𝑡 ″ 也做了同样的处理。

7.5 合起来

下面的推论把“当前不受阻”和“下一项仍有类型”接成对任意有限运行前缀的保证。只证明进展而不证明保型,保证可能一步后失效;只证明保型而不证明进展,则一个起初就受阻的良类型项没有任何一步可走,保型的条件从未成立,不能排除它。这个推论保证的是本文定义的“不受阻”,不保证程序一定终止,也不保证业务逻辑正确,例如得到 false 是否符合程序员的意图。

7.5.1 [推论] 类型安全 [type-safety]

由 [定义] 项的集合 、 [定义] 一步归约 与 [定义] 类型与类型判断 规定的布尔值/自然数语言是类型安全的( [定义] 类型安全 ):若 𝑡:𝑇 且 𝑡⟶ ∗𝑡 ′,则 𝑡 ′ 没有受阻。

证明

对 𝑡⟶ ∗𝑡 ′ 的步数归纳( [定义] 多步归约 的两条规则)。

  • 零步:𝑡 ′=𝑡,于是 𝑡 ′:𝑇。
  • 至少一步:𝑡⟶𝑡 1 且 𝑡 1⟶ ∗𝑡 ′。由 [定理] 保型 ,𝑡 1:𝑇;对 𝑡 1 用 归纳假设 即可。

归纳给出的其实是更强的结论:𝑡 ′:𝑇。最后对 𝑡 ′ 用 [定理] 进展 :它是值或能归约,总之不是受阻的。

∎

7.5.2 [注记] 约定与必然 [convention-and-necessity]

[推论] 类型安全 的证明里,我们一次也没有运行程序,却得到了关于所有运行的结论。这让人想起康德(Kant)的问题:有没有不靠经验、却又对经验有效的知识?

这里的答案相当朴素。结论之所以不靠“试”,是因为“运行”本身就是我们用规则定义出来的( [定义] 一步归约 ),定理只是把定义里已经包含的东西展开。按逻辑实证主义的说法,这类命题是分析的:它的真依赖于定义。但这不等于它空洞。“所有良类型程序都不受阻”在定义里并不显眼,要靠反演、典范形式、两层归纳才能抽出来,中途还可能发现定义写错了( [附注] 分支顺序里藏着证明 和 [附注] 为什么 E-PredSucc 要求 就是例子)。弗雷格说过,一个结论可以“像植物包含在种子里那样”包含在定义中,而不是“像梁木包含在房子里那样”一眼可见。

另一面是:证明只对这套规则成立。真实的 CPU 是否忠实地执行了这些规则,是另一个问题,属于经验,要靠测试和工程去回答。

7.6 练习:类型安全
本节练习(4题)

练习5.1:把进展与保型放到同一条链上

对 if ( iszero ( pred ( succ 0 ) ) ) then  pred 0 else  succ 0 写出归约链与每站类型。逐步说明 [定理] 进展 在哪里提供后继, [定理] 保型 又怎样重建类型;第一步内部用到的类型为何不都是整项的 Nat?
提示每个中间项都为 Nat;条件内部先保持 Nat,外层 iszero 保持 Bool,不能把这两个类型混成一个固定的 𝑇。
参考答案

 if ( iszero ( pred ( succ 0 ) ) ) then  pred 0 else  succ 0: Nat ⟶ if ( iszero 0 ) then  pred 0 else  succ 0: Nat ⟶ if  true  then  pred 0 else  succ 0: Nat ⟶ pred 0: Nat ⟶0: Nat

四步依次用 E-If + E-IsZero + E-PredSucc,E-If + E-IsZeroZero,E-IfTrue,E-PredZero。每个非末项都展示了进展要求的一个后继,末项 0 展示了“已经是值”的分支。

第一步内层 pred ( succ 0 ) 从 Nat 保持为 0: Nat,T-IsZero 重建条件的 Bool,T-If 再和两个 Nat 分支重建整项的 Nat。第二步条件变成 true : Bool,分支不变;第三步选出的 pred 0 本来就在 T-If 的前提里具有 Nat;第四步由 T-Zero 得 0: Nat。这才是类型为何保持的逐步解释,不只是把相同的标签写在每一行旁边。

练习5.2:扩展:只有一条安全性定理够吗?

分别考虑两种修改:一是删除 E-PredZero;二是把它改成 pred 0⟶ true。其余求值与类型规则都保留。每种修改破坏进展还是保型?另一条是否还能成立?用具体项说明只留一条为什么不足以保证类型安全。
提示分别修改,不要把两种修改混到同一门语言里。保型只对实际存在的归约步骤作保证。
参考答案

删除 E-PredZero 而保留其他规则时,pred 0: Nat 既不是值也没有后继,进展失效。但剩下的每一步仍是原语言合法的步骤,因此仍保型。保型无法排除一个根本没有步骤的坏终点。

将 E-PredZero 改为 pred 0⟶ true 时,pred 0: Nat 走到 true : Bool,保型失效。原来的进展证明仍能为每个良类型非值找到一步,T-Pred 的零参数情形只是把后继换了一个;但它不保证后继仍良类型。

例如 succ ( pred 0 ): Nat 在第二种修改下走到受阻的 succ  true。所以“现在有下一步”与“类型保证能传到下一项”必须一起使用,见 [推论] 类型安全 。

练习5.3:保型能倒过来读吗?

判断:“若 𝑡⟶𝑡 ′ 且 𝑡 ′:𝑇,则 𝑡:𝑇。”它是不是 [定理] 保型 的等价表述?若不是,给出原语言中的一步反例,说明为什么不与保型矛盾。
提示从“分支类型不同,但运行选中一个正常分支”的项开始寻找。
参考答案

不成立。取 𝑡= if  true  then 0 else  false,E-IfTrue 给出 𝑡⟶0,且 0: Nat,但 𝑡 没有类型:T-If 要求两个分支具有同一类型,而 0 与 false 的类型不同。

这不违反保型,因为保型的前提是起点已有类型。从一个正常的终点倒推起点有类型,相当于把一个单向蕴涵改成了逆命题, [附注] 类型系统是保守的 已经提醒过不能这样用。

练习5.4:扩展:不受阻,仍然可以永远运行

只将 E-PredZero 改成 pred 0⟶ pred 0,其余规则不变。分析从 pred 0 出发的运行:它受阻吗?保型吗?会终止吗?说明进展与保型的证明只需调整哪个情形,以及“每步让项变小”的论证为什么失效。
提示把 E-PredZero 改成自循环步骤,但不保留它原来归约到 0 的版本。检查下一步是否存在、类型是否改变、大小是否下降。
参考答案

新规则是 pred 0⟶ pred 0。pred 0: Nat 仍不是值,始终有下一步,所以没有受阻;每一步又回到同一个具有 Nat 的项,因此这条链一直保持类型,却永不结束。

原进展证明的 T-Pred 零参数情形仍有后继;原保型证明的 E-PredZero 情形改为“结果就是起点,已有 Nat”。其他情形不变,所以这门修改后的语言仍可证明进展、保型与不受阻的类型安全。

但大小始终为 2,原语言“每一步严格变小”的论证不能再用,求值循环也不能再保证停止。类型安全没有承诺终止;要证明终止,还需要另外的下降度量或其他证明。

8 [] 规则与算法:可靠性与完备性 [rules-and-algorithms]
8.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 ,大多数手写测试照样能过。要把“一样”说清楚,得拆成两个方向。

8.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) 。

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

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

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

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

8.2 证明

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

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

8.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 这样的合法程序;下面这条定理能发现它。

8.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) 。
∎

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

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

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

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

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

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

8.3 为什么这里这么顺利

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

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

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

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

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

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

8.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 对两个期望类型都返回假。这只是原算法的接口包装,没有增加一种对象语言的判断或求值策略。

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

纸面证明可能写错,代码也可能和规则对不上。定理本身可以直接写成测试:生成大量项,拿定理的结论去检查。

#[test]
fn safety() {
    for t in corpus() {                        // 深度 ≤ 2 的全部项 + 随机的深项
        let Some(ty) = type_of(&t) else { continue };
        let mut cur = t.clone();
        loop {
            // 进展:不是值,就必须能归约
            let Some(next) = step(&cur) else {
                assert!(is_value(&cur), "受阻了:{cur:?}");
                break;
            };
            // 保型:一步归约,类型不变
            assert_eq!(type_of(&next), Some(ty), "{cur:?} ⟶ {next:?}");
            cur = next;
        }
    }
}
#[test]
fn safety() {
    for t in corpus() {                        // 深度 ≤ 2 的全部项 + 随机的深项
        let Some(ty) = type_of(&t) else { continue };
        let mut cur = t.clone();
        loop {
            // 进展:不是值,就必须能归约
            let Some(next) = step(&cur) else {
                assert!(is_value(&cur), "受阻了:{cur:?}");
                break;
            };
            // 保型:一步归约,类型不变
            assert_eq!(type_of(&next), Some(ty), "{cur:?} ⟶ {next:?}");
            cur = next;
        }
    }
}

这门语言没有循环,每一步都让项变小,所以 loop loop 一定会停。代码在 code/plt-arith code/plt-arith 下, cargo test --release cargo test --release 即可运行。样本是深度 2 以内的全部 59439 个项,外加 50000 个深度到 6 的确定性随机项。

确定性也能这样测。测试里把 E-规则逐条照抄成一个返回所有可能 𝑡 ′ 的函数,不做任何排序或排除,然后检查它在每个样本上至多给出一个结果,并且和 step step 一致。这同时验证了 [附注] 分支顺序里藏着证明 的说法: match match 的分支顺序没有偷偷改变语义。

前文提过的两个错误写法都会被抓住,但抓住它们的测试不同:

  • T-If 不检查 else 分支,破坏的是保型。 safety safety 实际报出的反例是 succ ( if  false  then 0 else  true ):错误的检查器只看 then 分支,认为它是 Nat;一步归约(E-Succ + E-IfFalse)得到 succ  true,没有类型,而且受阻了。
  • E-PredSucc 写成任意 𝑡,类型安全其实仍然成立,坏掉的是确定性,测试报出 pred ( succ ( pred 0 ) ) 有两种归约方式。

每条定理守住的东西不同,少证一条,就会漏掉一类错误。

9.1 [附注] 测试不是证明 [testing-is-not-proof]

测试只能检查有限个样本,证明覆盖命题量词范围内的所有对象。例如, 用测试对照证明 的测试样本来自 [定义] 项的集合 的项集合,深度按 [定义] 大小与深度 计算;深度 3 的项已经多到没法穷举。测试过了,也只说明在这些样本上没找到反例。两者的分工是:证明负责“为什么对”,测试负责发现“证明和代码说的是不是同一件事”。测试失败,说明证明或代码有一处错了;证明写不下去,往往说明测试还没碰到反例。

这个差距在真实软件里大得惊人。MikanAffine 的《为什么 sqlite 可以被重写》以 SQLite 为例:它用约 9000 万行测试覆盖约 20 万行源码,仍然不断被报告出漏洞。原因是程序每经过一个 if if 就分裂出两条执行路径,要覆盖的路径数随分支数指数增长,测试的增长永远追不上。

类型系统走的是另一条路。 [推论] 类型安全 这样的定理不是对路径逐条检查,而是一次性地对所有良类型程序、所有执行路径断言“不会受阻”。前提是类型系统本身是可靠的(sound):它说没问题的程序,运行时真的没有那一类问题。一个不可靠的类型系统(比如允许随意强制转换指针的 C)给出的“通过检查”就不能当作保证,该测的还得测。

这正是近年 RIIR(Rewrite It In Rust,用 Rust 重写)思潮背后最实在的理由。Rust 的类型系统和借用检查器把内存安全、数据竞争这类错误从“要靠测试和运气去发现”变成了“编译不通过”;RustBelt 等工作则在形式化层面证明了它的安全核心是可靠的。微软和 Chromium 都报告过,各自产品中约七成的严重安全漏洞来自内存安全问题;而 Android 在新代码转向内存安全语言之后,这类漏洞的占比显著下降。静态检查当然不能取代测试,但它把一整类错误从测试的负担里拿掉了,这是测试本身做不到的。

9.2 [注记] 证伪与证实 [falsification-and-verification]

波普尔(Popper)认为,经验科学里的普遍命题无法被有限次观察证实,只能被一次反例证伪。测试的处境与此相同:一万个通过的样本不能证实“所有良类型程序都不受阻”,一个受阻的样本就能推翻它。

证明则走另一条路。它不观察,而是从定义出发推出结论,所以可以对无穷多个程序负责。代价是它只对定义负责:如果规则本身没有描述我们心里想的那门语言,证明再严密也帮不上忙。这说明了证明与测试的分工:证明管“从规则到结论”,测试和实现管“规则是不是我们想要的”。 [推论] 类型安全 与 用测试对照证明 分别给出了这两种工作的例子。

9.3 练习:测试与证明
本节练习(4题)

练习7.1:给不同的错误配不同的测试

分别对下面四处独立的错误设计一个具体输入与测试断言,并指出它违反了哪个性质:值 true 被归约成 0;pred 0 被误判为没有后继;关系中的 E-PredSucc 去掉数值限制;类型检查器不检查 else 分支。
提示分别检查值不可归约、进展、关系的所有后继,以及检查器接受后类型能否保持;测试一个函数只有一个返回值不是确定性测试。
参考答案
  • 让 step(True) step(True) 返回 Some(Zero) Some(Zero) :用值不可归约测试要求 step(True) == None step(True) == None 。这个错误也把 Bool Bool 变成 Nat Nat ,所以保型检查也能发现。
  • 让 step(Pred(Zero)) step(Pred(Zero)) 返回 None None :pred 0: Nat 不是值,却无法走一步,进展测试会失败。
  • 将关系中的 E-PredSucc 放宽到任意项:pred ( succ ( pred 0 ) ) 有两个不同后继 pred 0 与 pred ( succ 0 )。用独立的规则枚举器检查后继集合,而不是只看 step step 选出的一个返回值。
  • type_of type_of 忽略 else 分支:succ ( if  false  then 0 else  true ) 被错认为 Nat,一步得到 succ  true,新项没有类型且受阻。逐步保型或端到端安全测试能抓到它。

四处修改应分别注入、分别恢复,才能知道哪个测试在防哪类错误。放宽规则的错误不一定破坏类型安全,不能指望一条安全测试包办所有性质。

练习7.2:通过测试,还是没有真正测到?

本节的 safety safety 测试如果只生成不良类型项,或者只生成 true、false、0,会发生什么?怎样用统计断言发现这种空转?另解释为什么 assert_eq!(step(t), step(t)) assert_eq!(step(t), step(t)) ,或者从 step step 直接包装出的“关系枚举器”,不能验证求值器忠实于规则。
提示读 [附注] 测试不是证明 时也检查测试的前提:循环是否进入过良类型情形?是否实际走过一步?比较器是不是只在与自身比较?
参考答案

只生成不良类型的项时, let Some(ty) = type_of(&t) else { continue }; let Some(ty) = type_of(&t) else { continue }; 会跳过每个样本,进展和保型断言一次都不运行。只生成常量值时,类型检查会通过,但 step step 立即返回 None None ,仍从未检查一次类型保持。

至少统计并断言良类型样本数大于零、实际归约总步数大于零,再加入确实需要多步运行的嵌套项。还应单独测拒绝的项和受阻情形。这些统计能排除明显的空转,却不等于覆盖所有规则和路径。

assert_eq!(step(t), step(t)) assert_eq!(step(t), step(t)) 只是一个确定执行的函数与自身比较;即使它算错了,两边仍可能同时错。把“关系枚举器”直接写成 step(t).into_iter().collect() step(t).into_iter().collect() 也没有独立核对规则。应分别按规则列出后继,再与函数实现对照;两份代码仍可能犯相同错误,所以还需结合具体例子、逐规则审阅与证明。

练习7.3:再多的有限样本也留下边界

给定任意一批有限的测试项,设其中最大深度为 𝑑。构造一个检查器,使它通过这批项与正确 type_of type_of 的所有结果对照,却在某个更深的项上不可靠。给出函数、反例和推理,说明这个例子揭示的测试边界。
提示设这批样本的最大深度为 𝑑。让错误只在深度超过 𝑑 时出现。
参考答案

假设这批样本非空,𝑑 就是有限的最大深度;空样本集可取 𝑑=0。可以构造一个终止的错误检查器:

pub fn bounded_fake(t: &Term, d: usize) -> Option<Type> {
    if depth(t) > d { Some(Type::Nat) } else { type_of(t) }
}
pub fn bounded_fake(t: &Term, d: usize) -> Option<Type> {
    if depth(t) > d { Some(Type::Nat) } else { type_of(t) }
}

每个测试样本深度都不超过 𝑑,所以它们得到的结果与正确实现完全相同。可是给 true 外面包上 𝑑+1 层 succ,得到的项深度为 𝑑+1,会被假检查器接受为 Nat;原类型规则却无法给最内层 succ  true 定型,整个项没有类型,而且受阻。

因此有限测试通过,只能排除样本范围内已出现的反例。这里不声称真实 bug 一定这样写,而是用一个具体构造说明:没有关于所有项的证明,测试结果本身不蕴涵普遍可靠性。

练习7.4:为什么求值测试的循环会停?

不依赖类型安全,证明原语言每一步都使大小严格下降,并给出从大小为 𝑠 的项出发的归约步数上界。能否把大小换成深度并保持“每一步严格下降”?找反例。最后说明为何“循环停了”仍不足以断言求值成功。
提示对一步归约的推导归纳。计算规则删掉节点;同余规则把子项的严格下降带到外层。深度不一定严格下降。
参考答案

对 𝑡⟶𝑡 ′ 的推导归纳。E-IfTrue 与 E-IfFalse 只留下一个分支,删掉根、条件及另一分支,大小严格下降。E-PredZero 和 E-IsZeroZero 从两个节点变成一个;E-PredSucc 删除 pred 与 succ 两个节点;E-IsZeroSucc 把大小至少为 3 的项变成一个 false 节点。这覆盖六条计算规则。

对 E-Succ、E-Pred、E-IsZero, 归纳假设 给出子项大小严格下降,两边包上同一个一元节点,严格不等式仍成立。对 E-If,只有条件变化,两个分支与 if 根贡献的节点数相同,也保持严格下降。因此十条规则都给出 size( 𝑡 ′ )<size( 𝑡 )。∎

大小始终是至少为 1 的整数,起点大小为 𝑠 时,最多走 𝑠−1 步就必须停止。停止时是否为值是另一个问题:不良类型项也会停止,却可能受阻;对良类型项,进展才排除这种终点。

深度不适合替代这个严格下降度量。例如 if ( iszero 0 ) then ( succ ( succ 0 ) ) else 0 的第一步只把条件变成 true,最长路径仍在 then 分支,两项深度都是 3。类型安全也不独自保证循环停止,见本节之前的自循环扩展题。

10 [] 回头看与延伸阅读 [summary]

全文走过的路,可以按“用了什么前文”串成一条线:

  1. 自然语言在结构、完备、量词、自指、可检查性上的五个弊端( [例] 结构歧义 至 [例] 贝里悖论 ),引出对象语言与元语言的分层( [定义] 对象语言与元语言 )。
  2. 用“满足封闭条件的最小集合”定义项( [定义] 项的集合 ),“最小”直接给出结构归纳( [定理] 结构归纳原理(structural induction) )。
  3. 用同样的“最小”定义求值关系( [定义] 一步归约 ),得到对推导归纳( [定理] 对推导归纳 ),再证明确定性( [定理] 确定性 )。
  4. 用推导规则定义类型( [定义] 类型与类型判断 )。语法导向给出反演( [引理] 反演 ),反演给出类型唯一和典范形式。
  5. 进展与保型合起来得到类型安全( [推论] 类型安全 )。
  6. 证明检查器相对规则可靠且完备,把“检查器接受”与“运行不受阻”接上( [推论] 检查器接受的程序不会受阻 )。

贯穿始终的方法只有一个:先把东西定义成满足某些规则的最小对象,再沿着这些规则归纳。语言变大以后,规则会变多,证明会变长,但这个方法不变。它在数学和逻辑史上的来历,见 [注记] “最小 + 归纳”的历史 。

10.1 [附注] 算术语言形式化的未展开前提 [unexamined-assumptions]

形式化入门:从 BNF 到类型安全 定义了布尔值/自然数语言,并证明其类型安全;这套形式化仍有几项默认采用、未单独展开的前提:

  • 解析:从字符串到语法树这一步( [约定] 抽象语法 )。
  • 元语言本身的可靠性:我们默认“集合”“最小”“归纳”这些数学工具是可靠的。追究下去就是数学基础的问题了。
  • 实现与规则的对应:Rust 代码和规则之间的对应是靠阅读和测试确认的,没有机器证明。要做到机器证明,需要把规则和定理放进 Coq、Lean、Agda 这类证明助手。

10.2 延伸阅读

Backlinks

Based on Typsite