以下是第三章《语法分析器》的笔记。
语法分析器的任务是判断单词流表示的程序是不是一个有效的语句。
如何描述语法:CFG
正则表达式不能用来描述语法,本章使用「上下文无关语法 Context-Free Grammer」,缩写为 CFG,来描述语法。
先看简单的例子:
1 羊叫 -> 咩 羊叫
2 | 咩
- 第一行读作:「羊叫」可推导为「咩」后面接更多「羊叫」。
- 第二行读作:「羊叫」可推导为「咩」。
- 其中,「咩」不能再被推导,被称作「终结符」,「羊叫」可以被推导,被称为「非终结符」。
我们可以用一个表来表示羊叫的推导过程:
使用规则 | 句型 |
---|---|
羊叫 | |
1 | 咩 羊叫 |
2 | 咩 咩 |
当然,把羊叫推导成「咩咩咩咩咩咩」也是可以的。
再来看一个复杂一点的例子:
1 Expr -> ( Expr )
2 | Expr Op name
3 | name
4 Op -> +
5 | -
6 | *
7 | /
对 Expr 的一种推导如下:
使用规则 | 句型 |
---|---|
Expr | |
2 | Expr Op name |
6 | Expr * name |
1 | ( Expr ) * name |
2 | ( Expr Op name) * name |
4 | ( Expr + name) * name |
3 | ( name + name) * name |
因此我们知道 (a + b) * c
是该语言的合法语句。
我们还可以用「语法分析树」来表示这个推导过程:
上面推导过程的每一步,都是优先把最右边的非终结符推导成终结符,所以被称为「最右推导」。与之对应,你可以可以使用「最左推导」。
语言缺陷
如果一个语言在遇到 a + b x c
时如果先计算加法再计算乘法也是不对的,所以在定义 CFG 的时候也要注意操作符的优先级问题。
为了解决优先级问题,可以把上面的 CFG 改为如下形式:
0 Goal -> Expr
1 Expr -> Expr + Term
2 | Expr - Term
3 | Term
4 Term -> Term * Factor
5 | Term / Factor
6 | Factor
7 Factor -> ( Expr )
8 | number
9 | name
通过了实施优先级,上面的 CFG 加了不少非终结符,也是无奈之举。
除此之外,如果语言中的某个语句有一个以上的最右(或最左)推导,那么这个语言的语法就是有二义性的。
比如 JS 中的 { name: 'frank' }
就有两种解释:
- 它表示一个对象,name 和 'frank' 是一对键值对
- 它表示一个代码块,里面有一个叫做 name 的 label
JS 只能强行选择了第二种解释,这跟大部分人的直觉是相反的。
为输入字符串找到推导
编译器得到一个字符串之后,需要判断是否存在对应语法的一个推导,这个构造推导的过程叫做「语法分析」。
对于没有二义性的语言,我们可以认为语法分析器的输出是形如 <name, a> <op, +>, <name, b>, <op, *>, <name,c>
的单词流 ,而输出就应该是一颗「语法分析树」。
这课树的根节点是语法的起始符号,这个树的叶子节点则从左到右与输入流一一对应。语法分析的难点则是找到根节点与叶子节点的中间连接部分。
因此,自然就有两种对立的方法来构建语法树:
- 自顶向下(top-down)。从根开始构建语法树,并使树向叶子方向生长。
- 自底向上(bottom-up)。与上面正好相反。
语法分析的难点在于
- 每一步要如何生长。
- 发现匹配失败了怎么办
- 失败的第一种原因是因为中间的生产步骤选错了,那么此时必须回溯
- 失败的第二种原因是确实没有对应的推导,那么此时应该确保把所有可能性都尝试过了,然后退出
自顶向下语法分析器
还是以上面的 a + b * c
为例,我们的目标是找到它对应的 CFG 推导,于是有了下表:
这个表有两个特点:
- 每次都是从最左边的非终结符开始推导
- 推导过程一次都没有失误过,因此没有回滚
无法终止
如果上表中的前三行改成这样:
规则 | 句型 | 输入 |
---|---|---|
1 | Expr | ↑ name + name * name |
1 | Expr + Term | ↑ name + name * name |
1 | Expr + Term + Term | ↑ name + name * name |
1 | ... | ↑ name + name * name |
这里每一次推导都才用的是 Expr -> Expr + Term
规则,导致推导无法终结。
这种形如 A -> A + B
的规则被称为「左递归」,会导致自顶向下语法分析器无限循环,我们只能把它变成「右递归」,转换公式如下:
A -> A b A -> c A'
| c => A' -> b A'
| 空
该转换引入了一个新的非终结符 A' 和一个空字符串。
于是我们可以改写之前的规则:
图中那个特殊的希腊字母表示空字符串。
最终的 CFG 长这样:
这样一来就消除了左递归。
但除了「直接左递归」还有「间接左递归」,不容易被看出来,解决办法是通过「前向替换 forward substitution」将「简洁左递归」转为「直接左递归」,然后转为右递归。细节这里就不说了。
这样一来就能消除不终止的问题。
无回溯
但还是无法消除回溯问题这导致最左匹配的自顶向下语法分析器相当低效,因为只要错了就得回头重新推导。
就目前这一语言而言,只需要每次都多读入一个单词(被称为前瞻符号 lookahead symbol),就可以完全避免回溯。因此,我们说该语法在前瞻一个单词时是「无回溯」的,这也被称为预测性语法。
不过具体要实现的话,还需要引入 FIRST 集合和 FOLLOW 集合的概念。
对于非终结符 A 来说,FIRST(A) 表示 A 推导出的每个句型的第一个符号中所有可能的终结符。
对于终结符、空或 EOF(都记为 a),FIRST(a) 就是 a。
显然终结符的 FIRST 集合是不用算就知道的,我们主要算非终结符的 FIRST 集合。算法也很简单,就是使用每个规则 ->
符号右边各符号的 FIRST 集合来推导左边符号的 FIRST 集合,结果如下:
FIRST集合 | |
---|---|
Expr | (, name, number |
Expr' | +, -, 空 |
Term | (, name, number |
Term' | *, /, 空 |
Factor | (, name, number |
实际意义就是,Expr' 推导出来的语句,必然以 + 或 - 或空开头,其他非终结符以此类推。
有了 FIRST 集合,我们就能结合前瞻符号来避免走入错误的规则。 FOLLOW 概念就先跳过了。
有回溯
并非所有语言都是无回溯的。比如我们给上面的语法引入函数调用和数组索引,就会出现这样的规则:
Factor -> name
| name [ArgList]
| name (ArgList)
就算前瞻到了 name,但是由于有三条规则都是以 name 开头的,所以三条路里必然有两条错误的路,很有可能会回溯。
你可能会说前瞻两个单词不就好了么?但前瞻两个单词依然会遇到需要前瞻三个单词才能消除回溯的情况,对吧?所以还是有可能存在回溯。
那如果我们改写规则呢?比如把上面规则改写成这样:
Factor -> name Temp
Temp -> 空
| [ArgList]
| (ArgList)
这样不就没有公共前缀了吗?确实,这样可以消除回溯。但这只是因为我们的例子足够简单。
一般来说,对于任意的上下文无关语言,我们无法判断它是不是无回溯语言。
递归下降语法分析器
有一种叫做「递归下降」的范式,可以实现简单高效地无回溯语法分析器。
这种分析器实际上是一组互相调用的函数,语法规则中的每个非终结符都对应一个函数。对于规则 A -> B c
,会有对应的 A 函数和 B 函数。因此,CFG 规则自身就是实现语法分析器的指南。
LL(1) 语法分析器
还有一种 LL(1) 语法分析器,它的名字的意思是:
- Left 从左往右扫描输入字符串
- Leftmost 只做最左推导
- One 仅使用一个前瞻符号
为构建 LL(1) 语法分析器,开发者需要提供一个右递归、无回溯的语法和一个决策表。
细节我们就直接跳过了。