.
语法:BNF、最小集合与结构归纳
. 语法:BNF、最小集合与结构归纳
用 BNF 与最小封闭集合定义算术语言的词项,并由最小性导出结构归纳原理.
1 语法树
这条约定是第一个“隐含前提”:本文所有的定理都是关于树的. 至于怎样把字符串可靠地变成树,那是另一个话题. 这种只关心树形结构、不关心字符怎么排的语法,叫抽象语法.
2 BNF
文法用产生规则描述允许的语法构造;语法则是关于程序结构的更一般的概念. 本文用文法直接描述抽象语法树,省略字符层面的解析.
常用的文法记号是 BNF,即 Backus–Naur 表示法 (参见维基百科). 它得名于 John Backus 与 Peter Naur,与 ALGOL 60 的语法描述密切相关. 英文名里的 Form 指一种表示形式,注意与后文规约理论中的“正规形式”区分.
BNF 用产生式描述各个语法类别,形如
其中 读作“定义为”或“可以是”, 读作“或者”,用来隔开几种选项. 右侧的符号分为终结符号与非终结符号: 终结符号是直接给出的符号(如关键字 、), 非终结符号表示还可由产生式展开的语法类别.
名字出现在自己的右边,就形成了递归.例如
说的是: 可以是 ,也可以是 后面跟着另一个 . 于是 、、 都是 . BNF 本身只是一种记号,它的准确含义要靠下面的“最小集合”来说清楚.
3 词项
对象语言中的程序称为词项(简称“项”),用元变量 表示. 这门语言只有表达式,没有单独的语句类别;词项的抽象语法用两行 BNF 给出:
竖线读作“或者”. 是 的后继,可以理解为加一; 是前驱,可以理解为减一; 判断 是否为零. 例如 是词项, 它对应的语法树见下图:
图中的 是根,有三个孩子; 、、 是只有一个孩子的内部节点; 三个标着 的节点是不同位置上的叶子.
这段 BNF 描述了词项的构造方式,严格的集合含义由归纳定义给出.
记号 花体(T) 是花体字母 T,可读作
script T
script T
; 它表示词项集合,与后文表示类型的元变量 不同.
“封闭”说的是:集合里有了 ,就必须也有 等等, 用这几条规则造不出集合外的东西.
只要求“封闭”还不够,因为满足封闭条件的集合有很多. 例如允许额外的叶子
null
null
, 并且把所有包含这个新叶子的 、 等树也一起加进来, 得到的更大集合仍然满足封闭条件. 但三条生成条件并未给出
null
null
,它不属于本文的项 (若只加
null
null
,漏掉 等树,则会直接破坏封闭性).
取最小集合,就是说: 词项只有用上面三条规则在有限步内构造出来的对象,没有其他元素. 这一限制称为最小性子句;它排除了规则没有生成的额外对象.
由此可以看出两件事: 每个项都是一棵有限的树,叶子是 、、, 内部节点是 、、(各有一个孩子) 或 (有三个孩子); 同时,语法只规定形状,不保证项满足类型规则. 和 都是合法的项; 它们是否良类型,要由后面的类型规则判断. 操作语义则单独规定项能怎样规约, 不良类型的项也在它的讨论范围内,甚至可能正常求值.
在 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
的指针,并且独占它指向的那块内存. Rust 需要在编译时确定每个类型占多少字节: 若直接写成
Succ(Term)
Succ(Term)
,里面嵌套的
Term
Term
又可能装一个
Term
Term
,大小就会变成无穷大,编译器会报错
recursive type has infinite size
recursive type has infinite size
. 换成
Box
Box
以后,
Succ
Succ
里装的只是一个固定大小的指针,真正的子树放在堆上.
从树的角度看,
Box
Box
正好对应语法树里的一条边: 父节点通过它“拥有”自己的子树,子树不和别的节点共享. 也正因为独占,
Box
Box
不能按位复制; 复制一个项要用
.clone()
.clone()
把整棵子树深拷贝一遍,后面的代码里会看到这一点.
4 结构归纳
最小性在排除额外元素的同时,还给出了结构归纳法. 要证明“所有词项都满足性质 ”,不必逐一检查无穷多个词项: 只需验证常量的基准情况,再验证各个构造子的归纳情况, 也就是证明当直接子项满足 时,由它们构成的词项也满足 .
先看“最小”怎样推出归纳. 设 是所有满足 的项组成的集合. 如果 能“跟着规则搭上去”,意思就是 也满足那三条封闭条件, 用这三条规则同样搭不出 之外的对象. 而 花体(T) 是这类集合里最小的,即每个这类集合的子集. 因此 花体(T) ⊂ S, 花体(T) 的每个项都在 里,都满足 .
要是去掉“最小”, 花体(T) 里就可能混进
null
null
这样的例外: 三条规则搭不出它,对它也无话可说, 在它身上成不成立也就无从谈起. “最小”保证了 花体(T) 里只有规则搭出来的东西, 所以“对每条规则检查一遍”就等于“对每个项检查一遍”. 下面把这段论证写成定理.
在元语言的集合论背景下,结构归纳直接由“最小”这两个字推出,无需作为对象语言的额外公理引入 (当然,元语言自身的数学基础仍有其公理,这里只是说定义了这个最小集合后不必再单独追加一条归纳公理).
再看一棵具体语法树上的归纳方向. 要证 对整棵树成立,只需在叶子处直接验证(第 1 条), 并在每个内部节点处假定以各个孩子为根的子项都满足 , 再推出以当前节点为根的项满足 (第 2、3 条). 这里 描述的是以该节点为根的整棵子项,与单个节点的标签区分开. 箭头表示“由孩子所在的子树推出父节点所在的子树”,信息自下而上流动:
先用两个函数练手,它们分别计算一棵语法树有多少个节点、有多深:
按项的形状逐个情形写等式,之所以能定义出函数,是因为每个项恰好属于一种形状 ( 抽象语法 已经保证没有歧义), 而右边只用到直接子项的函数值,子项又更小,一路往下总会落到常量上. 这种定义方式称为结构递归,它和结构归纳相伴而生: 结构归纳沿着项的构造过程证明性质,结构递归则沿着同样的结构定义函数. 熟悉 Haskell 的读者会认出, 这正是许多基于归纳类型(也称代数数据类型)和模式匹配的递归函数所采用的方式. Haskell 也允许非结构递归,但不能把它们一概称为共递归; 利用惰性求值逐步产生无限列表是共递归的常见例子, 还须检查它能否持续给出可观察部分( 什么是归纳 ).
接下来这条引理检查“大小”和“深度”两个定义是否协调: 最长路径用到的节点不会超过整棵树的节点数. 它也给递归的资源估计一个上界,例如遍历语法树时,递归栈深度不超过节点总数. 没有这个证明,这只是直觉; 如果误把 的深度写成三个分支深度之和加一,就不再是在数最长路径了. 例如三个分支各有深度 时,这种错误写法给出 ,但实际最长路径只有 条边. 要证明递归终止,还需另查每次调用的参数是否严格变小,不能只引用一个数值上界.
这就是结构归纳证明的基本思路:虽然项有无穷多个,但构造它们的规则只有有限几条。我们只需按这些规则逐一讨论:基础情形直接验证,递归情形则借助对子项的 归纳假设 ,一步步推出结论.
5 练习:语法
Exercises (5)
Exercise 2.1. 把项还原成树
画出 的树, 标出根、叶子与内部节点,列出直接子项,计算大小和深度, 并判断 是否属于整项的直接子项. 依据 树的基本术语 、 直接子项与真子项 与 大小与深度 作答.Hint
根是 ;三个直接子项要按条件、 分支、 分支的顺序列出. 大小数节点,深度数最长路径上的边.Solution
if
├── iszero
│ └── pred
│ └── 0
├── succ
│ └── succ
│ └── 0
└── pred
└── succ
└── 0
if
├── iszero
│ └── pred
│ └── 0
├── succ
│ └── succ
│ └── 0
└── pred
└── succ
└── 0
三个直接子项依次是 、、 ,大小都为 ,深度都为 . 所以整项大小为 ,深度为 .
三个标着 的节点都没有孩子,都是叶子; 根 和其余六个一元节点都是内部节点. 属于整项的真子项,但隔着中间节点,不算整项的直接子项. 不能因为三个叶子的标签相同,就把它们合并成同一个位置.
Exercise 2.2. 语法合法,不等于满足类型规则
判断下面四种写法是否描述本文的项; 构成项的写出构造过程,不构成项的说明生成条件缺了什么: 、、单独的pred
pred
、字面记号
1
1
. 不要用“它运行时会出错”作为不属于项集合的理由.Solution
是项:先由第一条生成 ,再由第二条包上 . 也是项: 三个常量都由第一条生成,再用第三条组合.
单独的
pred
pred
缺少子项,不构成词项. 字面记号
1
1
也不在这套抽象语法里; 后面会用 表示自然数一,但不能未经约定就把
1
1
当成已有的构造. 前两个项都满足语法的生成条件; 这既不保证它们有类型,也不保证求值能到达值. 是否有类型、怎样规约,要分别对照后面各自的规则, 不能混成一个“有没有意义”的判断.
Exercise 2.3. 封闭与最小的分工
设 S = 花体(T) ∪ {null},只比原项集合多一个新叶子. 判断 是否满足原来的三条封闭条件; 若不满足,说明怎样扩充才封闭, 并指出扩充后的集合与 词项的集合 定义的集合差在哪个条件上.Hint
考虑集合里一旦有null
null
,封闭条件对 所提出的要求.Solution
S = 花体(T) ∪ {null} 不封闭:,但 ; 既不在原语言的项集合 花体(T) 中,也不等于新添的叶子
null
null
.
要得到含
null
null
的封闭集合, 必须同时加入所有由原构造子和这个额外叶子在有限步内搭出的树, 包括 、 等. 这个集合满足原来的封闭条件,却严格大于 花体(T). “封闭”要求构造后不能跑到集合外,“最小”则排除规则没有生成的额外东西.
Exercise 2.4. 证明边数比节点数少一
令 表示项 的语法树中的边数. 写出 的完整结构递归定义, 并用 结构归纳原理 证明 . 每个归纳步骤都要写清 归纳假设 用在哪个子项上.Hint
常量有零条边;一元构造增加一条边; 根连向三个孩子,增加三条边.Exercise 2.5. 两种不同的错误“归纳证明”
令 为“”. 有人分别写出两段归纳步骤: “假定 ,所以 ”; “假定 ,大小加一仍不超过 ,所以 ”. 指出两段论证各自的错误,给出具体反例,并区分“假设用错对象”与“由归纳假设推不出结论”.Hint
先核对第一段使用的假设对象, 再检查第二段的不等式能否从子项传给父项.Solution
第一段是循环论证:它直接把当前待证的 当作假设, 没有使用结构归纳许可的子项性质 ,只相当于说了“结论蕴涵自身”.
第二段使用子项上的假设本身合法,但推理错误: 只能给出 ,不能给出 . 取 ,子项大小为 ,包上一层后为 . 可见 本身就是假命题,光修改措辞也无法把它证出来. 也是大小为 的反例.