gitbook/编译原理之美/docs/139628.md
2022-09-03 22:05:03 +08:00

20 KiB
Raw Permalink Blame History

18 | 移进和规约用LR算法推演一个实例

到目前为止我们所讨论的语法分析算法都是自顶向下的。与之相对应的是自底向上的算法比如本节课要探讨的LR算法家族。

LR算法是一种自底向上的算法它能够支持更多的语法而且没有左递归的问题。第一个字母L与LL算法的第一个L一样代表从左向右读入程序。第二个字母R指的是RightMost最右推导也就是在使用产生式的时候是从右往左依次展开非终结符。例如对于“add->add+mul”这样一个产生式是优先把mul展开然后再是add。在接下来的讲解过程中你会看到这个过程。

自顶向下的算法是递归地做模式匹配从而逐步地构造出AST。那么自底向上的算法是如何构造出AST的呢答案是用移进-规约的算法。

本节课,我就带你通过移进-规约方法自底向上地构造AST完成语法的解析。接下来我们先通过一个例子看看自底向上语法分析的过程。

通过实例了解自底向上语法分析的过程

我们选择熟悉的语法规则:

add -> mul
add -> add + mul
mul -> pri
mul -> mul * pri  
pri -> Int | (add)

然后来解析“2+3*5”这个表达式AST如下

我们分步骤看一下解析的具体过程。

第1步看到第一个Token是Int2。我们把它作为AST的第一个节点同时把它放到一个栈里就是图中红线左边的部分。这个栈代表着正在处理的一些AST节点把Token移到栈里的动作叫做移进Shift

第2步根据语法规则Int是从pri推导出来的pri->Int那么它的上级AST肯定是pri所以我们给它加了一个父节点pri同时也把栈里的Int替换成了pri。这个过程是语法推导的逆过程叫做规约Reduce

Reduce这个词你在学Map-Reduce时可能接触过它相当于我们口语化的“倒推”。具体来讲它是从工作区里倒着取出1到n个元素根据某个产生式组合出上一级的非终结符也就是AST的上级节点然后再放进工作区也就是竖线的左边

这个时候栈里可能有非终结符也可能有终结符它仿佛是我们组装AST的一个工作区。竖线的右边全都是Token也就是终结符它们在等待处理。

第3步与第2步一样因为pri只能是mul推导出来的产生式是“mul->pri”所以我们又做了一次规约。

第4步我们根据“add->mul”产生式将mul规约成add。至此我们对第一个Token做了3次规约已经到头了。这里为什么做规约而不是停在mul上移进+号是有原因的。因为没有一个产生式是mul后面跟+号而add后面却可以跟+号。

第5步移进+号。现在栈里有两个元素了分别是add和+。

第6步移进Int也就是数字3。栈里现在有3个元素。

第7到第8步Int规约到pri再规约到mul。

到目前为止,我们做规约的方式都比较简单,就是对着栈顶的元素,把它反向推导回去。

第9步我们面临3个选择比较难。

第一个选择是继续把mul规约成add第二个选择是把“add+mul”规约成add。这两个选择都是错误的因为它们最终无法形成正确的AST。

第三个选择也就是按照“mul->mul*pri”继续移进 *号 而不是做规约。只有这样才能形成正确的AST就像图中的虚线。

第10步移进Int也就是数字5。

第11步Int规约成pri。

第12步mul*pri规约成mul。

注意这里也有两个选择比如把pri继续规约成mul。但它显然也是错误的选择。

第13步add+mul规约成add。

至此我们就构建完成了一棵正确的AST并且栈里也只剩下了一个元素就是根节点。

整个语法解析过程,实质是**反向最右推导Reverse RightMost Derivation。**什么意思呢如果把AST节点根据创建顺序编号就是下面这张图呈现的样子根节点编号最大是13

但这是规约的过程如果是从根节点开始的推导过程顺序恰好是反过来的先是13号再是右子节点12号再是12号的右子节点11号以此类推。我们把这个最右推导过程写在下面

在语法解析的时候我们是从底下反推回去所以叫做反向的最右推导过程。从这个意义上讲LR算法中的R带有反向Reverse和最右Reightmost这两层含义。

在最右推导过程中,我加了下划线的部分,叫做一个句柄Handle。句柄是一个产生式的右边部分以及它在一个右句型最右推导可以得到的句型中的位置。以最底下一行为例这个句柄“Int”是产生式“pri->Int”的右边部分它的位置是句型“Int + Int * Int”的第一个位置。

简单来说,句柄,就是产生式是在这个位置上做推导的,如果需要做反向推导的话,也是从这个位置去做规约。

针对这个简单的例子我们可以用肉眼进行判断找到正确的句柄做出正确的选择。不过要把这种判断过程变成严密的算法做到在每一步都采取正确的行动知道该做移进还是规约做规约的话按照哪个产生式这就是LR算法要解决的核心问题了。

那么,如何找到正确的句柄呢?

找到正确的句柄

我们知道,最右推导是从最开始的产生式出发,经过多步推导(多步推导记做->*),一步步形成当前的局面 也就是左边栈里有一些非终结符和终结符右边还可以预看1到k个Token

add ->* 栈 | Token

我们要像侦探一样根据手头掌握的信息反向推导出这个多步推导的路径从而获得正确的句柄。我们依据的是左边栈里的信息以及右边的Token串。对于LR(0)算法来说我们只依据左边的栈就能找到正确的句柄对于LR(1)算法来说我们可以从右边预看一个Token。

我们的思路是根据语法规则复现这条推导路径。以第8步为例下图是它的推导过程橙色的路径是唯一能够到达第8步的路径。知道了正向推导的路径自然知道接下来该做什么在第8步我们正确的选择是做移进。

为了展示这个推导过程,我引入了一个新概念:项目Item

Item代表带有“.”符号的产生式。比如“pri->(add)”可以产生4个Item“.”分别在不同的位置。“.”可以看做是前面示意图中的竖线,左边的看做已经在栈里的部分,“.”右边的看做是期待获得的部分:

pri->.(add)
pri->(.add)
pri->(add.)
pri->(add).

上图其实是一个NFA利用这个NFA我们表达了所有可能的推导步骤。每个Item或者状态在接收到一个符号的时候就迁移到下一个状态比如“add->.add+mul”在接收到一个add的时候就迁移到“add->add.+mul”再接收到一个“+”就迁移到“add->add+.mul”。

在这个状态图的左上角我们用一个辅助性的产生式“start->add”作为整个NFA的唯一入口。从这个入口出发可以用这个NFA来匹配栈里内容比如在第8步的时候栈以及右边下一个Token的状态如下其中竖线左边是栈的内容

add + mul | *

在NFA中我们从start开始遍历基于栈里的内容能找到图中橙色的多步推导路径。在这个状态迁移过程中导致转换的符号分别是“ε、add、+、ε、mul”忽略其中的ε就是栈里的内容。

在NFA中我们查找到的Item是“mul->mul.*pri”。这个时候“.”在Item的中间。因此下一个操作只能是一个Shift操作也就是把下一个Token*号,移进到栈里。

如果“.”在Item的最后则对应一个规约操作比如在第12步栈里的内容是

add + mul | $    //$代表Token串的结尾

这个时候的Item是“add->add+mul.”。对于所有点符号在最后面的Item我们已经没有办法继续向下迁移了这个时候需要做一个规约操作也就是基于“add + mul”规约到add也就是到“add->.add+mul”这个状态。对于任何的ε转换其逆向操作也是规约比如图中从“add->.add+mul”规约到“start->.add”。

但做规约操作之前我们仍然需要检查后面跟着的Token是不是在Follow(add)中。对于add来说它的Follow集合包括{$ + }。如果是这些Token那就做规约。否则就报编译错误。

所以,现在清楚了,我们能通过这个有限自动机,跟踪计算出正确的推导过程。

当然了,在16讲我提到每个NFA都可以转换成一个DFA。所以你可以直接在上面的NFA里去匹配也可以把NFA转成DFA避免NFA的回溯现象让算法效率更高。转换完毕的DFA如下

在这个DFA中我同样标注了在第8步时的推导路径。

为了更清晰地理解LR算法的本质我们基于这个DFA再把语法解析的过程推导一遍。

第1步移进一个Int从状态1迁移到9。Item是“pri->Int.”。

第2步依据“pri->Int”做规约从状态9回到状态1。因为现在栈里有个pri元素所以又迁移进了状态8。

第3步依据“mul->pri”做规约从状态8回到状态1再根据栈里的mul元素进入状态7。**注意,**在状态7的时候下一步的走向有两个可能的方向分别是“add->mul.”和“mul->mul.*pri”这两个Item代表的方向。

基于“add->mul.”会做规约而基于“mul->mul.*pri”会做移进这就需要看看后面的Token了。如果后面的Token是 *号,那其实要选第二个方向。但现在后面是+号,所以意味着这里只能做规约。

第4步依据“add->mul”做规约从状态7回到状态1再依据add元素进入状态2。

第5步移进+号。这对应状态图上的两次迁移首先根据栈里的第一个元素add从1迁移到2。然后再根据“+”从2到3。Item的变化是

状态1start->.add
状态1add->.add+mul
状态2add->add.+mul
状态3add->add+.mul

你看通过移进这个加号我们实际上知道了这个表达式顶部必然有一个“add+mul”的结构。

第6到第8步移进Int并一直规约到mul。状态变化是先从状态3到状态9然后回到状态3再进到状态4。

第9步移进一个*。根据栈里的元素迁移路径是1->2->3->4->5。

第10步移进Int进入状态9。

第11步根据“pri->Int”规约到pri先退回到状态5接着根据pri进入状态6。

第12步根据“mul->mul*pri”规约到mul从而退回到状态4。

第13步根据“add->add+mul”规约到add从而退回到状态2。

从状态2再根据“start->add”再规约一步就变成了start回到状态1解析完成。

现在我们已经对整个算法的整个执行过程建立了直觉认知。如果想深入掌握LR算法我建议你把这种推导过程多做几遍自然会了然于胸。建立了直觉认知以后接下来我们再把LR算法的类型和实现细节讨论一下。

LR解析器的类型和实现

LR算法根据能力的强弱和实现的复杂程度可以分成多个级别分别是LR(0)、SLR(k)即简单LR、LALR(k)Look ahead LR和LR(k)其中k表示要在Token队列里预读k个Token。

我来讲解一下这四种类型算法的特点,便于你选择和使用。

**LR(0)不需要预看右边的Token仅仅根据左边的栈就能准确进行反向推导。**比如前面DFA中的状态8只有一个Item“mul->pri.”。如果处在这个状态那接下来操作是规约。假设存在另一个状态它也只有一个Item点符号不在末尾比如“mul->mul.*pri”那接下来的操作就是移进把下一个输入放到栈里。

但实际使用的语法规则很少有这么简单的。所以LR(0)的表达能力太弱能处理的语法规则有限不太有实用价值。就像在前面的例子中如果我们不往下预读一个Token仅仅利用左边工作区的信息是找不到正确的句柄的。

比如在状态7中我们可以做两个操作

  • 对于第一个Item“add->mul.”,需要做一个规约操作。
  • 对于第二个Item“mul->mul.*pri”实际上需要做一个移进操作。

这里发生的冲突,就叫做“移进/规约”冲突Shift/Reduce Conflict。意思是又可以做移进又可以做规约到底做哪个对于状态7来说到底做哪个操作实际上取决于右边的Token。

**SLRSimple LR是在LR(0)的基础上做了增强。**对于状态7的这种情况我们要加一个判断条件右边下一个输入的Token是不是在add的Follow集合中。因为只有这样做规约才有意义。

在例子中add的Follow集合是{+ ) $}。如果不在这个范围内那么做规约肯定是不合法的。因为Follow集合的意思就是哪些Token可以出现在某个非终结符后面。所以如果在状态7中下一个Token是*它不在add的Follow集合中那么我们就只剩了一个可行的选择就是移进。这样就不存在两个选择也不存在冲突。

实际上就我们本讲所用的示例语法而言SLR就足够了但是对于另一些更复杂的语法采用SLR仍然会产生冲突比如

start -> exp
exp -> lvalue = rvalue
exp -> rvalue
lvalue -> Id
lvalue -> *rvalue
rvalue -> lvalue

这个语法说的是关于左值和右值的情况,我们曾在语义分析的时候说过。在这个语法里,右值只能出现在赋值符号右边。

在状态2如果下一个输入是“=”,那么做移进和规约都是可以的。因为“=”在rvalue的Follow集合中。

怎么来处理这种冲突呢仅仅根据Follow集合来判断是否Reduce不太严谨。因为在上图状态2的情况下即使后面跟着的是“=”,我们仍然不能做规约。因为你一规约,就成了一个右值,但它在等号的左边,显然是跟我们的语法定义冲突的。

办法是Follow集合拆了把它的每个成员都变成Item的一部分。这样我们就能做更细致的判断。如下图所示这样细化以后我们发现在状态2中只有下一个输入是“$”的时候才能做规约。这就是LR(1)算法的原理,它更加强大。

但LR(1)算法也有一个缺点就是DFA可能会很大。在语法分析阶段DFA的大小会随着语法规则的数量呈指数级上升一个典型的语言的DFA状态可能达到上千个这会使语法分析的性能很差从而也丧失了实用性。

**LALR(k)是基于这个缺点做的改进。**它用了一些技巧能让状态数量变得比较少但处理能力没有太大的损失。YACC和Bison这两个工具就是基于LALR(1)算法的。

课程小结

今天我们讲了自底向上的LR算法的原理包括移进-规约如何寻找正确的句柄如果基于NFA和DFA决定如何做移进和规约。

LR算法是公认的比较难学的一个算法。好在我们已经在前两讲给它做了技术上的铺垫了包括NFA和DFAFirst和Follow集合。这节课我们重点在于建立直观理解特别是如何依据栈里的信息做正确的反推。这个直觉认知很重要建立这个直觉的最好办法就是像本节课一样根据实例来画图、推导。这样在你真正动手写算法的时候就胸有成竹了

到今天为止,我们已经把前端技术中的关键算法都讲完了。**不过我还是想强调一下,**如果想真正掌握这些算法,必须动手实现一下才行,勤动手才是王道。

一课一思

在讲自顶向下的算法时我提到递归思维是重要的计算机科学思维方式。而自底向上的方法也是另一种重要的思维方式。那么请结合你的经验思考一下在你的领域内是否有一些问题用自底向上的方法能更好地解决。LR算法的移进-规约思想,能否在解决其他自底向上的问题中发挥作用?欢迎在留言区分享你的经验和思考。

最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友。

本节课的示例代码我放在了文末,供你参考。