一文带你读懂语法分析(编译原理)
一、前言
最近刚考完编译原理,抽出一点时间,来把学的知识总结一下,以便之后的回顾。在这篇博客里可能会稍作全面地讲解语法分析(学生角度),涉及到FIRST集和FOLLOW集的求取、自顶向下的语法分析和自底向上的语法分析。其中自顶向下的语法分析会包含预测分析法和递归下降法以及LL文法的介绍;自底向上的语法分析则会包含算符优先、LR(0)、SLR(1)、LR(1)和LALR(1)的介绍。内容比较多,只需要学习部分内容的话,按需目录跳转即可。
二、前置知识
既然大家已经准备学习语法分析,那这里我就默认大家已经了解过编译原理的一些基本概念(如:语言、文法、产生式等基础概念)和学习过编译器前端的词法分析。
首先来看一下语法分析的概念:
语法分析(syntax analysis)是编译程序的核心部分,其任务是检查词法分析器输出的单词序列是否是源语言中的句子,亦即是否符合源语言的语法规则。
简单来说,语法分析就是读取词法分析产生的单词序列,看是否满足该语言的语法。比如c语言中,int double =
,这种不符合语言语法规范的错误就是在语法分析中检查出来的。
当然,语法分析远远不只有检查语句语法规范的功能,还涉及到符号表的管理、错误分析报告、为语义分析提供入口(语法制导翻译)等等。但在这里不是我们讨论的重点。
要想判断一个句子是否所属某一语言,一般可以通过两种角度:句子的产生(推导)和句子的识别(归约)。
产生句子的方式是从文法的开始符号开始,逐步推导出该单词序列,也称为自顶向下的语法分析;而识别句子的方式则是逐步将构成程序的单词序列归约为文法的开始符号,称为自底向上的语法分析。
三、自顶向下的语法分析
1、自顶向下的分析会遇到的问题
自顶向下分析实际上是一种试探性的过程,可能导致分析效率极低甚至失败。通常,在自顶向下的分析过程中会遇到二义性问题、左递归引起的无限推导和回溯问题。
a. 二义性问题
对于文法G,如果L(G)中存在一个具有两棵或两棵以上分析树(或最左推导)的句子(或句型),则称G是二义性的。
如果对于一个文法G,属于L(G)的一个句子s有多个最左推导,那在对句子s进行自顶向下的语法分析过程中将会在某一步推导遇到多个可选的产生式,此时便无法确定选用哪个产生式进行推导。若想继续分析下去,就要尝试某个产生式,如果在之后的推导过程中出现问题,就要回溯到这个分叉点,重新选择另一个产生式进行尝试,直至停止或接受。
如下图的产生式集合:
如果我们要对id+id*id
进行自顶向下的语法分析,则会出现二义性问题。如:
句子id+id*id
有两个最左推导,所以存在二义性问题。
出现二义性通常是因为文法提供的信息不全,比如上面的id+id*id
,常识来看乘的运算优先级要大于加,而在上面的文法中优先级是一样的,所以为了设置算符的优先级,我们可以提供更加详细语法信息。改造过的产生式集合如下:
在改造过的文法中,乘的优先级是大于加的,所以不会出现二义性问题。具体为什么乘的优先级大于加,可以参考后面的算符优先文法。
但同样,修改后的文法的产生式集合规模会大规模地增加。
b. 回溯问题
假设某个产生式的左部是E,则该产生式的右部称为E的候选式。如果对于非终结符E有多个候选式存在公共前缀,则自顶向下的语法分析程序便不能准确地选择产生式进行推导,只能进行试探,出现错误要回溯到上一个分支点,再选择其它的产生式进行尝试。
对于以下产生式集合:
如果我们要对(id)
进行自顶向下的语法分析,从文法的开始符号E开始推导。想要推导出句子的第一个符号(
,我们有1和2两个产生式可以选择,假设我们选择2作为最先推导的产生式,得到以下推导:
推导的产生式序列是:2->4,得到了(c)
,第一个(
可以匹配,但到id
的时候匹配错误,所以要回溯到上一步推导,选择其它可选择的产生式进行尝试。
我们可以发现,对一个句子的推导可能会产生大量的回溯,效率极低。
c. 左递归引起的无穷推导问题
如果对于一个文法G,存在以下情况,则称为文法G是递归的:
简单来说如果非终结符A能在有限步推导后得到A开头的句型,则称该文法是递归的。如果只通过了一步推导,称为直接左递归;如果通过了大于一步的推导,则称为间接左递归。
同上,对于以下产生式集合,进行对id+id*id
的自顶向下语法分析:
我们可以发现,产生式1是左递归的,因此在分析的过程中可能会出现一直使用产生式1进行推导的情况:
这样便会出现无穷推导。
2、解决部分问题
a. 二义性问题
许多文法的二义性是由于概念不清、语法变量的定义不明确导致的(比如:算符的优先级不明确、对某个非终结符定义不清等等),此时可以通过引入新的语法变量改写文法来解决。
实际上,二义性问题没有特别形式化的解法,需要一定的经验和创造力来对文法进行改造,所以这里不对其进行详细讲解。
b. 提取左因子
如之前所说的,若一个非终结符有多个拥有公共前缀的候选式,对该非终结符进行推导的时候就难以选择合适的产生式进行推导,因此会产生回溯问题。这里,我们便可以使用提取左因子的方式在一定程度上规避回溯问题。
提取左因子其实和简单数学中的提取公因式差不多。对某一非终结符A
的候选式,提取它们不为ε
的最大公共前缀C
和公共前缀后的文法符号串S
,引入新的语法变量A'
,并引入产生式A'->S
,用A'
替换掉候选式中的公共前缀后的文法符号串,使其出现这样的形式:A->CA' A'->S
。
上面的解释可能过于生硬,这里我们对一下产生式集合进行提取左因子的操作:
我们可以明显地发现产生式1和2的右部存在公共前缀(
,对非终结符E
,如果下一个要推导出的终结符是(
,我们只能对产生式1或2进行试探,出现错误后再回退选择另一个产生式进行尝试。因此我们可以提取产生式1和2右部的最大公共前缀(
,然后引入新的文法变量M
和新的产生式来消除产生式1和2的公共前缀。
进行完以上改造后,该产生式集合不再有某一文法变量的候选式存在公共前缀问题。对非终结符E
,如果下一个要推导出的终结符是(
,我们只能选择产生式1进行推导,这样就消除了公共前缀引起的回溯问题。
需要注意的是,提取左因子并不能完全消除回溯,只能消除由公共前缀引起的回溯。这个问题我们将在后面进行讨论。
c. 消除左递归
我们已经知道,左递归分为直接左递归和间接左递归,虽具体的消除方式不同,但思想还是一致的,那就是将左递归转换成右递归。
这里我们可以讨论下为什么转换为右递归即可消除无穷推导。实际上到底是左递归还是右递归会引起无穷推导,是与单词序列的读取方向有关。对于自顶向下的语法分析,我们默认是使用的最左推导,也就是每次关注尽量左的非终结符。所以每次推导完,关注的是推导后最左边的非终结符,而由左递归的定义可知,最左边的非终结符与产生式左部是一样的,所以一旦套起娃,便会出现无穷推导。
红框圈起来的是最左推导关注的目标非终结符。因此不难理解,对于右递归,最左推导不会出现无穷推导。
红框圈起来的是最左推导关注的目标非终结符。对右递归,即使进行推导,关注的非终结符也不会是引起无穷推导的因子。
同样的,如果我们使用的是最右推导,那右递归便是引起无穷推导的因素,需要将其转换为左递归。
言归正传,我们回到消除左递归的问题上。根据之前说的,我们可以将左递归转换成右递归。
这里改造看起来有点抽象,但实际上是比较容易理解的。通过分析原产生式,我们不难发现,产生的句子是这样的结构:
我们通过A'
构建一个左右递归转换的桥梁,便可得到等价转换。当然,如果β
为ε
,那我们就不用大费周折进行转换,直接调转即可。
通过以上的做法便可以消除直接左递归。消除间接左递归的方法也是将左递归转换为右递归,但我们需要进行一些略微复杂的操作。
看以下的产生式集合:
我们很容易发现A->Ac
是存在直接左递归的。然而,如果将A->Sd
带入S->Aa
,会得到S->Sda
,所以同样存在间接左递归。
为了消除间接左递归,我们首先要为每个非终结符编号,这里我们将A
编为1,S
编为2,然后按编号从小到大遍历产生式左部为目标非终结符的产生式,将其产生式右部的其它非终结符通过其它产生式进行替换。如果发现存在直接左递归,就使用上面的方法进行消除,不存在的话就跳过直到遍历完成。
在进行变量代换的时候不一定要带到底,当明显发现不存在左递归之后就可以适当地停止,比如上面图片中的步骤2,对A'
的替换就是适可而止的。
3、LL(1)文法
存在回溯问题的分析我们可以称之为不确定的自顶向下分析,而不存在回溯问题的分析我们称之为确定的自顶向下分析。然而,确定的自顶向下分析不能分析全部的文法,只能分析满足一定条件的文法。LL(1)文法就是能够满足确定的自顶向下分析条件的文法。
能够彻底消除回溯,实现确定的自顶向下分析的文法有以下要求:
- 无二义性
- 无左递归
- 任意一个语法变量A的各个候选式所能推导出的第一个终结符必须各不相同
前两点在之前已经讲解过了,而通过分析不难发现提取左因子是满足第三点的一个充分条件。如果某一非终结符的候选式存在公共前缀,那很明显是不满足第三点的。
确定的自顶向下分析首先从文法的开始符号出发,每一步推导都根据当前句型的最左非终结符A和当前输入符号a,选择A的某个候选式α(alpha)来替换A,并使得从α(alpha)推导出的第一个终结符恰好是a。但不能存在多个候选式推导出的第一个终结符是a,这样就不能确定选择哪个产生式进行推导。
为了表示第一个终结符这个概念,我们要引入FIRST集这个概念。
a. FIRST集的概念以及求解
FIRST集的概念如下:
简单来说就是某个串的FIRST集就是这个串能推导出的第一个终结符的集合(该终结符必须在最左边)。
我们可以给出以下规则:
求非终结符的FIRST集的时候主要关注产生式左部的符号和产生式右部的最左侧符号。比如我们要求A
的FIRST集,先找到所有左部为A
的产生式,然后遍历这些产生式的右部:如果右部第一个符号是终结符,那么把这个终结符加入到A
的FIRST集中;如果右部是ε
,把ε
加入到A
的FIRST集中;如果右部第一个符号是非终结符B
,将该非终结符B
的FIRST集加入A
的FIRST集中,但需要注意的是,如果B
的FIRST集存在ε
,向FIRST(A
)中添加FIRST(B
)的时候就要去掉ε
,然后加上下一个符号C
的FIRST集,如果FIRST(C
)还有ε
就继续向后推演,以此类推。
对于最后一种比较复杂的情况,我们有以下结论:
对于这种比较复杂的情况,有没有ε
取决于连续的最后一个FIRST集含ε
的符号是不是产生式右部的最后一个符号。因为只有产生式右部所有符号都有可能推导出ε
,产生式左部的符号才能推导出ε
。
需要注意的是,我们认为一个终结符的FIRST集就是它本身。
b. FOLLOW集的概念以及求解
在LL(1)文法中,如果存在A->ε
这样的产生式,就代表在推导过程中,我们可以直接利用这种空产生式消掉A
。比如当前串是Aab
,我们可以利用A->ε
将A
推导为空,串将变为ab
。
在这种存在空产生式的情况下,仅仅利用FIRST集就明显不够了。因为对于某个要推导出的终结符a
,我们可以将现在的待推导非终结符推导为空,然后利用下个符号最左推导出a
(即判断下个符号的FIRST集是否包含该终结符a
)。
因此我们便要引入FOLLOW集这个概念,来记录某个非终结符后可能出现的第一个终结符。
我们可以给出以下的规则:
求FOLLOW集的时候主要关注产生式右部的符号。比如我们要求A
的FOLLOW集,找到所有产生式右部为存在A
的产生式,然后找A
后面的第一个符号:如果A
后第一个符号是终结符,那么把这个终结符加入到A
的FOLLOW集中;开始符号的FOLLOW集包含#
(#
是终止符,代表一个句子的末尾,可以看做EOF,在有的材料中是$
);如果A
是最后一个符号,要将该产生式左部非终结符E
的FOLLOW集加入A
的FOLLOW集;如果A
后第一个符号是非终结符B
,将该非终结符B
的FIRST集(要去除ε
)加入A
的FOLLOW集中,但需要注意的是,如果B
的FIRST集存在ε
,要加上下一个符号C
的FIRST集(要去除ε
),如果FIRST(C
)还有ε
就继续向后推演,以此类推,如果直到最后一个符号的FIRST集还包含ε
,要将该产生式左部非终结符E
的FOLLOW集加入A
的FOLLOW集。
需要注意的是,我们认为终结符是没有FOLLOW集的。
c. SELECT集的概念以及求解
引入FIRST集和FOLLOW集之后,我们便可以判断LL(1)文法的条件3(任意一个语法变量A的各个候选式所能推导出的第一个终结符必须各不相同)是否满足了。
此时我们引入SELECT集(可选集):
如果一个文法满足对相同左部的产生式的SELECT集互不相交,那这个文法便满足任意一个语法变量A的各个候选式所能推导出的第一个终结符必须各不相同(条件3)。
SELECT集其实也是比较好理解的,也是一个非终结符能推导出的最左终结符的集合,但有对空产生式的优化。
d. 判断文法是否是LL(1)文法的步骤
1.提取左因子;
2.消除左递归;
3.求解各个产生式的SELECT集,如果左部相同的产生式的SELECT集没有相交,则为LL(1)文法。
e. 判断文法是否是LL(1)文法的例子
这里我们们给出一个例子:
首先,我们遍历产生式,发现没有某一非终结符的候选式存在公共前缀,所以不需要提取左因子。
但我们发现产生式E->E+T
和T->T*F
存在左递归,所以我们需要引入E'
和T'
来消除左递归:
为了判断相同左部的各个产生式的SELECT集是否相交,我们要先求出各个非终结符的FIRST集:
然后便可以求各个非终结符的FOLLOW集:
然后求各个产生式的SELECT集:
然后判断相同左部的产生式的SELECT集是否相交:
发现相同左部的产生式的SELECT集不相交。此时满足了d部分中的三个条件,此时我们获得了LL(1)文法。
4、预测分析法
a. 预测分析法的原理
经过上面的讲解,我们已经能够判断一个文法是否是LL(1)文法,但光有文法是无法进行语法分析的,需要相应的分析方案才行。预测分析法便是常用的自顶向下的语法分析方法。
要实现预测分析法,我们要先了解其结构。预测分析器主要由符号栈、预测分析表和输入缓冲区组成。符号栈保存了当前推导出的句型、预测分析表定义了推导的规范、输入缓冲区保存了待推导出的符号序列。
预测分析表可以使用二维数组表示,行标代表要进行推导的非终结符,列表代表要推导出的最左终结符,单元内的数据表示选用的产生式。Table[A][b]代表:对于非终结符A
要想推导出最左终结符为b
的短语,要采用Table[A][b]中的产生式进行推导。
这里给出一个简单的预测分析表:
构造预测分析表的方法也比较简单,先将所有非终结符放在行首,将所有终结符(要带上终止符#
)放在列首。然后对于产生式左部为非终结符A
的产生式,如果终结符x
属于该产生式的SELECT集,则Table[A][x]
填上该产生式右部;如果产生式是空产生式(A->ε
),则对于该产生式的SELECT集中的终结符x
,Table[A][x]填上该空产生式右部(->ε
)。
该预测分析器的总控也比较简单,初始状态将开始符号S
压入符号栈,将待读句子放入缓冲区。然后根据符号栈栈顶非终结符A
和缓冲区最左符号a
查预测分析表,如果Table[A][a]
为空,则说明分析错误,该句子不符合语法规范;如果Table[A][a]
有多个产生式,就说明该文法不是LL(1)文法;如果只有一个产生式,就利用该产生式进行推导,然后符号栈弹出一个符号(产生式左部),将该产生式右部逆向入栈(因为是最左推导),比较符号栈顶和缓冲区最左符号是否匹配,如果匹配,符号栈和缓冲区弹出相匹配的符号;如果不匹配,继续查预测分析表进行推导,直到结束或接受。
b. 预测分析法的例子
这里我们扩展了上面判断是否为LL(1)文法的题目,要求给出预测分析表和对于一个句子i+i*i
的分析过程。
为了判断该文法是否为LL(1)文法,我们检查各个非终结符的候选式是否存在公共前缀(是否需要提取左因子)和是否存在左递归。发现不需要提取左因子但需要改造文法消除左递归。
然后为了判断相同左部的产生式的SELECT集是否相交。我们先后求了各个非终结符的FIRST集、各个非终结符的FOLLOW集、各个产生式右部的FIRST集和各个产生式的SELECT集。
最后得到了以下SELECT集,并发现我们改造后的文法是LL(1)文法:
到此为止的步骤和上面判断是否是LL(1)文法的题是一样的。之后我们便可以构造预测分析表。实际上,构造预测分析表只需要各个产生式的SELECT集。
我们先将所有非终结符放在行首,将所有终结符(要带上终止符#
)放在列首:
然后对于产生式左部为非终结符A
的产生式,如果终结符x
属于该产生式的SELECT集,则Table[A][x]
填上该产生式右部;如果产生式是空产生式(A->ε
),则对于该产生式的SELECT集中的终结符x
,Table[A][x]填上该空产生式右部(->ε
)。
得到了预测分析表后我们便可以对句子i+i*i
进行分析了。首先我们构建分析器的初始状态:
然后开始分析并记录过程:
到此,这道题就解答完成了。
5、递归下降法
递归下降法是为每个非终结符设置一个处理子程序。如对非终结符A
有产生式:
因此我们可以发现,处理子程序是要求可以递归的。
现在我们有正则定义式A->aBC
,我们用伪代码简单编写其处理子程序:
procedure A:
begin
match 'a'; // 匹配终结符 a 并向后移动指针
call procedure B; // 调用 B 的处理子程序
call procedure C; // 调用 C 的处理子程序
end
递归下降分析法就是通过递归调用非终结符的处理子程序来进行分析。这种方法简单、直观、可读性好并便于扩充;但同时效率低(使用递归)、处理能力有限而且难以自动生成。
这里不再过多赘述递归下降分析法,如果之后有时间,我可能会再来补充。
四、自底向上的语法分析
1、自底向上的语法分析的介绍
之前,我们讲解了自顶向下的语法分析的思想和设计思路。之前说过,只有确定的自顶向下语法分析才能彻底消灭回溯问题,带来较为高效的分析过程。然而,确定的自顶向下分析并不适用于所有的文法,所以便出现了自底向上的分析方法。
与自顶向下的分析方法不同,自底向上的语法分析方法从输入串出发,反复利用产生式进行归约,如果最后能得到文法的开始符号,则输入串是相应文法的一个句子;否则输入串存在语法错误。
自顶向下分语法分析一般是使用最左推导,而自底向上的语法分析是使用最左归约(最左归约是规范归约;最右推导是规范推导)。在自底向上分析过程中,寻找当前句型最左的和某个产生式的右部相匹配的子串(句柄),用该产生式的左部符号代替该子串(最左归约)。
大家可能会对某些概念有些模糊,所以在这里进行简单的讲解。
句型是能从一个文法的开始符号推导出的一个可以包括非终结符的子串,如TYPE VARIABLE = 5;
,这是一个声明语句,TYPE
和VARIABLE
都是非终结符;而句子是能从一个文法的开始符号推导出的一个只含终结符的子串,如int a = 5;
,这是一个声明语句,只包含终结符。所以不难理解,句子一定是句型,而句型不一定是句子。
短语则是可以进行归约(但这个归约可能并不直接)的一个符号串。在语法树中的体现则是,以一个节点为树根的子树的叶子节点的顺序结合。如果这棵子树只含根叶两层节点(高度为2),则又称直接短语。句柄是当前句型的最左直接短语。
不难理解的是,自底向上的语法分析的核心问题便是怎样寻找句柄,所以我们可以说,不同的自底向上语法分析方法实质上便是不同的寻找句柄的方法。
我们常使用的自底向上的语法分析方法便是移进-归约分析法,该方法的分析程序主要包含了符号栈、输入缓冲区和分析表等,我们从输入缓冲区逐渐将单词移进到符号栈,并利用分析表在符号栈栈顶找到句柄进行归约。
在这里,我们可以将移进-归约分析法再度细分为优先法和状态法,而我们对自底向上的语法分析的学习也是以此为基础展开的。
2、优先法
a. 优先法的介绍与思想
优先法的基本思想是根据归约的先后次序为句型中相邻的文法符号规定优先关系,我们可以通过符号间的优先关系来确定句柄。
比如在简单数学运算的文法中,对a+b*c
进行分析。在最简单的最左归约中,我们应该先对句柄a+b
进行归约。但很明显,*
的优先级要大于+
,所以通过终结符的优先关系我们可以得知,实际上应该先对b*c
进行归约。
然而,与确定的自顶向下的语法分析相似的是,优先法也只能对满足一定条件的文法进行分析,简单优先文法和算符优先文法便是其二。
下面简单说明下简单优先文法和算符优先文法的概念:
简单优先文法:如果各文法符号之间的优先关系互不冲突(即至多存在一种优先关系),则可识别任意句型的句柄
算符优先文法:仅对文法中可能在句型中相邻的终结符定义优先关系,并且各终结符对之间的优先关系互不冲突。
这里,我们对优先法的讲解只关注基于算符优先文法的算符优先分析法。
b. 算符优先分析法
算符优先分析法在分析表达式的过程中尤为有效。其思想是将文法中的终结符视为算符,借助算符之间的优先关系来决定句柄。
我们可以如下定义算符间的优先关系:
但需要注意的是,这里的比较是涉及到方向的,可以看做矢量。比如* > +
和+ > *
是不冲突的,前者是说左边的*
优先度大于右边的+
,而后者是说右侧的*
优先度小于左侧的+
。这里给个例子,在a*b+c
中要先归约a*c
,而在a+b*c
中要先归约a+b
。所以冲突只存在于同向中,如* > +
和* < +
。
在表达式A+B*C
中我们要确定运算顺序,只需要比较运算符的优先级,和运算对象没有关系,所以这里我们引入算符文法的概念:如果文法 G 中不存在具有相邻非终结符的产生式,则称为算符文法。此时,只存在运算对象和运算符相邻,而不存在运算对象和运算对象相邻,更有利于我们确定运算顺序。
如下图不存在相邻的非终结符,所以是算符文法。
但即使满足了算符文法的条件,我们还是无法对其句子(如:id+id*id
)进行有效的分析。因为算符文法本身是没有体现算符间的优先级的。所以我们要定义算符之间的优先关系。
对于优先关系的确定,我们给定以下规则:
如果一个算符文法的任意两个算符(终结符)在同一方向上只存在一种优先关系,则称该文法为算符优先文法。
我们简单判断下以下文法是不是算符优先文法。
如果我们用产生式E->E-E
替换掉E->E+E
中右部第一个E
,我们可以得到E->E-E+E
,此时- > +
;如果我们用E->E+E
替换掉E->E-E
中右部第二个E
,我们也可以得到E->E-E+E
,但此时- < +
。所以对算符+
和*
在同一方向上存在不同的优先级,所以该文法不是算符优先文法。
回到正题,为了语法分析器的高效运行,我们需要数据结构来存贮不同算符间的优先关系。这里我们通常使用优先矩阵和优先函数来记录。
c. 获取算符间的优先级关系
在构造优先矩阵或优先函数前,我们需要先获取算符间的优先级关系。先前我们已经知道了算符优先级的规则与定义,但并不知道具体地求解方式,为了更好地求解优先级关系,我们要引入FIRSTOP和LASTOP的概念。
FIRSTOP又称最左终结符集,是一个非终结符可推导出的最左终结符(不是最左的符号,是最左的终结符)集合;LASTOP又称最右终结符集,是一个非终结符可推导出的最右终结符(不是最右的符号,是最右的终结符)集合。
下面给出FIRSTOP和LASTOP的具体求法:
在这里我们不用考虑ε
带来的影响,因为算符优先文法是不存在空产生式的。
然后我们便可以通过FIRSTOP和LASTOP来确定算符间的优先关系:
d. 优先矩阵的构建
先前我们已经求得了算符之间的优先级关系,现在我们可以通过优先级关系来构建优先矩阵。
优先矩阵是一个二维矩阵,第一行和第一列是所有的终结符(包括结束符#
),Table[x][y]=op
的意义是存在Table[x][0] op Table[0][y]
的优先关系。
这里给出一个例子的求解过程,对于以下算符优先文法的产生式集合,求解其优先矩阵:
首先我们要求FIRSTOP和LASTOP集合:
然后遍历产生式集合,寻找优先级关系:
然后我们便可以构造优先矩阵:
#
的优先关系可以通过增加语法变量S’
和产生式S’->#S#
来完成;当然我们也可以认为#
的优先级小于其它任何算符,在上图中我们便是这样做的,这种做法虽然不是十分严谨,比如#
可能和某个算符不存在优先级关系,但我们认为#
的优先级更小。但这样一般不会影响求解的结果。
e. 优先函数的构建
我们可以发现,如果使用优先矩阵存储算符间的优先关系,会占用较大的空间,为O(n^2)
;所以我们可以使用优先函数来减少占用的空间资源,优先函数占用的空间为O(2n)
。
优先函数的构建遵循以下规则:
在求优先函数之前,我们可以先求出优先矩阵,比较直观但相对麻烦;我们也可以求出FIRSTOP和LASTOP后直接通过优先关系画出有向图,但不直观。现在,我们对以下算符优先文法求优先函数:
这里我们省略具体的求解过程,并且先求出优先矩阵,使过程更加明显。以下是其优先矩阵:
然后通过优先矩阵构建有向图:
然后找到以每个点为起点的最长路径长度,获取优先函数:
优先函数虽然能明显降低存储算符间优先关系的空间消耗,但同时也存在一些问题。比如上题中的id
与id
不能比较,但通过观察优先函数,f(id)=4<g(id)=5
,所以优先级id<id
。这是因为将偏序关系转化为了全序关系,所以原来无法比较优先级的算符变得可以比较了,这使得程序的检错能力降低,但并不一定会影响分析的进行。
f. 利用算符优先分析方法进行语法分析
算符优先分析方法是移进-归约分析法的一种,所以我们通过不断向符号栈移进缓冲区中的符号,并通过优先关系寻找句柄,以归约。我们一般通过<
关系寻找句柄头,通过>
关系寻找句柄尾。
我们要知道的是,无论是使用优先矩阵还是优先函数优先关系,其分析过程都不会有太大变化,因为我们只是从存储结构中获取优先关系,,假设我们分析串#a#
的优先关系,利用优先矩阵是通过比较Table[#][a]
中的符号来比较#a
的优先关系,通过比较Table[a][#]
中的符号来比较a#
的优先关系;利用优先函数则是通过比较f(#)
和g(a)
的大小来比较#a
的优先关系,通过比较f(a)
和g(#)
的大小关系来比较a#
的优先关系,其中值越大,优先程度越高。此时我们应该可以获得# < a > #
的优先关系,此时我们可以发现a
是句柄,要对其进行归约。
这里我们给出一个例子,在以下文法的基础上对id+id
进行分析:
这道题在上面已经解答过,所以这里直接沿用之前的结果,以下是优先矩阵:
分析过程如下:
到此,对id+id
的分析就结束了。之前我们说的是通过<
找句柄头,通过>
找句柄尾,但在实际分析过程中是比较直观的,被< >
括起来的便是句柄。
但我们同时也能在分析过程中发现不少问题,比如在第六步中被< >
括起来的是+
,我们却对F+F
进行归约;同样在第六步中,虽然待归约串是F+F
,但我们却使用E->E+T
进行归约,产生式右部对不上。
总结一下就是:有时未归约真正的句柄;不是规范归约;归约的符号串有时和相应产生式的右部不同。
出现以上的问题的原因是算符优先文法未定义非终结符之间的优先关系,不能识别由单非终结符组成的句柄;而在算符优先分析法中的句柄和我们之前所说的句柄不是一个句柄。前面说的句柄是最左直接短语,而在算符优先分析中的句柄是最左素短语。最左素短语是至少含有一个终结符,而且不递归含有更小的素短语的短语。这里不再详细展开素短语的求解方法,但其实和求短语是同一种方法,找子树,只不过每求得一个短语后要对该短语进行判断,是否至少含有一个终结符,是否不含有更小的素短语,如果都满足了,那这个短语是一个素短语。
3、状态法
a. 状态法的介绍与思想
在之前的讲解中我们学习了移进-归约方法中的优先法,它可以有效地进行自底向上的语法分析,但同时,它存在着诸多局限,如不能处理所有的文法,算符优先分析无法处理非终结符之间的优先关系,所以如果产生式中存在相邻的非终结符(非算符文法),将难以分析,而且终结符之间也只能有一种优先关系;存在查不到的语法错误;在发现句柄(最左素短语)尾后需要向前搜索找句柄头…所以优先法很难广泛应用在语法分析中,更多的是在表达式分析中使用。
此时,我们便可以使用状态法进行自底向上的语法分析。该方式是使用确定的有穷状态机(DFA)来进行语法分析,通过当前状态、移进单词和目标动作推进分析。
LR(k)分析法便是状态法的一种实现。L代表从左向右读入符号、R表示最右推导的逆过程(即规范(最左)归约)、k是归约时超前读入的字符个数以便确定使用的产生式。当k=1时便可以满足绝大多数高级语言编译程序的需要。
LR分析解决了算符优先分析存在的诸多问题。LR的归约过程是规范归约过程;适用范围广,可识别所有上下文无关文法描述的程序设计语言;分析速度快,准确定位错误。但也存在一点小问题,便是构造分析表的过程较为复杂,所以多采用自动生成的方式(YACC)。
为了更好地表示当前状态,我们引入项目这个概念。项目在形态上就是产生式加上一个点,而这个点代表了当前识别的程度。如A->.BC
,这个项目就代表了还没有读取任何符号,BC
是待读符号;而A->B.C
,这个项目就代表了已经读入了B
,C
是待读符号;而A->BC.
,这个项目就代表了已经读入了BC
符号,没有待读符号,可以进行归约了。
简单来说,项目中点的左边便是已经读入的符号,右边是还没有读入的符号,如果点在最右边就代表已经读取完,可以进行归约了。
首先我们先介绍一下LR分析程序的组成部分。LR分析程序由符号栈、状态栈、分析表、缓冲区、总控程序等部分构成。符号栈存储当前分析过的单词、状态栈记录当前状态、分析表记录状态与待读符号决定的动作、缓冲区保存待读符号、总控程序控制整个分析过程。
符号栈中的符号串是当前句型的活前缀之一。活前缀是不含相应句型的句柄右部的任何符号的前缀。如果已得到待规约的句柄,该活前缀也称为可归前缀。
所有活前缀构成一个正则语言,对文法的识别就变成利用有穷状态机对规范句型的活前缀的识别的过程。
有穷状态自动机的状态转移函数包括两个表:动作表(action表)和转移表(goto表),两个表统称为LR分析表:
1、 ACTION表示当前状态面临输入符号时应采取的动作;action[S,a]=m
的意义是对当前状态S
(状态栈栈顶状态),当前输入符号为a
时进行的语法动作,该动作m
包括移进(移进后进行状态的转换(shift),简写是sx
,s
代表转换,x
目标状态编号)、归约(归约是reduce,简写为rx
,r
是reduce的简写,x
是利用编号为x
的产生式进行归约)、接受(acc
,分析结束)和出错。
2、GOTO表示当前状态面临文法符号(非终结符)时应转向的下一个状态。goto[S, X]=x
意味着当栈顶状态为S
,且分析器刚归约出语法变量X
时要转向的下一个状态x
。
与优先法相同的是,状态法的关键也是分析表的构建,而分析表是驱动分析的基础。LR分析法在错误检测的强度从低到高是LR(0)->SLR(1)->LR(1),而LALR(1)是对LR(1)的粗化,LR(1)是LALR(1)的细化。它们的总控程序并没有大的差异,只是在分析表的求解过程中有差异。
总控程序的分析流程如下:
为了使过程更加直观,这里我们给出一个例子来展示总控程序的分析流程。由于我们还没有学习构建分析表的方法,所以这里直接给出对应文法的分析表。
这里我们直接给出该文法的分析表,为了使其尽量直观,err不再填入分析表,用空来替代:
以下便是分析过程:
以上便是总控程序的分析流程,可能在不同材料中,分析过程的细节会有略微不同,但分析规则和分析表的使用是相同的。而在不同的LR分析法中,除了分析表的构建不同,其它步骤都是相同的,所以在介绍不同的LR分析法的时候,只会着重介绍分析表的构建方法,总控程序的流程不再赘述。
b. LR(0)分析法
从上面的LR分析法的定义中我们不难理解,LR(0)中的0意味着可以归约的时候不需要超前搜索符号来确定归约使用的产生式,所以可以归约的时候就一定要归约。这句话现在可能不好理解,但看完下面的就很容易理解。
我们知道,LR分析法是使用确定的有穷状态机(DFA)来构造分析表。所以我们有必要了解一下该DFA的组成。
之前我们引出了项目的概念,在LR(0)分析法的DFA中,我们要使用到LR(0)项目,LR(0)项目的结构和之前说的项目结构是一样的,由产生式和点组成,点左边是识别过的符号,右边是待识别的符号。
由数个项目组成的集合称为项目集,而项目集便是DFA中的状态。我们称DFA中所有状态的集合为项目集规范簇。
下面是一个DFA的示意图:
了解了LR(0)的DFA的基本概念后,我们就可以对产生式集合求DFA了。这里我们引入闭包的概念。
现有一个状态中存在项目S->.AB
且存在产生式A->ab
。该项目点后的第一个符号是A
,也就是等待归约出A,而又存在A->ab
,也就是ab可以归约出A。我们转化下思维,可以通过等待移进ab,然后用ab
归约出A
的方式得到A
。所以我们要加入新的项目A->.ab
。
如此描述可能并不好理解,所以这里直接给出闭包的求取方式:
我们将这个求闭包的函数设为CLOSURE(C)
。
DFA包括了一系列的状态和它们之间的转换关系,所以我们需要定义一个获取后继状态的函数GO(C, X)
,返回项目集(状态)C
在识别到符号X
后转入的新状态。
这里直接给出GO(C, X)
的规则:
此时我们便得到了状态转换的方法。得到一个DFA的必要条件是确定一个初始状态和已知获取新状态的方法,此时我们已经满足了后者,现在只要确定DFA的初始状态便可以得到该DFA。
为了使文法有唯一出口,我们要引进拓广文法这一概念。对文法G=(V, T, P, S)
,添加新的开始符号S'
,并将产生式S'->S
加入产生式集合得到新的产生式集合P'
,文法G'=(V, T, P', S')
便是G
的拓广文法。通过构造拓广文法,我们可以使文法有唯一的出口(接受点),也就是在分析表中只有一个单元是acc
,可以使分析更加稳定直观。
而DFA的初始状态I0
便是拓广文法新加的产生式S'->S
所产生的项目S'->.S
的闭包。也就是CLOSURE({S'->.S})
。
现在我们已经满足求DFA的条件了,接下来给出其具体过程。需要注意的是,我们可以通过项目集规范簇和GO(C, X)
函数来体现DFA,项目集规范簇记录所有的状态,通过GO(C, X)
来获取状态间的关系。
此时我们便获取了DFA,可以构建分析表了:
以上是LR(0)分析表生成程序的流程,当我们比着状态转换图填表的时候更为简单。我们先初始化分析表,即上面步骤的1,然后如果有从状态Ix
到状态Iy
的连线,线上标着M
。如果M
是终结符,则action[x][M]=sy
;如果M
是非终结符,则goto[x][M]=y
;如果状态Iz
中有项目S'->S.
,则action[z][#]=acc
。
得到分析表后,便可以通过a部分中的总控程序来对句子进行语法分析了。
下面给出一个球LR(0)分析表的例子:
首先获得其拓广文法:
然后画出其DFA:
最后根据DFA获取LR(0)分析表:
然而LR(0)分析法不能分析所有的文法。因为LR(0)在可以归约的时候并不会前瞻任何符号来确定归约使用的产生式,所以当一个状态存在可以归约的项目(点在最右边)的时候,一定会归约。这就导致了这个状态在分析表action部分中对应的一行全是rx
,意思就是无论下一个要读取的符号是什么,我都要先归约,也就是这部分最开始所说的可以归约的时候就一定要归约。这样的性质会导致移进-归约冲突和归约-归约冲突,前者表示当一个状态中存在待归约项目和待移进项目的时候无法选择是进行归约还是进行移进,后者表示当一个状态中存在多个待归约项目无法确定先归约哪个。当一个文法的DFA中的每一个状态都不存在移进-归约冲突和归约-归约冲突,那这个文法便是LR(0)文法。
假设一个文法的DFA中的某个状态如下:
后两个项目存在归约-归约冲突,而第一个项目和后两个项目存在移进-归约冲突。此时LR(0)分析法就不足以支持分析该文法,我们需要使用检错能力更高的SLR(1)文法来进行分析。
c. SLR(1)分析法
SLR(1)分析法是对LR(0)分析法的优化,同时也是LR(1)分析法的简化,在可以进行归约的时候会前瞻一个符号来判断是否进行归约或选择哪个产生式进行归约。
SLR(1)分析法构建DFA的方法和LR(0)分析法一模一样,只是在构建分析表的时候略有差别。SLR(1)分析法在某个状态存在项目A->ab.
的时候,只有待读符号属于FOLLOW(A)的时候,才用产生式A->ab
进行归约。
其实原理也比较简单,假设当前串是ab.cd
,目前的状态中存在项目A->ab.
,待读符号是c
,如果c
不在A
的FOLLOW集中,那在该文法能产生的所有句型中,c
就不可能出现在A
的后面,就算我们使用产生式A->ab
进行归约得到A.cd
,最终也只会得到一个错误报告。所以我们已经可以预测到用这个产生式进行归约是一定行不通的,那我们为什么还要进行无谓的尝试呢,将这个机会让给其它的待归约项目或待移进项目不是更好吗?这便是SLR(1)的思想,缩小了可归约的范围。
在整个流程中,只有构建分析表的过程SLR(1)与LR(0)稍有不同,而在只存在于有待归约项目的情况下:
只有划红线的地方与LR(0)有区别。所以在SLR(1)的构建分析表前,我们要求所有非终结符的FOLLOW集。
下面给出一个例题:
首先求文法G
的拓广文法,其产生式集合编码过后如下:
然后求每个非终结符的FOLLOW集:
之后画出DFA:
可以发现在状态I1
、I2
和I9
中存在移进-归约冲突,所以在构建分析表的时候要格外注意:
此时SLR(1)分析表的构建就完成了。SLR(1)分析法已经可以满足分析部分高级语言的语法规则了,但仍然不能满足所有的文法。如对以下产生式集合构建DFA:
可以得到:
我们可以发现状态I2
中=
属于R
的FOLLOW集,所以存在移进-归约冲突。原因是SLR(1)分析法在归约的时候没有关注当前的语境。就像上图中的状态I2
,判断是否要用R->L
进行归约的时候,虽然=
属于R
的FOLLOW集,但在当前语境(上下文)中,=
是不可能出现在R
的后面的。这种矛盾在该文法中并不明显。我们通过其它的例子来进行展示。
假设我们定义以下的语法规则:
// 有如下的判断语句
if (a < b) :
xxx;
// 有如下的循环语句
while (a < b) {
xxx;
}
此时我们可以如下简单定义该部分的产生式:
其中1是判断语句的产生式,2是循环语句的产生式,语法变量C
代表判断表达式(如a<b
)。现在我们不拘泥于这部分产生式的正确与否,单独考虑以下这段代码的语法分析:
if (a < b):
S
代码中判断体中的代码块我们直接用语法变量S
表示。假设分析到了IC.:S
这个程度,当前语境是判断语句,虽然{
属于C
的FOLLOW集,但很明显,在当前语境下{
是不可能出现在C
的后面的,只有在循环语句的语境下{
才可能出现在C
的后面,而在判断语句的语境下只有:
可能出现在C
的后面。实际上,FOLLOW集仅仅是针对文法来定义的,对于某个语境来说,某一非终结符后可能出现的终结符集合只能是该非终结符的FOLLOW集的子集。而正是因为SLR(1)分析法没有考虑当前语境,只通过FOLLOW集来判断是否归约,所以会出现这样的问题。为了使归约的范围再度缩小,我们可以使用考虑当前语境(上下文)的LR(1)分析法。
d. LR(1)分析法
先前我们说过LR(1)分析法是考虑当前所在语境的分析法,所以为了在状态中导入当前语境,我们需要改变项目的结构,改变后的项目我们称之为LR(1)项目。
LR(1)项目在LR(0)项目的基础上添加了展望符集合,展望符集合包含了可能出现在项目左部非终结符后的终结符集合。正是通过展望符集合来体现当前语境。需要注意的是,展望符集合只在归约的时候发挥作用,在移进等其它情况下没有一点作用。
需要注意的是,展望符集合一定是当前项目左部非终结符的FOLLOW集的子集。
在LR(1)项目的作用下,LR(1)分析法构建DFA的方式也会有变化。比如对于初始状态中求闭包前的初始项目S'->.S
,我们需要更新为S'->.S , #
,因为S'
后只可能出现#
。
求闭包的方法也略有改变:
也就是在产生新的项目的时候要计算可能出现在项目左部非终结符后的终结符集合。而上面δ
属于FIRST(βχ)
是因为β
有可能是空。
对应的,获取新状态的方法GO(C, X)
也有所改变:
但项目集规范簇的求法几乎没有改变。
而之前说过,在构造分析表的方法上LR(0)、SLR(1)和LR(1)三种方法只在规约时略有不同,而在LR(1)分析法中,对可以归约的项目,只有对待读符号是该项目展望符集合中的终结符才使用该项目对应的产生式进行归约。
这里对c部分中使用SLR(1)分析法出现冲突的文法使用LR(1)文法构建DFA:
得到的DFA如下:
到此LR(1)分析法也就讲完了。虽然LR(1)分析法考虑当前语境,能将归约的范围缩到最小,有最强的检错能力,但相应的会付出相当的代价。对于一个文法,若SLR(1)的项目集规范簇有100个状态,对于LR(1)的项目集规范簇可能会有1000个状态,大大提高了分析表的状态,增大了YACC自动生成分析表的开销和分析的开销。为了尽可能降低分析表的规模,我们可以使用LALR(1)分析法。
e. LALR(1)分析法
LR(1)分析法产生的DFA状态比较多,我们可以通过合并一些可以合并的状态来降低分析表的规模。在这里我们选择合并同心闭包。
同心闭包是具有相同的LR(0)项目的LR(1)项目闭包。它们在结构上只有展望符集合不同。
我们可以合并那些不会带来冲突的同心的LR(1)闭包并重构分析表。
对于具体的例子这里暂时鸽一下,之后有时间的话再更新,大家可以去别的资料查找一下。
五、总结
在本篇博客中主要讲解了编译原理中的语法分析,由于最近事情比较多,所以对于一些部分可能讲解得不是很细致(比如LALR(1)分析),但在总体的讲解结构上应该是没有问题的,对于讲解不细致的地方大家可以自行查询或告诉我再补充。最后希望大家能有所收获,谢谢观看!