本文对编译原理中语法分析的基本内容作简要介绍。鉴于各种考虑,本文在语言风格上以易读为导向,并以牺牲一定的严谨性与完整性为代价。阅读本文建议配合经典教材食用,也希望本文能为阅读教材中的读者带来些许灵感与洞见,起到抛砖引玉的作用。时间仓促,不免疏漏,恳请读者批评指正。
背景
语法分析器的输入是一个单词流,其中每个单词都被标注了语法范畴。
语法分析器的职责是识别语法,即寻找给定文法下输入串的推导。
语法分析器分为自顶向下和自底向上两类:
-
自顶向下文法分析器试图通过在各个点上预测下一个单词,依照语法的产生式来匹配输入流。
-
自底向上语法分析器从实际单词序列开始,不断积累上下文信息并进行规约,最终得到文法开始符号,以得出一个反向的推导。
正则文法虽然方便分析但表达能力有限,因此我们引入上下文无关文法 CFG。
CFG 的产生式形如 (A o alpha),其中 (A) 总是可以被 (alpha) 替换而不用考虑上下文,因此称为上下文无关文法。
CFG 是可能具有二义性的文法,因此我们需要在语法分析前,进行二义性消除操作。
-
所谓二义性是指对于一个句子,可能存在多种不同的最左合法推导或最右合法推导。
-
二义性未必是可消除的。
-
我们无法在多项式时间内,判别一个文法是否具有二义性或者消除它的二义性。
本文中讨论的所有语法分析器都针对 CFG 或它的子集。
自顶向下语法分析
介绍
自顶向下文法分析器试图通过在各个点上预测下一个单词,依照语法的产生式来匹配输入流。
具体而言,在分析过程中,分析器维护了一棵不完整的语法分析树。
每一步,分析器选择一个(当前的)叶子 (A),根据某个产生式,为它添加孩子。
下图展示了一个典型的过程:
这个过程可以用递归实现,也可以用非递归实现。
回溯:一种暴力
如果我们有多个左侧同为 (A) 的产生式,我们难以确定,该用哪个产生式去推导。
我们不妨先忽略这个问题。就像写一个搜索算法那样,如果错了,就回溯。
考虑一个用栈实现的方案,在栈中按序存放所有的叶子,不断对栈顶进行推导扩展,如果匹配了终结符就弹栈。
嗯,看起来一切都好。只是你或许会有些不详的预感,尤其是当看到一个形如 (A o Aa) 的产生式时。
约束:无左递归
一个 CFG 产生式的右侧第一个符号与左侧相同,称为(直接)左递归。
一个 CFG 非终结符可以通过多次推导得到一个串,其第一个符号与原非终结符相同,称为(间接)左递归。
左递归会导致无限循环。
幸运的是,我们可以通过程式化的方法,将左递归转化为右递归。
对于直接左递归,参考如下方法:
一个典型的例子是对加减运算表达式的文法转换:
对于间接左递归,我们需要为符号钦定一种出现的顺序,转化掉所有逆序产生式,进而消除左递归。
其算法思路可以概括为:钦定一种拓扑序,边展所有逆序式,边消直接左递归。
约束:无回溯
虽然消灭了左递归,但我们距离一个高效的语法分析器还有很远。
没错,就是它,回溯!
然而回溯并不是一个好解决的问题。为了继续做下去,只能委曲求全,对语法本身加以限制。
我们称一种 CFG 是无回溯文法,当且仅当,语法分析器可以在至多前瞻一个单词的情况下,总是能够选择正确的产生式。
也就是说,每一步应该选择哪个产生式必须是显然的。
直觉上想,既然我们可以顺理成章地往后看一个符号(终结符),那就好好利用起来。如果能算出每个产生式最终推导出的所有语句的开头终结符的集合,如果对于同一个非终结符,它的各产生式的这种集合互不相交,就会做了。
为了确切地描述无回溯条件,我们需要一些概念。
定义终结首符集 (First(alpha)) 表示由语法符号 (alpha) 推导出的所有句子的首符号(终结符)。
然而仅有 (First) 是不够的。如果 (alpha) 推导出了一个空串,我们似乎需要一些更多的信息。
定义后随终结符集 (Follow(A)) 表示在本语言的所有句型中,可能紧跟在非终结符 (A) 后方的终结符集合。
现在我们可以为 (epsilon in First(alpha)) 的情况提供一些补救,只差双剑合璧。
增强终结首符集 (First^+(A oalpha)) 在 (epsilon in First(alpha)) 时定义为 (First(alpha) igcup Follow(A)),否则定义为 (First(alpha))。
由此,无回溯条件可以表达为:对于任意 (A),所有 (A oalpha) 的 (First^+) 互不相交。这样的文法称为无回溯文法。
刚才搁置了一件事:怎样计算终结首符集和后随终结符集?
我们的思路是迭代合并。循环去考虑每个产生式,不断计算它右端的 First 合并到左端的集合中,迭代直到算法收敛。
实现上,我们的 First 是针对单个符号定义的,所以计算时需要多做一点枚举。
Follow 的计算也是类似但有更多递推的思路。欸,怎么有点最大子段和的感觉。
那么,不满足无回溯条件的文法就真的没救了吗?能否像消除左递归那样,消除回溯呢?
提取左因子或许会有所帮助。它指的是对一组产生式,提取并隔离公共前缀的过程。一个典型的例子:
遗憾的是,这种方法的能力是有限的。事实上,无论我们做出包括前瞻多个符号在内的多少努力,都无法将领土扩张到整个 CFG。自顶向下语法分析器具有其天然的能力限制,而这种限制,也将会成为我们研究自底向上分析的动力。
在更进一步之前,先去思考一下无回溯文法分析的实现。
实现:递归下降
递归下降分析器是一种非常直观的实现。
我们为每个非终结符编写一个过程,过程之间相互调用以完成推导,在决策时考查前瞻符号属于哪一个增强终结首符集以决定调用哪一个过程,递归边界为匹配终结符并让输入指针前进。
一个识别表达式的例子如下:
实现:表驱动 LL(1)
编写上述程序令人倍感厌烦。事实上,我们可以将其中影响决策的信息提取出来,制成表格,以此驱动解析器运作。
我们一直讨论的这种分析器从左向右啃食输入串,吐出一个最左推导,每次前瞻一个符号,因此成为 LL(1) 分析器。
LL(1) 分析表中需要包括的信息,无非是栈顶是某个非终结符时,看到(前瞻)某个终结符,应当采取哪个产生式。
一个典型的 LL(1) 分析表如下所示:
编写分析程序的思路与最开始给出的基于栈的搜索方法类似。在栈中按序存放所有的叶子,不断对栈顶进行推导扩展,如果匹配了终结符就弹栈。
对了,差点忘了构造分析表的程序。既然已经有了增强终结首符集集,其实这部分工作也不复杂。
小结与讨论
本节我们讨论了自顶向下语法分析器。
- 自顶向下分析的过程是语法分析树暂时的叶子不断展开成子树的过程,也是最左推导的构建过程。
- 首先我们需要通过一种程式化的方法消除左递归,以避免程序陷入死循环。
- 我们固然可以用回溯去处理产生式的选取问题,但为了高效起见,我们尽可能只考虑不需要回溯的文法。
- 描述无回溯文法需要终结首符集和后随终结符集以及它们的合体作为工具,
- 它们的意义不仅式概念上的,其计算也为实现提供帮助。
- 有回溯文法未必总是能转换为无回溯,而提取左因子有时能提供一些帮助。
- 最后我们展示了两种典型的实现,递归下降分析法比较直观但编写工作繁复,表驱动 LL(1) 分析法是更加精巧而合适的选择。
虽然无回溯文法的要求并不会对程序语言的设计产生严重的限制,但我们仍然希望能做所突破。
同时,左递归是一种自然的存在,我们期盼它的归来。
在自底向上语法分析中,这些问题会在一定程度上得到解决。
自底向上语法分析
介绍
自底向上语法分析开始于森林,通过不断添加非终结符节点以合并树,缩减构建中的语法分析树的上边缘,并最终结束于一棵树。
在某个有效推导的意义下,对于一个句型,其下一步应当进行的规约为将 (k) 位置的 (eta) 替换为 (A),则称 ((A o eta, k)) 是它的句柄。实现自底向上语法分析只需要一个句柄查找器。
LR 分析即从左向右输入,输出最右推导。根据超前查看字符的情况不同来区别各种类型的分析器。
移进-规约
LR 分析的核心思路是移进-规约。
类似那种经典的简单表达式分析方法,算法将维护一个栈。
移进操作代表让新符号进栈,规约操作将弹出栈顶的若干个元素并将其替换为一个元素后重新压进去。
但这还不够,算法同时维护了运行在一个特别的有限状态自动机上的状态。
移进操作会导致一次转移,规约操作将导致一次返回到栈中某个状态的撤退行为,并接着引发一次转移。
此时,联想 AC 自动机的运作过程或许会有所帮助。
设计上述机制的关键动机是去回答一个问题:对当前状态(栈和尚未输入的序列,下称局面),怎样查找句柄?
换言之,面对一个新的待移进符号,我们需要做移进还是规约,采用哪个产生式规约?
一个重要的观察是:当你等待移进一个非终结符的时候,你就是在等待某个它展开后的串的开始元素。
这种项与项之间的关系被称为等价,它是我们进行闭包运算的重要缘由。当一个项集中有移入非终结符的项时,我们通过不断求其等价项,最后收敛到若干移入终结符的状态,以明确每个项可以进行哪些移入动作。
上面啰嗦了很多飘渺的东西。接下来按照一种逻辑演进的顺序介绍四种基础的 LR 分析器。
LR(0)
LR(0) 只需要查看栈就可以确定该如何转移,不需要前瞻符号。
因此它很容易遇上它无法处理的移进-规约冲突或规约-规约冲突。
项是一条文法规则加上一种栈顶的可能位置。
自动机的每个节点是一个项集。
求项集的过程称为求闭包。它将通过迭代,使得集合 (I) 内,每个项 (A o alpha.Xeta) 的栈顶标记 (.) 后紧邻的非终结符 (X) 发出的所有产生式 (X o gamma) 的栈顶在开头的项 (X o .gamma) 也都在集合内。
在此基础上,考察一个节点的出边,就是要考察其项集内所有项 (A o alpha.Xeta) 中栈顶标记 (.) 后紧邻的非终结符 (X)。
而经某个 (X) 的出边所到达的节点,则是将所有形如 (? o alpha .X eta) 的项的栈顶标记位置后移一位后(i.e. (A o alpha .X eta Rightarrow A o alpha X. eta))得到的项的闭包。
好绕啊,看代码吧!
一个文法的自动机以及分析表的示例:
基于这张表,LR 分析引擎的算法维护了一个栈,栈中即存放符号,又存放对应的状态。
更加程序化一点的表述:
这个程序我们会一直用下去。之后的改进几乎完全发生在建表之前的阶段。
LR(0) 是很容易发生冲突的。从表中可以观察到:具有规约动作状态对于任意的输入符号总是进行规约。
直觉上,这样是不好的。
LR(0) 之所以战火纷纷,就是因为规约动作的放置过于任性,过于膨胀。
实际上,某个状态并不是遇到所有符号都需要规约。即使规约,也未必对所有符号都采用同样的产生式规约。
但在没有前瞻符号的情况下,好像也没什么办法。
面对这样的情况,我们可以考虑引入前瞻符号,对状态进行细分。
在真的去细分状态之前,不妨考虑,能否在 LR(0) 的分析表的基础上,加一点 trick 拯救一下?
SLR(1)
如果你还记得有个东西叫 Follow 集:定义后随终结符集 (Follow(A)) 表示在本语言的所有句型中,可能紧跟在非终结符 (A) 后方的终结符集合。
前面说到,LR(0) 中膨胀的规约动作霸占了所有的出边,令移入和其它的规约动作无处容身。
我们希望能有一种森破的方式,更加精确地定义每个规约动作的激活条件,从而让分析表中容纳更多的动作。
目光投向了角落里的前瞻符号。
对于一个状态,它可以有若干个移进项目 (A_1 o alpha_1 .a_1 eta_1, A_2 o alpha_2 .a_2 eta_2, ...),有若干个规约项目 (B_1 o gamma_1., B_2 o gamma_2.,...)。
它们对应了不同的前瞻符号,分别是 (a_i, Follow(B_i))。
只要这些前瞻符号(集)互不相交,我们就可以通过前瞻符号,洞察需要进行哪个操作。
或许你已经想到了一些问题。
- 为什么是 (A_i o alpha_i . a_i eta_i) 而不是 (A_i o alpha_i . X_i eta_i)?
Hint: 思考引入等价状态的目的何在。
- 前瞻符号和转移用的符号有什么区别?
Hint: 前瞻符号是针对规约的,是在采用这个产生式(项)规约后,得到的非终结符后随的终结符。对于 (A o alpha.aeta) 是无直接意义的概念;对于 (B o gamma.) 不存在转移符号,但规约符号是 (Follow(B)) 的子集;对于 (A o alpha.Beta) 前瞻符号是某些可能出现在 (eta) 或者出现在 (A) 后的东西,它显然跟这一步移进没什么关系。
这就是我们的 SLR,Simple 的 LR(1) 分析。
我们几乎只需要修改 LR(0) 中的建表算法就可以得到 SLR(1)。
直觉上它还是有点弱,事实上也确实如此。
有些麻烦,终究还是逃不开。
LR(1)
LR(1) 将项的形式修改为 ([A o alpha . eta, a]),其中第二项是前瞻符号。它的涵义是,仅仅在前瞻符号为 (a) 时才利用该项进行规约。
由前面 SLR 的经验可知,对于规约项,这样的 (a) 总是 (Follow(A)) 的子集,且通常是真子集。
既然实际可以规约的 (a) 的取值集合是 (Follow(A)) 的真子集,那么 SLR(1) 就必然扩大了可以规约的范围,导致了不必要的冲突。
现在我们专心来看 LR(1)。
我们只需要多解决一个问题:如何确定等价项的展望符。即对于 (A o alphacdot Beta,a) 的等价项 (B o cdot gamma, b),如何确定 (b)?
自然有 (b in First(eta a))。
由此我们只需在原先的闭包函数中内套一层循环,枚举等价项的所有合法展望符即可。
而在填表时,对于每个规约动作,由于项有唯一对应的展望符,因此对于一个规约项,我们只会在分析表中填入一个规约动作。
LALR(1)
LALR 在 LR 的基础上,对同心项集进行了合并。
所谓同心,就是不看前瞻符号的情况下,两个项集长得一模一样。
这样合并可能会导致规约-规约冲突。如果发生了这样的事情,LALR 无能为力,我们只能去把文法怒斥一番。
LALR 的好处是,它让 LR 往 LR(0) 的方向倒退了一点,却能对状态的数量进行大幅度的压缩。
小结
本节我们介绍了自底向上语法分析及其基本原理与逻辑实现。
- 自底向上语法分析开始于森林,通过不断添加非终结符节点,缩减构建中的语法分析树的上边缘。
- 这里的核心问题是如何查找句柄,即下一步需要做怎样的规约。
- LR 分析即从左向右输入,输出最右推导。根据超前查看字符的情况不同来区别各种类型的分析器。
- LR(k) 自底向上语法分析的核心思路是移进-规约。移进操作代表让新符号进栈,规约操作将弹出栈顶的若干个元素并将其替换为一个元素后重新压进去。
- 我们需要一个自动机来回答:面对一个新的待移进符号,我们需要做移进还是规约,采用哪个产生式规约?
- 当你等待移进一个非终结符的时候,你就是在等待某个它展开后的串的开始元素。这种关系被称为等价。当一个项集中有移入非终结符的项时,我们通过不断求其等价项,最后收敛到若干移入终结符的状态,以明确每个项可以进行哪些移入动作。这种等价关系下形成的闭包操作被 LR 分析反复使用。
- LR(0) 分析不借助任何前瞻符号设计状态。作为代价,其中的规约动作必须占据一个完整的项集,无法具备更细致的条件判断。一旦某个项集可能发生规约动作,它就不能有移入或使用其它产生式的规约动作。
- SLR(1) 通过规约式(产生式)左侧符号的 Follow 集来细化规约条件。这不需要对 LR(0) 项集族进行修改,就能让其中多容纳一些规约边和转移边。
- LR(1) 分析用一种更加暴力的方式突破规约条件模糊的瓶颈。它直接引入前瞻符号(即此时输入流指针后方的 1 个 token)作为分析项的状态成员。从此,移进和规约操作不仅需要栈顶位置后方符号的条件,还需要前瞻符号与分析项要求的相等。这导致了状态数量的急剧增加。
- LALR(1) 分析让 LR(1) 在能力上略作牺牲,换来了空间上的大幅减小。它将排除前瞻符号外完全相等的两个项集合二为一,也因此增加了冲突发生的概率。