
前记
PFPL,即 Practical Foundations for Programming Languages,是关于编程语言的类型结构和语法表现的一本书。有一定编程经验的人都知道不同的语言有不同的特点。通俗地举例来说,一些语言的变量类型在编写的时候就确定了,而有一些要在运行的时候才知道;有一些语言对变量类型的检查比较“严格”,而有一些则比较“宽松”。继续深入地思考,就会发现它们有很多奇妙的地方。其中似乎有一些规律,但又无法清楚地描述究竟是什么。PFPL 就是一本解释这方面内容的书。
纸上得来终觉浅,但目前又没有合适的方式让我将其运用到实践中,只好把书上的内容复述一遍,记录下想法,希望能对自己的理解有所帮助。
语法对象
编程语言是一种语言,它们用于表示计算机和人类都能理解的计算过程。一门编程语言的语法确定了它可以由哪些语句组成。那么这些语句是如何确定的,程序是如何组成的呢?
当提到语法的时候,可能表示的是几个不同的概念。一个是 表层语法,表示语句是如何输入并展示在计算机上的,通常是一些字符串等形式。而 抽象语法 表示语句之间是如何组合在一起的。从这个层面来说,语法是一颗树,称为 抽象语法树。这种树的节点是运算符,将几个语句组合在一起。另外还有关于标识符的声明和使用的问题,这部分结构称为 绑定。这个层次的语法称为 抽象绑定树,它在 抽象语法树 的基础上增加了绑定和作用域的概念。
抽象语法树
一棵 抽象语法树(abstract syntax tree,简称为 ast),是一棵有序树。它的叶子节点是 变量,内部节点是 运算符,运算符 的参数是它的子树。Ast 可以分为很多种 类别,表示不同形式的语法。变量 代表特定类别的语法中一个未确定的片段。Ast 可以用 运算符 组合起来。运算符 具有类别和 参数表,参数表 使用类别的有限序列来表示它的参数数量和每个参数的类别。举例来说,如果一个运算符具有类别
变量在其中是一个很重要的概念。在数学领域,变量一般表示某个作用域下的未知对象(如未知的实数),而在这里变量表示的是某个类别的 ast。因为这是一个未知的量,所以只有在 代换 的时候变量才能获得意义。例如数学中我们可能会将
举例来说,有一门简单的语言用于表示数字、加法和乘法。它的语法中只有一个类别 Exp,以及一个无限的运算符集合:
如果将
即表达式
Ast 的树形结构支持一种非常有用的原则推理,称为 结构归纳。假设我们想证明对于一个类别中所有的 ast,
- 所有 Exp 类别的变量 $ x $ 都具有该性质:
.
- 对于所有
,
都具有该性质:
,
.
- 假设
,
都具有该性质,证明
,
都具有该性质:if
and
, then
and
.
因为以上过程说明了所有
接下来考虑更加一般的情况。设
- 一个类别
的变量是
的一个 ast:if
, then
.
- 运算符可以组合 ast:if
,
and
,…,
, then
.
同样地,这个方法也可以用于证明所有 ast 具有性质
- if
, then
.
- if
and
, then if
and … and
, then
.
根据上面的原理,我们可以轻松地证明如果
如果
代换 赋予变量意义。如果
-
and
if
.
-
.
例如
定理 1.1. 如果
证明:如果
大部分情况下可以提前枚举出所有运算符,但是在一些情况下却不行,有些运算符只能在固定的上下文生效,此时运算符的集合
形式参数可能会和变量混淆,但它们是根本不相同的两个概念。变量是一个未知的 ast,而形式参数不代表任何东西,它只是用来区分其它的形式参数。我们用
抽象绑定树
抽象绑定树(abstract binding tree,简称为 abt),为 ast 添加了新变量和形式参数的声明,称为 绑定,以及他们的有效范围,称为 作用域,一个绑定的作用域是被绑定的标识符所在的 abt。因此一棵子树的活跃标识符集合可能比外层的集合大,不同的子树也可能会包含不同的标识符。但是所有的标识符都只是一个引用,也就是说选用不同的标识符所表达的含义是一致的,因此我们总是可以给绑定关联一个不同的标识符。
比如有一个表达式
let x be 2 in
let y be 3 in
x + x
let y be 2 in
let y be 3 in
y + y
Abt 可以给运算符参数绑定有限个变量,记作
为了表示绑定,abt 中运算符的参数表使用 格 的有限序列表示。这个序列的长度表示参数的数量,其中每个格表示一个参数的类别和绑定的变量类别。一个格用
设
- 如果
, 则
.
- 如果
,
属于类别
且
,则
.
这个定义有一点问题,考虑下面这个 abt:
如果
这个重命名规则避免了外部的变量和内部的变量冲突的情况。它保证了所有绑定的变量都与外围环境无关。
类似于 ast,如果需要证明一个性质
- 如果
,那么
.
- 对于任意属于类别
,具有参数表
的运算符
,如果对于每个重命名
都有
,那么
.
这也是一个归纳性的推理,遵循了上面构造 abt 的过程。举例来说,我们定义一个命题
-
.
- 如果存在
对于每个重命名
使得
,则
.
第一个条件说明
如果两个 abt,
-
.
- 对于每个
和所有新的重命名
和
,都有
.
如果
考虑 abt 中将某个自由变量
-
,
.
- 对于每个
,要求
,且若
,设
,否则设
,那么
.
从第二个条件可以看出,如果
这样我们就不需要关注
和变量类似,形式参数也可以绑定在运算符的参数上。为了表示形式参数,我们把格扩展为
总结
这部分介绍了语法的基本概念,将 表层语法 和 抽象语法 进行了区分。在抽象语法中又分为仅包含语句结构的 抽象语法树 和包含标识符定义和作用域的 抽象绑定树,并提出了相关的定义和定理。其中运用了大量的归纳法,从定义出发按照构造的过程对一些定义进行了说明。这部分是后面更为深入的内容的基础,需要熟悉里面的符号表示并理解概念。
区分这几个不同层次的概念可以帮助我们更好地理解语法结构,也能够知道在遇到什么问题时要从什么地方下手。如重构代码时,需要保证重命名前后的变量在语义上保持一致,且不与其它的变量名产生冲突。这部分涉及到变量的绑定问题,就需要从 abt 层面进行解决。以 JavaScript 为例,@babel/parser 可以将代码转化为 ast,但根据刚刚的分析,仅仅根据这个是很难帮助我们完成重命名的操作的。这时候可以借助 @babel/traverse 遍历 ast,在遍历的过程中能够访问到当前节点的作用域以及绑定等信息,这样就有了更多的信息,可以保证重命名前后语法的一致性。