抽象语法¶
语义动作(semantic actions)¶
我们以加减乘除的计算机为例。假设我们有下面的 tokens:
num
+
,-
,*
,/
(
,)
那么,我们的 semantic actions 就类似于下面这样:
case NUM:
return $1;
case PLUS:
return $1 + $2;
case MINUS:
return $1 - $2;
case MULT:
return $1 * $2;
case DIV:
return $1 / $2;
case PARENTHESIS: // LPAREN E RPAREN
return $2; // return E
Note
实际上,AST 的构建就是靠这个 semantic actions
例子¶
由于符号本身和值是绑定的,因此我们可以直接将符号栈和值栈二合一(如下图):
为什么需要抽象语法¶
如上图:
- 抽象语法树里面有很多冗余信息(比如括号)
- 因此,如果直接传递抽象语法树的话,不仅会徒增很多冗余信息,而且会导致语法树过度依赖具体文法
- 如果具体文法做了改动,那么就会导致语法树大变
Positions¶
由于抽象语法树相比源代码已经“面目全非”。因此,在 parsing 之后,如果我们在 semantic analysis 中发现错误并报错,那么由于报错信息中没有位置信息,因此我们就无法定位到源代码中的错误。
因此,我们一般会在构建 ast 的时候,往 ast 的节点中主动加入位置信息。
Semantic Analysis¶
语义分析,基本可以起三种功能:
- Scope and visibility of names
- every identifier is declared before use
- Type of variables, functions and expressions
- every expression has a proper type
- function calls should conform to definitions
- 将 AST 翻译成一个更加简单、更容易转换成机器码的表达方式,即 IR (Intermediate Representation)
Symbol Tables¶
A Motivating Example¶
因此,一个符号表至少需要支持 4 个操作(对于某个 binding):
- 插入
- 查找
beginScope
: 进入新作用域endScope
: 离开当前作用域
多重符号表
在一些语言中,同一时刻可能会存在多个 active environment。
以支持前向引用的 Java 为例:
我们首先完整扫描一遍文件,求出以 class 为单位的所有符号表。然后,我们在所有符号表均 active 的前提下,自前往后扫描第二遍,对 class 内部进行检查
以不支持前向引用的 ML 为例:
我们从头到尾扫描一遍即可。
符号表的类型¶
如上图:
- 对于命令式,我们只在全局维护一个统一的符号表,以及一个 undo stack。前者用于追踪目前环境的符号;后者用于在退出某个环境的时候,可以 pop 掉所有该环境的符号
- 对于函数式,我们将每一个环境都分开记录
命令式¶
三种基本操作:Pop, Lookup, Insert
如上图,技术上主要有三点:
- 使用哈希表,保证 \(\mathcal O(1)\) 的查找速度
- 哈希表的每一个 bucket 里面,都是一个链表
- 链表的每一项,代表同一个 symbol 在不同 scope 中的 binding
- 链表的表头,就是该 symbol 在当前 scope 中的 binding
- 此外,我们再额外维护一个链表:每插入一个符号,就在其头部进行插入;每 enterScope,就做一个记号;每 endScope,就从头部开始一直删除,直到删除到记号位置
函数式¶
实际上,我们这里就是运用了类似于“可持久化数据结构”的方法
- 可持久化,其重要特征有二
- 一是保留所有历史信息
- 二是数据的不可变性(即我们不能修改原有内容,而是只能用最新的数据建立一个新的数据结构)
我们这里的树,保留了所有历史信息,并且保证除了 endScope
以外,不会对任何数据结构进行删除操作。
具体来说:
- 当我们插入
mouse
的时候- 搜索路径如下:m2 -> (dog, m1) -> (dog, m1) 的右子节点, which is NULL
- 此时,我们在 (dog, m1) 这里的右子节点进行插入,而是将 (dog, m1) 复制成 (dog, m2),插到 m2 里面,最后在 (dog, m2) 的右子节点处插入 (mouse, m2)
- 在插入
mouse
之后,我们搜索 camel 的时候- 搜索路径如下:m2 -> (dog, m2) -> (bat, m1) -> (camel, m1)
- 在找到
camel
之后,我们endScope
- 删除整个 m2,并且将当前的 scope 切换到 m1
注意:上面两种方法,第一种插入、查找快,删除慢;第二种插入、查找略慢,但是删除快。两种都是常用的方法。
转载:Tiger编译器符号相关的实现 (Source)¶
Note
Tiger 编译器用的其实就是第一种方法(简单、容易实现)。
我们这里是讲一讲具体的 implementation tricks
在哈希表中的链表进行 lookup 时,不断进行字符串比较是很耗时的。
- 解决办法:使用新的数据结构将符号对象关联到一个整数上(哈希值)
Tiger 编译器的 environment 是 destructive-update 的。也就是说,有两个函数:
S_beginScope
: 记下当前符号表的状态S_endScope
: 恢复到最近的、还未被恢复的S_beginScope
记下的状态
我们引入一个 辅助栈(Auxiliary stack) 来维护上文提到的必要的额外信息:
- 符号入栈时,会将 binding 联动地插入对应 bucket 的链表头
- 弹出栈顶符号时,对应 bucket 的链表头也会联动地被移除
- beginScope: 压入一个特殊标记到辅助栈中
- endScope: 一直弹出符号直到弹出了一个特殊标记
- 可以由此标记推断:此次因为退出 scope 引发的 restore 操作可以就此结束
Type Checking¶
Info
我们基于 AST,检查一些静态的性质、规则是否被满足
我们要回答的关键问题包含三个:
- 哪些类型表达式是合法的/非法的?
- e.g.
int
,string
,nil
,array
ofint
- e.g.
- 如何定义”两种类别是等价的“
- What are the type-checking rules?
Type Equivalence¶
有两种等价的定义:
- Name Equivalence: if they are defined by the exact same type declaration
a = {x: int, y:int}; b = a
,则a == b
a = {x: int, y:int}; b = {x: int, y: int}
,则a != b
- Structural Equivalence: if they are composed of the same constructors applied in the same order.
a = {x: int, y:int}; b = a
,则a == b
a = {x: int, y:int}; b = {x: int, y: int}
,则a == b
对于 tiger 语言,我们用 name equivalence
Namespaces in Tiger¶
Tiger 有两套 namespaces:
- types
- functions and variables
对于两个 namespaces,我们分别用一下两种方式来维护:
Type Checking in Action¶
如上图:
Ty_ty_
就是类型结构体kind
代表类型name
只有在kind
为Ty_record/array/name
时才会有用record
在Ty_record
有用,指向一个 field listarray
在Ty_array
有用,指向另一个Ty_ty_
(也就是这个 array 的元素类型)name
在Ty_name
有用。以let list = {first: int, rest: list}
为例,sym
就是list
,而ty
就指向{first: int, rest: list}
- 正由于这是一个指针,我们可以循环引用
下面是具体的操作:
- Type-checking expressions
transExp
可以在给定的两个环境下将输入的表达式标记上type(如果发现非法则报错)
- Type-checking declarations
- 在Tiger语言中声明只可能在
let
语句中出现 - 变量声明:如果提供了变量类型,则检查初始化表达式类型是否匹配;否则直接通过初始化表达式类型获得变量类型
- 比如
let int a = 3 in ...
- 比如
- 类型声明:递归地获取类型别名对应的实际类型。
- 比如
let type a = b
,type b = {first: int, rest: a} in ...
- Q: 如何处理递归声明
type list = {first: int, rest: list}
?A: 不使用one-pass而是two-pass,如下- pass#1: 记录声明头部(左侧)放入环境
- pass#2: 完成
- 不允许类型的直接循环引用(
type a=b;type b=a
):必须通过record或array完成(type a=b;type b={i:a}
)
- 比如
- 函数声明:检查形参、返回值与函数体
- 比如
let int fib (int a) = {...} in ...
- Q: 如何处理递归声明?A: 不使用one-pass而是two-pass,如下
- pass#1: 记录函数声明(签名)放入环境
- pass#2: 处理函数体
- 比如
- 在Tiger语言中声明只可能在
注意:
- 在 C 语言中,initialization 和 declaration 是不一样的。如果两者存在”循环使用“的情况,那么就需要 declaration 在前。这样做,只需要 one pass 即可。
- 当然,对于更加现代的语言,就没有所谓 declaration 了。但是这样做,编译器就需要 two passes。
- 这部分内容,其实也不是非常有“理论性”。用 C 或者 OCaml 实现一个就清楚了。中心思想就是:维护两个表,一个是 type 定义,一个是 variable 定义;然后不断往这两个表里面添加 item,并且检查是否存在冲突