递归 - 词法分析与语法分析的分界
一般来说,决定词法分析和语法分析的界限是是否需要递归。
词法分析是将输入的符号流转换成一个个独立的token。比如说,996是个数值型或者更精确一些整型的token。
这个token解析的过程,它前面是什么符号,后面是什么符号,完全没有关系。
token也不存在递归的可能性,token之间相互独立,不可能是嵌套的关系。
所以,词法分析可以用正则表达式来实现。只要一个串符合[0-9]+,我们就可以确定地认为,这是一个整数。
词法分析可以从左到右,完全线性的方式实现。它不需要树的结构,自然没有递归的需求。
而语法分析就有不同,比如一个表达式,它可能是"7 * 24",也可能是"(8+1) * 5",或者更复杂的组合。这样的表达式就需要用一棵树的结构来表示。
比如我们这样定义表达式:
表达式 = 数字
表达式 = 表达式 + 表达式
表达式 = 表达式 - 表达式
这样,表达式"1+2+3+4"就可以表示成下图这样的一棵树:
自顶向下和自底向上
针对上面的图,我们有两种分析的办法,一种是自顶向下,一种是自底向上。
为了大家看起来方便,我们不妨把上面的式子改成前缀表达式:
表达式 = 数字
表达式 = + 表达式 表达式
表达式 = - 表达式 表达式
自顶向下就是,先扫描到一个"+"的token,然后分别对它后面的两个token进行分析。第一个token是数字,不需要递归了。然后去看第二个表达式,发现还是一个"+",于是递归去分析+,以此类推。
总结起来,自顶向下的思想是,先预测是个什么,然后按照期待去找下一个token。
而自底向上的思想不是这样,它是从左到右把token都读进来,然后去尝试找目前已经读进来的token们符合哪个式子的定义。
以上面的例子为例:
第一步,读到"+" 不符合上面三个式子的任何一个,继续向右读token。这个过程叫做shift,中文译成“移进”。
第二步,"+ 1",数字1匹配了第一条,变成+ 表达式,不能继续匹配了,继续读token
第三步,"+ 表达式 +",什么鬼,不匹配,继续读。
...
第五步,读到"+ 表达式 + 表达式 +",还是找不到可以匹配的式子,继续向右读。
...
第七步,读到"+ 表达式 + 表达式 + 表达式 4",4匹配了第一条,变成表达式, "+ 表达式 表达式"匹配了第二条,也变成表达式。这种操作叫做“归约”-reduce。这一步归约了"+ 3 4"
第八步,归约"+2 表达式"
第九步,归约"+1 表达式"
LR分析器
自底向上的方法的重要方法是LR方法,LR分析器的构造一般如下图所示:
LR分析器是以一个状态机的方向来运作的。
这其中有两个重要的表:
- 一个是主要处理移进的Action表
- 另一个是主要处理归约的Goto表
Action表的输入有两项:
- 一是当前的状态,从状态栈顶可以取到;
- 另一个是输入符号,可以从输入串中取得。
Action表的输出有4种情况:
- 移进:这时候输出一个移进后的新状态。输入符号和新状态压入栈
- 归约:这时候输出一个归约的表达式。栈中的符号串,包括输入符号和状态,被替换成归约后的新符号串。这时候的要变成的状态就要去Goto表中去查询。输入为归约后的新符号串,也就是产生式的左端,与这个符号串左边的上一个状态,查出来之后,就是最新的状态
- 接受:说明一次文法解析已经完成,可以输出语法分析树了
- 出错:走到了两个表中查不到的状态
我们看一个龙书上的例子,构造含有二元运算符+和*的算术表达式的文法:
1. E -> E + T
2. E -> T
3. T -> T * F
4. T -> F
5. F -> (E)
6. F -> id
我把龙书上用符号表示的表用文字标注上颜色,使大家更加容易记忆和理解。
对应的action表如下:
goto表如下:
下面我们尝试分析一下"id * id $".
- 初始状态是0. 输入为id. 我们查0行id列的action表,是将id移进栈,同时,状态栈顶转为5. 完成这一步后,栈中内容[0 id 5]
- 状态5,输入为。查action表5行列,是使用公式6(F->id)进行归约。此时,状态5和id输入,都被从栈中归约掉,变成F。这时的栈为[0 F],因为产生了归约,所以要再去查goto表,根据目前栈中的值,去查0行F列,查到操作是转到状态3. 于是将3压入栈中,现在栈中的值是[0 F 3]
- 状态3下,还是遇到刚才的输入“*”。查action表,要做的是使用公式4(T->F)来归约。同样,F和状态3出栈,T入栈。现在栈中的内容是[0 T],又产生了归约,于是再查goto表,0行T列是转到状态2. 现在栈中的值是[0 T 2]
- 状态2下,刚才输入的还在,继续查表。action表的2行列是移进,下一状态是7。终于把这个*移进去了。现在的栈中的内容是:[0 T 2 * 7]
- 状态7下,遇到id。查action表,7行id列,移进,下一状态是5. 现在栈中内容为:[0 T 2 * 7 id 5]
- 状态5下,遇到结束符$。查action表,5行$列,归约,使用公式6(F->id). id和状态5出栈,F入栈。现在的栈是[0 T 2 * 7 F],再去goto表中查7行F列,状态为10。这一步的最终栈是:[0 T 2 * 7 F 10]
- 状态10下,输入还是$。查action表,10行$列:使用公式3(T->T*F)归约。请注意,除去状态不计的话,[0 T 2 * 7 F 10]的值正是[T * F],于是将[T 2 * 7 F 10]全部出栈,将T入栈。归约之后再查goto表,[0 T],查0行T列,状态是2. 这一步最终栈结果:[0 T 2]
- 状态2下,输入还是$。查action表,归约,使用公式2(E -> T). T和2出栈,E入栈。现在的栈为[0 E],再查goto表,0行E列,状态为1。这一步最终结果是[0 E 1]
- 状态1下,输入仍然是$没变。查action表,1行$列,接受,解析成功!
下面的问题就变成如何能够构造action表和goto表。LR下面的不同方法,就是如何生成这两张表的过程。
子集构造算法
子集构造算法是将不确定的有穷自动机NFA转换成确定的有穷自动机的算法。
从不确定的有穷自动机转换成确定的有穷自动机的基本思想是将确定有穷自动机的一个状态对应于不确定有穷自动机的一个状态集合。
子集构造算法
状态集合初值为初始状态的空闭包(ε-closure),且不作标记
while (状态集合中还有未标记的状态T){
标记这个状态T;
for 每个输入符号a in 输入集合 {
U = 空闭包(move(T,a));
if(U不在状态集合中){
U添加到状态集合中;
U的状态为未标记;
}
Dtran[T,a]=U;
}
}
其中,构造子集算法使用到了求空闭包(ε-closure)的算法。
求ε-closure的算法用人话讲就是,从起点或者起点的集合,计算出走ε路径可以到达的所有状态。我们可以把a,b这些值理解为大于0的权值,而ε为权值为0. 求ε闭包的算法就是求从指定起点的权值之和为0的所有路径的集合。
求ε-closure空闭包的算法
将T中所有的状态压入栈中; //这是所有的起点的集合
空闭包集合初始化为T; //清空栈
while (栈不空){
栈顶元素t弹出栈; //取一个起点出来
for 状态u in 从t到u有一条标记为ε的边{ //起点和状态之间有ε的边
if (u不在空闭包集合中){
将u添加到空闭包集合中; //u是符合条件的值
将u压入栈中; //如果u下面还可以继续传导,后面还可以有ε的边
}
}
}
我们看下面的龙书上的例子:
ε-closure(0)就是从0开始距离为ε的所有状态,直接跟0相连的有1和7。1又可以通达2,4. 所以ε-closure(0)为{0,1,2,4,7}
下面我们开始应用到子集构造算法的例子中:
初始状态0的空闭包集合为{0,1,2,4,7},我们用A来表示。
move({0,1,2,4,7},a) = {3,8}。这一步是从{0,1,2,4,7},指定输入为a时可以到达的状态,move(2,a)=3, move(7,a)=8,其他都不能到达。
ε-closure({3,8})= {1,2,3,4,6,7,8},用B来表示.
move({1,2,3,4,6,7,8},a)={3,8},跟B重复
于是a的情况完成了,我们再遍历输入为b的情况。
move({0,1,2,4,7},b)={5}
ε-closure({5})={1,2,4,5,6,7} = C
ε-closure(move(B,b))=ε-closure({5,9})={1,2,4,5,6,7,9} = D
ε-closure(move(C,a))=ε-closure({3,8}) = B
ε-closure(move(C,b))=ε-closure({5}) = C
ε-closure(move(D,a))=ε-closure({3,8}) = B
ε-closure(move(D,b))=ε-closure({5}) = C
最后生成的是这样的状态转换图:
SLR方法
拓广文法
如果文法G的开始符号是S,那么文法G的拓广文法G'是在G的基础上增加一个新的开始符号S'和产生式S->S'。新产生式的目的是用做归约的终点。
闭包运算
闭包是:
- 初始项目集都是闭包的成员
- 如果A->α.Bβ在闭包中,且存在产生式B->γ, 若B->.γ不在闭包中,则将其加入闭包。重复直至所有的产生式都加入到闭包中。
例:
E' -> E
E -> E+T | T
T -> T*F | F
F -> (E) | id
closure{[E' -> .E]}包含:
根据规则1,E' -> .E 本身在闭包里
根据规则2,
E -> .E+T
E -> .T
T -> .T*F
T -> .F
F -> .(E)
F -> .id
goto函数
我们终于开始看到如何生成goto函数了。
goto(I,X)函数的定义为A->αX.β的闭包。
例:若I是两个项目的集合{[E'->.E],[E->E.+T]},则goto(I,+)包括:
E -> E + .T
T -> .T + F
T -> .F
F -> .(E)
F -> .id
项目集的构造算法
算法:
C = {closure([S'->.S])};
do{
for 项目集I in C, 文法符号X in C {
if(goto(I,X)!=nullptr && inC(goto(I,X))
C.add(goto(I,X));
}
}while(还有更多的项目可以加入C);
SLR语法分析表的构造
算法:
- 构造G'的LR(0)项目集规范族。采用上面介绍的项目集构造算法。C={I0,I1,I2,...}
- 从Ii构造状态i,它的分析动作确定如下:
2.1. 如果[A->α.aβ]在Ii中,并且 goto(Ii,a)=Ij,则置action[i,a]为"移进j",这里的a必须是终结符
2.2. 如果[A->α.]在Ii中,则对FOLLOW(A)中的所有a,置action[i,a]="归约A->α",这里的A不能是S'.
2.3. 如果[S'->.S]在Ii中,则置action[i,$]为“接受” - 对所有的非终结符A,使用下面的规则构造状态i的goto函数:如果goto(Ii,A)=Ij,则goto[i,A]=j
- 不能由2和3构造出来的表项都置为出错。
构造规范LR语法分析表
SLR对于某些情况是无法归约的,我们可以通过重新定义项目,把更多的信息并入状态中,变成[A->α.β,a], 其中A->α.β是产生式,a是终结符或$。
这样的对象叫做LR(1)项目。
构造LALR语法分析表
LALR是(look-ahead-LR)的缩写。它的优点是比LR(1)的分析表要小得多。
乔姆斯基的文法分类
我们先看个乔姆斯基文法分类的示例图:
类似于我们上面所讲的生成式,可以对应到乔姆斯基的文法分类上。
关于终结符和非终结符,我们就不需要做严谨的数学定义了吧。像数字一样不能推导出其他式子的,就是终结符。像表达式这样可以继续推导的就是非终结符。
乔姆斯基将文法分成4类:
- 0型文法:这种文法只有一种要求,就是左边的式子里有一个非终结符。直观理解就是,总要有一个能推导的式子啊。
- 1型文法:在0型的基础上,要求右部的长度比左式长。这样,推导的话可以越推越长,归约的话可以越归约越短。
- 2型文法:在1型的基础上,要求左部必须为非终结符,不能有终结符。
- 3型文法:在2型的基础上,左部只能有一个单独的非终结符。而右部更有严格的限制,必须全部是终结符,或者终结符只能连接一个非连接符。