[] 为什么形式化:自然语言与元语言

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

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

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

2 为什么不用自然语言

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

2.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 ?

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

2.2 弊端二:没说到的情形

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

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

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

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

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

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

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

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

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

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

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

2.4 弊端四:自指问题

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

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

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

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

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

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

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

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

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

2.6 形式化带来了什么

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

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

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

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

逐条对应如下:

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

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

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

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

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

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

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

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。这里“子节点”指一个节点,“子树”指从那个节点开始的整块结构。

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 却不满足。因此“检查了一百次”只能支持这一百个具体实例,不能独自推出普遍结论。

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

3 两个层次的语言

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

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

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

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

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

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

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

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

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

References

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

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

Backlinks

Based on Typsite