This commit is contained in:
louzefeng
2024-07-11 05:50:32 +00:00
parent bf99793fd0
commit d3828a7aee
6071 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,403 @@
<audio id="audio" title="16 | NFA和DFA如何自己实现一个正则表达式工具" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c6/e9/c693a53e2eccfedc3359aef4538907e9.mp3"></audio>
回顾之前讲的内容,原理篇重在建立直观理解,帮你建立信心,这是第一轮的认知迭代。应用篇帮你涉足应用领域,在解决领域问题时发挥编译技术的威力,积累运用编译技术的一手经验,也启发你用编译技术去解决更多的领域问题,这是第二轮的认知迭代。而为时三节课的算法篇将你是第三轮的认知迭代。
在第三轮的认知迭代中,我会带你掌握前端技术中的核心算法。而本节课,我就借“怎样实现正则表达式工具?”这个问题,探讨第一组算法:**与正则表达式处理有关的算法。**
在词法分析阶段我们可以手工构造有限自动机FSA或FSM实现词法解析过程比较简单。现在我们不再手工构造词法分析器而是直接用正则表达式解析词法。
你会发现我们只要写一些规则就能基于这些规则分析和处理文本。这种能够理解正则表达式的功能除了能生成词法分析器还有很多用途。比如Linux的三个超级命令又称三剑客grep、awk和sed都是因为能够直接支持正则表达式功能才变得强大的。
接下来,我就带你完成编写正则表达式工具的任务,与此同时,你就能用正则文法生成词法分析器了:
**首先,**把正则表达式翻译成非确定的有限自动机Nondeterministic Finite AutomatonNFA<br>
**其次,**基于NFA处理字符串看看它有什么特点。<br>
**然后,**把非确定的有限自动机转换成确定的有限自动机Deterministic Finite AutomatonDFA<br>
**最后,**运行DFA看看它有什么特点。
强调一下,不要被非确定的有限自动机、确定的有限自动机这些概念吓倒,我肯定让你学明白。
## 认识DFA和NFA
在讲词法分析时我提到有限自动机FSA有有限个状态。识别Token的过程就是FSA状态迁移的过程。其中FSA分为**确定的有限自动机DFA<strong>和**非确定的有限自动机NFA</strong>
**DFA的特点是**在任何一个状态,我们基于输入的字符串,都能做一个确定的转换,比如:
<img src="https://static001.geekbang.org/resource/image/15/35/15da400d09ede2ce6ac60fa6d5342835.jpg" alt="">
**NFA的特点是**它存在某些状态,针对某些输入,不能做一个确定的转换,这又细分成两种情况:
- 对于一个输入,它有两个状态可以转换。
- 存在ε转换。也就是没有任何输入的情况下,也可以从一个状态迁移到另一个状态。
比如“a[a-zA-Z0-9]*bc”这个正则表达式对字符串的要求是以a开头以bc结尾a和bc之间可以有任意多个字母或数字。在图中状态1的节点输入b时这个状态是有两条路径可以选择的所以这个有限自动机是一个NFA。
<img src="https://static001.geekbang.org/resource/image/9b/e8/9bf26739958568453cceeb6f209da2e8.jpg" alt="">
这个NFA还有引入ε转换的画法它们是等价的。实际上第二个NFA可以用我们今天讲的算法通过正则表达式自动生成出来。
<img src="https://static001.geekbang.org/resource/image/9b/09/9bb22ee26309b3076db53fee34112009.jpg" alt="">
需要注意的是无论是NFA还是DFA都等价于正则表达式。也就是所有的正则表达式都能转换成NFA或DFA所有的NFA或DFA也都能转换成正则表达式。
理解了NFA和DFA之后来看看我们如何从正则表达式生成NFA。
## 从正则表达式生成NFA
我们需要把它分为两个子任务:
**第一个子任务,**是把正则表达式解析成一个内部的数据结构,便于后续的程序使用。因为正则表达式也是个字符串,所以要先做一个小的编译器,去理解代表正则表达式的字符串。我们可以偷个懒,直接针对示例的正则表达式生成相应的数据结构,不需要做出这个编译器。
用来测试的正则表达式可以是int关键字、标识符或者数字字面量
```
int | [a-zA-Z][a-zA-Z0-9]* | [0-9]+
```
我用下面这段代码创建了一个树状的数据结构,来代表用来测试的正则表达式:
```
private static GrammarNode sampleGrammar1() {
GrammarNode node = new GrammarNode(&quot;regex1&quot;,GrammarNodeType.Or);
//int关键字
GrammarNode intNode = node.createChild(GrammarNodeType.And);
intNode.createChild(new CharSet('i'));
intNode.createChild(new CharSet('n'));
intNode.createChild(new CharSet('t'));
//标识符
GrammarNode idNode = node.createChild(GrammarNodeType.And);
GrammarNode firstLetter = idNode.createChild(CharSet.letter);
GrammarNode letterOrDigit = idNode.createChild(CharSet.letterOrDigit);
letterOrDigit.setRepeatTimes(0, -1);
//数字字面量
GrammarNode literalNode = node.createChild(CharSet.digit);
literalNode.setRepeatTimes(1, -1);
return node;
}
```
打印输出的结果如下:
```
RegExpression
Or
Union
i
n
t
Union
[a-z]|[A-Z]
[0-9]|[a-z]|[A-Z]*
[0-9]+
```
画成图会更直观一些:
<img src="https://static001.geekbang.org/resource/image/a6/8e/a6af22cdcb96ba92fe9df35bf998768e.jpg" alt="">
测试数据生成之后,**第二个子任务**就是把表示正则表达式的数据结构转换成一个NFA。这个过程比较简单因为针对正则表达式中的每一个结构我们都可以按照一个固定的规则做转换。
- 识别ε的NFA
>
不接受任何输入,也能从一个状态迁移到另一个状态,状态图的边上标注ε。
<img src="https://static001.geekbang.org/resource/image/0d/ed/0d11ad629f809a94ff091199f27661ed.jpg" alt="">
- 识别i的NFA
>
当接受字符i的时候引发一个转换状态图的边上标注i。
<img src="https://static001.geekbang.org/resource/image/fe/bc/fe3edc36b5bd69e88eebcd0d28aa4abc.jpg" alt="">
- 转换“s|t”这样的正则表达式
>
它的意思是或者s或者t二者选一。s和t本身是两个子表达式我们可以增加两个新的状态**开始状态和接受状态(最终状态)**也就是图中带双线的状态它意味着被检验的字符串此时是符合正则表达式的。然后用ε转换分别连接代表s和t的子图。它的含义也比较直观要么走上面这条路径那就是s要么走下面这条路径那就是t。
<img src="https://static001.geekbang.org/resource/image/19/95/197071ebe504889264cf8c955d112895.jpg" alt="">
- 转换“st”这样的正则表达式
>
s之后接着出现t转换规则是把s的开始状态变成st整体的开始状态把t的结束状态变成st整体的结束状态并且把s的结束状态和t的开始状态合二为一。这样就把两个子图接了起来走完s接着走t。
<img src="https://static001.geekbang.org/resource/image/95/0b/9504b495df0de1cc59ef8d8357c49e0b.jpg" alt="">
- 对于“?”“*”和“+”这样的操作:
>
意思是可以重复0次、0到多次、1到多次转换时要增加额外的状态和边。
以“s*”为例,做下面的转换:
<img src="https://static001.geekbang.org/resource/image/40/c5/409d889a2c811221a0cfdd81f32df4c5.jpg" alt="">
你能看出它可以从i直接到f也就是对s匹配零次也可以在s的起止节点上循环多次。
- “s+”:
>
没有办法跳过ss至少经过一次。
<img src="https://static001.geekbang.org/resource/image/a7/07/a753fb42e82341d381c3cbca0247b007.png" alt="">
按照这些规则,我们可以编写程序进行转换。你可以参考示例代码[Regex.java](https://github.com/RichardGong/PlayWithCompiler/blob/master/lab/16-18/src/main/java/play/parser/Regex.java)中的regexToNFA方法。转换完毕以后将生成的NFA打印输出列出了所有的状态以及每个状态到其他状态的转换比如“0 ε -&gt; 2”的意思是从状态0通过ε转换到达状态2
```
NFA states:
0 ε -&gt; 2
ε -&gt; 8
ε -&gt; 14
2 i -&gt; 3
3 n -&gt; 5
5 t -&gt; 7
7 ε -&gt; 1
1 (end)
acceptable
8 [a-z]|[A-Z] -&gt; 9
9 ε -&gt; 10
ε -&gt; 13
10 [0-9]|[a-z]|[A-Z] -&gt; 11
11 ε -&gt; 10
ε -&gt; 13
13 ε -&gt; 1
14 [0-9] -&gt; 15
15 ε -&gt; 14
ε -&gt; 1
```
我用图片直观地展示了输出结果图中分为上中下三条路径你能清晰地看出解析int关键字、标识符和数字字面量的过程
<img src="https://static001.geekbang.org/resource/image/3d/9b/3defa4a1d7ce789b6c6cfecdfbf8179b.jpg" alt="">
生成NFA之后如何利用它识别某个字符串是否符合这个NFA代表的正则表达式呢
以上图为例当我们解析intA这个字符串时首先选择最上面的路径去匹配匹配完int这三个字符以后来到状态7若后面没有其他字符就可以到达接受状态1返回匹配成功的信息。可实际上int后面是有A的所以第一条路径匹配失败。
失败之后不能直接返回“匹配失败”的结果因为还有其他路径所以我们要回溯到状态0去尝试第二条路径在第二条路径中尝试成功了。
运行Regex.java中的matchWithNFA()方法你可以用NFA来做正则表达式的匹配
```
/**
* 用NFA来匹配字符串
* @param state 当前所在的状态
* @param chars 要匹配的字符串,用数组表示
* @param index1 当前匹配字符开始的位置。
* @return 匹配后新index的位置。指向匹配成功的字符的下一个字符。
*/
private static int matchWithNFA(State state, char[] chars, int index1){
System.out.println(&quot;trying state : &quot; + state.name + &quot;, index =&quot; + index1);
int index2 = index1;
for (Transition transition : state.transitions()){
State nextState = state.getState(transition);
//epsilon转换
if (transition.isEpsilon()){
index2 = matchWithNFA(nextState, chars, index1);
if (index2 == chars.length){
break;
}
}
//消化掉一个字符,指针前移
else if (transition.match(chars[index1])){
index2 ++; //消耗掉一个字符
if (index2 &lt; chars.length) {
index2 = matchWithNFA(nextState, chars, index1 + 1);
}
//如果已经扫描完所有字符
//检查当前状态是否是接受状态或者可以通过epsilon到达接受状态
//如果状态机还没有到达接受状态,本次匹配失败
else {
if (acceptable(nextState)) {
break;
}
else{
index2 = -1;
}
}
}
}
return index2;
}
```
其中在匹配“intA”时你会看到它的回溯过程
```
NFA matching: 'intA'
trying state : 0, index =0
trying state : 2, index =0 //先走第一条路径即int关键字这个路径
trying state : 3, index =1
trying state : 5, index =2
trying state : 7, index =3
trying state : 1, index =3 //到了末尾了,发现还有字符'A'没有匹配上
trying state : 8, index =0 //回溯,尝试第二条路径,即标识符
trying state : 9, index =1
trying state : 10, index =1 //在10和11这里循环多次
trying state : 11, index =2
trying state : 10, index =2
trying state : 11, index =3
trying state : 10, index =3
true
```
**从中可以看到用NFA算法的特点**因为存在多条可能的路径所以需要试探和回溯在比较极端的情况下回溯次数会非常多性能会变得非常慢。特别是当处理类似s*这样的语句时因为s可以重复0到无穷次所以在匹配字符串时可能需要尝试很多次。
注意在我们生成的NFA中如果一个状态有两条路径到其他状态算法会依据一定的顺序来尝试不同的路径。
9和11两个状态都有两条向外走的线其中红色的线是更优先的路径也就是尝试让*号匹配尽量多的字符。这种算法策略叫做“贪婪greedy”策略。
在有的情况下,我们会希望让算法采用非贪婪策略,或者叫“忽略优先”策略,以便让效率更高。有的正则表达式工具会支持多加一个?,比如??、*?、+?,来表示非贪婪策略。
NFA的运行可能导致大量的回溯所以能否将NFA转换成DFA让字符串的匹配过程更简单呢如果能的话那整个过程都可以自动化从正则表达式到NFA再从NFA到DFA。
## 把NFA转换成DFA
的确有这样的算法,那就是**子集构造法,**它的思路如下。
首先NFA有一个初始状态从状态0通过ε转换可以到达的所有状态也就是说在不接受任何输入的情况下从状态0也可以到达的状态。这个状态的集合叫做“状态0的ε闭包”简单一点儿我们称之为s0s0包含0、2、8、14这几个状态。
<img src="https://static001.geekbang.org/resource/image/9c/f7/9c35bf11efb869c5fa4a22e23de52ff7.jpg" alt="">
将字母i给到s0中的每一个状态看它们能转换成什么状态再把这些状态通过ε转换就能到达的状态也加入进来形成一个包含“3、9、10、13、1”5个状态的集合s1。其中3和9是接受了字母i所迁移到的状态10、13、1是在状态9的ε闭包中。
<img src="https://static001.geekbang.org/resource/image/d2/40/d2f3035a3492b680c56777b7fa375e40.jpg" alt="">
在s0和s1中间画条迁移线标注上i意思是s0接收到i的情况下转换到s1
<img src="https://static001.geekbang.org/resource/image/58/29/58388daf0627d0bc71efbf7b48401029.jpg" alt="">
在这里我们把s0和s1分别看成一个状态。也就是说要生成的DFA它的每个状态是原来的NFA的某些状态的集合。
在上面的推导过程中,我们有两个主要的计算:
1.ε-closure(s)即集合s的ε闭包。也就是从集合s中的每个节点加上从这个节点出发通过ε转换所能到达的所有状态。<br>
2.move(s, i)即从集合s接收一个字符i所能到达的新状态的集合。<br>
所以s1 = ε-closure(move(s0,i))
按照上面的思路继续推导识别int关键字的识别路径也就推导出来了
<img src="https://static001.geekbang.org/resource/image/be/00/be1a150ce14e828e8e9993b419360e00.jpg" alt="">
我们把上面这种推导的思路写成算法,参见[Regex.java](https://github.com/RichardGong/PlayWithCompiler/blob/master/lab/16-18/src/main/java/play/parser/Regex.java)中的NFA2DFA()方法。我写了一段伪代码,方便你阅读:
```
计算s0即状态0的ε闭包
把s0压入待处理栈
把s0加入所有状态集的集合S
循环:待处理栈内还有未处理的状态集
循环针对字母表中的每个字符c
循环针对栈里的每个状态集合s(i)(未处理的状态集)
计算s(m) = move(s(i), c)就是从s(i)出发接收字符c能够
迁移到的新状态的集合)
计算s(m)的ε闭包叫做s(j)
看看s(j)是不是个新的状态集,如果已经有这个状态集了,把它找出来
否则把s(j)加入全集S和待处理栈
建立s(i)到s(j)的连线转换条件是c
```
运行NFA2DFA()方法然后打印输出生成的DFA。画成图你就能很直观地看出迁移的路径了
<img src="https://static001.geekbang.org/resource/image/b3/ea/b31b50f7b527de9915b81cb7a117c2ea.jpg" alt="">
从初始状态开始如果输入是i那就走int识别这条线也就是按照19、21、22这条线依次迁移如果中间发现不符合int模式就跳转到20也就是标识符状态。
注意在上面的DFA中只要包含接受状态1的都是DFA的接受状态。进一步区分的话22是int关键字的接受状态因为它包含了int关键字原来的接受状态7。同理17是数字字面量的接受状态18、19、20、21都是标识符的接受状态。
而且你会发现算法生成的DFA跟手工构造DFA是很接近的我们在第二讲手工构造了DFA识别int关键字和标识符比本节课少识别一个数字字面量
<img src="https://static001.geekbang.org/resource/image/11/3c/11cf7add8fb07db41f4eb067db4ac13c.jpg" alt="">
不过光看对int关键字和标识符的识别我们算法生成的DFA和手工构造的DFA非常相似手工构造的相当于把18和20两个状态合并了所以这个算法是非常有效的你可以运行一下示例程序Regex.java中的matchWithDFA()的方法,看看效果:
```
private static boolean matchWithDFA(DFAState state, char[] chars, int index){
System.out.println(&quot;trying DFAState : &quot; + state.name + &quot;, index =&quot; + index);
//根据字符,找到下一个状态
DFAState nextState = null;
for (Transition transition : state.transitions()){
if (transition.match(chars[index])){
nextState = (DFAState)state.getState(transition);
break;
}
}
if (nextState != null){
//继续匹配字符串
if (index &lt; chars.length-1){
return matchWithDFA(nextState,chars, index + 1);
}
else{
//字符串已经匹配完毕
//看看是否到达了接受状态
if(state.isAcceptable()){
return true;
}
else{
return false;
}
}
}
else{
return false;
}
}
```
运行时会打印输出匹配过程,而执行过程中不产生任何回溯。
现在我们可以自动生成DFA了可以根据DFA做更高效的计算。不过有利就有弊DFA也存在一些缺点。比如DFA可能有很多个状态。
假设原来NFA的状态有n个那么把它们组合成不同的集合可能的集合总数是2的n次方个。针对我们示例的NFA它有13个状态所以最坏的情况下形成的DFA可能有2的13次方也就是8192个状态会占据更多的内存空间。而且生成这个DFA本身也需要消耗一定的计算时间。
当然了这种最坏的状态很少发生我们示例的NFA生成DFA后只有7个状态。
## 课程小结
本节课,我带你实现了一个正则表达式工具,或者说根据正则表达式自动做了词法分析,它们的主要原理是相同的。
首先我们需要解析正则表达式形成计算机内部的数据结构然后要把这个正则表达式生成NFA。我们可以基于NFA进行字符串的匹配或者把NFA转换成DFA再进行字符串匹配。
NFA和DFA有各自的优缺点NFA通常状态数量比较少可以直接用来进行计算但可能会涉及回溯从而性能低下DFA的状态数量可能很大占用更多的空间并且生成DFA本身也需要消耗计算资源。所以我们根据实际需求选择采用NFA还是DFA就可以了。
不过一般来说正则表达式工具可以直接基于NFA。而词法分析器如Lex则是基于DFA。原因很简单因为在生成词法分析工具时只需要计算一次DFA就可以基于这个DFA做很多次词法分析。
## 一课一思
本节课我们实现了一个简单的正则表达式工具。在你的日常编程任务中,有哪些需要进行正则处理的需求?用传统的正则表达式工具有没有性能问题?你有没有办法用本节课讲到的原理来优化这些工作?欢迎在留言区分享你的发现。
最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友。
本节课的示例代码我放在了文末,供你参考。
- lab/16-18算法篇的示例代码[码云](https://gitee.com/richard-gong/PlayWithCompiler/tree/master/lab/16-18) [GitHub](https://github.com/RichardGong/PlayWithCompiler/tree/master/lab/16-18)
- Regex.java正则表达式有关的算法[码云](https://gitee.com/richard-gong/PlayWithCompiler/blob/master/lab/16-18/src/main/java/play/parser/Regex.java) [GitHub](https://github.com/RichardGong/PlayWithCompiler/blob/master/lab/16-18/src/main/java/play/parser/Regex.java)
- Lexer.java基于正则文法自动做词法解析[码云](https://gitee.com/richard-gong/PlayWithCompiler/blob/master/lab/16-18/src/main/java/play/parser/Lexer.java) [GitHub](https://github.com/RichardGong/PlayWithCompiler/blob/master/lab/16-18/src/main/java/play/parser/Lexer.java)
- GrammarNode.java用于表达正则文法[码云](https://gitee.com/richard-gong/PlayWithCompiler/blob/master/lab/16-18/src/main/java/play/parser/GrammarNode.java) [GitHub](https://github.com/RichardGong/PlayWithCompiler/blob/master/lab/16-18/src/main/java/play/parser/GrammarNode.java)
- State.java自动机的状态[码云](https://gitee.com/richard-gong/PlayWithCompiler/blob/master/lab/16-18/src/main/java/play/parser/State.java) [GitHub](https://github.com/RichardGong/PlayWithCompiler/blob/master/lab/16-18/src/main/java/play/parser/State.java)
- DFAState.javaDFA的状态[码云](https://gitee.com/richard-gong/PlayWithCompiler/blob/master/lab/16-18/src/main/java/play/parser/DFAState.java) [GitHub](https://github.com/RichardGong/PlayWithCompiler/blob/master/lab/16-18/src/main/java/play/parser/DFAState.java)

View File

@@ -0,0 +1,295 @@
<audio id="audio" title="17 | First和Follow集合用LL算法推演一个实例" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/eb/f6/eb28140f3d4e196e2b87633856d094f6.mp3"></audio>
在前面的课程中,我讲了递归下降算法。这个算法很常用,但会有回溯的现象,在性能上会有损失。所以我们要把算法升级一下,实现带有预测能力的自顶向下分析算法,避免回溯。而要做到这一点,就需要对自顶向下算法有更全面的了解。
另外,在留言区,有几个同学问到了一些问题,涉及到对一些基本知识点的理解,比如:
- 基于某个语法规则做解析的时候,什么情况下算是成功,什么情况下算是失败?
- 使用深度优先的递归下降算法时,会跟广度优先的思路搞混。
要搞清这些问题也需要全面了解自顶向下算法。比如了解Follow集合和$符号的用法,能帮你解决第一个问题;了解广度优先算法能帮你解决第二个问题。
所以本节课我先把自顶向下分析的算法体系梳理一下让你先建立更加清晰的全景图然后我再深入剖析LL算法的原理讲清楚First集合与Follow集合这对核心概念最终让你把自顶向下的算法体系吃透。
## 自顶向下分析算法概述
自顶向下分析的算法是一大类算法。总体来说它是从一个非终结符出发逐步推导出跟被解析的程序相同的Token串。
这个过程可以看做是一张图的搜索过程,这张图非常大,因为针对每一次推导,都可能产生一个新节点。下面这张图只是它的一个小角落。
<img src="https://static001.geekbang.org/resource/image/87/46/876d50f726b34f5c4218cd919f78cf46.jpg" alt="">
算法的任务就是在大图中找到一条路径能产生某个句子Token串。比如我们找到了三条橘色的路径都能产生“2+3*5”这个表达式。
根据搜索的策略,有**深度优先Depth First和广度优先Breadth First**两种,这两种策略的推导过程是不同的。
**深度优先**是沿着一条分支把所有可能性探索完。以“add-&gt;mul+add”产生式为例它会先把mul这个非终结符展开比如替换成pri然后再把它的第一个非终结符pri展开。只有把这条分支都向下展开之后才会回到上一级节点去展开它的兄弟节点。
递归下降算法就是深度优先的,这也是它不能处理左递归的原因,因为左边的分支永远也不能展开完毕。
而针对“add-&gt;add+mul”这个产生式**广度优先**会把add和mul这两个都先展开这样就形成了四条搜索路径分别是mul+mul、add+mul+mul、add+pri和add+mul*pri。接着把它们的每个非终结符再一次展开会形成18条新的搜索路径。
所以广度优先遍历需要探索的路径数量会迅速爆炸成指数级上升。哪怕用下面这个最简单的语法去匹配“2+3”表达式都需要尝试20多次更别提针对更复杂的表达式或者采用更加复杂的语法规则了。
```
//一个很简单的语法
add -&gt; pri //1
add -&gt; add + pri //2
pri -&gt; Int //3
pri -&gt; (add) //4
```
<img src="https://static001.geekbang.org/resource/image/d2/dd/d2f4c3a577ee6c7b4b0ffcff3d8792dd.jpg" alt="">
这样看来,指数级上升的内存消耗和计算量,使得广度优先根本没有实用价值。虽然上面的算法有优化空间,但无法从根本上降低算法复杂度。当然了,它也有可以使用左递归文法的优点,不过我们不会为了这个优点去忍受算法的性能。
而深度优先算法在内存占用上是线性增长的。考虑到回溯的情况,在最坏的情况下,它的计算量也会指数式增长,但我们可以通过优化,让复杂度降为线性增长。
了解广度优先算法,你的思路会得到拓展,对自顶向下算法的本质有更全面的理解。另外,在写算法时,你也不会一会儿用深度优先,一会儿用广度优先了。
针对深度优先算法的优化方向是减少甚至避免回溯思路就是给算法加上预测能力。比如我在解析statement的时候看到一个if就知道肯定这是一个条件语句不用再去尝试其他产生式了。
**LL算法就属于这类预测性的算法。**第一个L是Left-to-right代表从左向右处理程序代码。第二个L是Leftmost意思是最左推导。
按照语法规则一个非终结符展开后会形成多个子节点其中包含终结符和非终结符。最左推导是指从左到右依次推导展开这些非终结符。采用Leftmost的方法在推导过程中句子的左边逐步都会被替换成终结符只有右边的才可能包含非终结符。
以“2+3*5”为例它的推导顺序从左到右非终结符逐步替换成了终结符
<img src="https://static001.geekbang.org/resource/image/dc/21/dce93faf1fbce5d439b38b02c07e7e21.jpg" alt="">
下图是上述推导过程建立起来的AST“1、2、3……”等编号是AST节点创建的顺序
<img src="https://static001.geekbang.org/resource/image/44/a5/443c87e6af51a42a76f5d58220e4fda5.jpg" alt="">
好了我们把自顶向下分析算法做了总体概述并讲清楚了最左推导的含义现在来看看LL算法到底是怎么回事。
## 计算和使用First集合
LL算法是带有预测能力的自顶向下算法。在推导的时候我们希望当存在多个候选的产生式时瞄一眼下一个或多个Token就知道采用哪个产生式。如果只需要预看一个Token就是LL(1)算法。
拿statement的语法举例子它有好几个产生式分别产生if语句、while语句、switch语句……
```
statement
: block
| IF parExpression statement (ELSE statement)?
| FOR '(' forControl ')' statement
| WHILE parExpression statement
| DO statement WHILE parExpression ';'
| SWITCH parExpression '{' switchBlockStatementGroup* switchLabel*
| RETURN expression? ';'
| BREAK IDENTIFIER? ';'
| CONTINUE IDENTIFIER? ';'
| SEMI
| statementExpression=expression ';'
| identifierLabel=IDENTIFIER ':' statement
;
```
如果我看到下一个Token是if那么后面跟着的肯定是if语句这样就实现了预测不需要一个一个产生式去试。
问题来了if语句的产生式的第一个元素就是一个终结符这自然很好判断可如果是一个非终结符比如表达式语句那该怎么判断呢
我们可以为statement的每条分支计算一个集合集合包含了这条分支所有可能的起始Token。如果每条分支的起始Token是不一样的也就是这些集合的交集是空集那么就很容易根据这个集合来判断该选择哪个产生式。我们把这样的集合**就叫做这个产生式的First集合。**
First集合的计算很直观假设我们要计算的产生式是x
- 如果x以Token开头那么First(x)包含的元素就是这个Token比如if语句的First集合就是{IF}。
- 如果x的开头是非终结符a那么First(x)要包含First(a)的所有成员。比如expressionStatment是以expression开头因此它的First集合要包含First(expression)的全体成员。
- 如果x的第一个元素a能够产生ε那么还要再往下看一个元素b把First(b)的成员也加入到First(x)以此类推。如果所有元素都可能返回ε那么First(x)也应该包含ε意思是x也可能产生ε。比如下面的blockStatements产生式它的第一个元素是blockStatement*也就意味着blockStatement的数量可能为0因此可能产生ε。那么First(blockStatements)除了要包含First(blockStatement)的全部成员,还要包含后面的“;”。
```
blockStatements
: blockStatement*
;
```
- 最后如果x是一个非终结符它有多个产生式可供选择那么First(x)应包含所有产生式的First()集合的成员。比如statement的First集合要包含if、while等所有产生式的First集合的成员。并且如果这些产生式只要有一个可能产生ε那么x就可能产生ε因此First(x)就应该包含ε。
在本讲的示例程序里,我们可以用[SampleGrammar.expressionGrammar()](https://github.com/RichardGong/PlayWithCompiler/blob/master/lab/16-18/src/main/java/play/parser/SampleGrammar.java)方法获得一个表达式的语法把它dump()一下,这其实是消除了左递归的表达式语法:
```
expression : assign ;
assign : equal | assign1 ;
assign1 : '=' equal assign1 | ε;
equal : rel equal1 ;
equal1 : ('==' | '!=') rel equal1 | ε ;
rel : add rel1 ;
rel1 : ('&gt;=' | '&gt;' | '&lt;=' | '&lt;') add rel1 | ε ;
add : mul add1 ;
add1 : ('+' | '-') mul add1 | ε ;
mul : pri mul1 ;
mul1 : ('*' | '/') pri mul1 | ε ;
pri : ID | INT_LITERAL | LPAREN expression RPAREN ;
```
我们用GrammarNode类代表语法的节点形成一张语法图蓝色节点的下属节点之间是“或”的关系也就是语法中的竖线
<img src="https://static001.geekbang.org/resource/image/a9/7b/a9a2210fcf94ac474259fca459b86e7b.jpg" alt="">
基于这个数据结构能计算每个非终结符的First集合可以参考[LLParser](https://github.com/RichardGong/PlayWithCompiler/blob/master/lab/16-18/src/main/java/play/parser/LLParser.java)类的caclFirstSets()方法。运行示例程序可以打印出表达式语法中各个非终结符的First集合。
在计算时你要注意因为上下文无关文法是允许递归嵌套的所以这些GrammarNode节点构成的是一个图而不是树不能通过简单的遍历树的方法来计算First集合。比如pri节点是expression的后代节点但pri又引用了expressionpri-&gt;(expression)。这样计算First(expression)需要用到First(pri)而计算First(pri)又需要依赖First(expression)。
破解这个僵局的方法是用“不动点法”来计算。多次遍历图中的节点看看每次有没有计算出新的集合成员。比如第一遍计算的时候当求First(pri)的时候它所依赖的First(expression)中的成员可能不全,等下一轮继续计算时,发现有新的集合成员,再加进来就好了,直到所有集合的成员都没有变动为止。
现在我们可以用First集合进行分支判断了不过还要处理产生式可能为ε的情况比如“+mul add1 | ε”或“blockStatement*”都会产生ε。
## 计算和使用Follow集合
对ε的处理分成两种情况。
**第一种情况,是产生式中的部分元素会产生ε。**比如在Java语法里声明一个类成员的时候可能会用public、private这些来修饰但也可以省略不写。在语法规则中这个部分是“accessModifier?”,它就可能产生ε。
```
memberDeclaration : accessModifier? type identifier ';' ;
accessModifier : 'public' | 'private' ;
type : 'int' | 'long' | 'double' ;
```
所以,当我们遇到下面这两个语句的时候,都可以判断为类成员的声明:
```
public int a;
int b;
```
这时type能够产生的终结符 intlongdouble也在memberDeclaration的First集合中。这样我们实际上把accessModifier给穿透了直接到了下一个非终结符type。所以这类问题依靠First集合仍然能解决。在解析的过程中如果下一个Token是 int我们可以认为accessModifier返回了ε忽略它继续解析下一个元素type因为它的First集合中才会包含 int
**第二种情况是产生式本身(而不是其组成部分)产生ε。**这类问题仅仅依靠First集合是无法解决的要引入另一个集合Follow集合。它是所有可能跟在某个非终结符之后的终结符的集合。
以block语句为例在PlayScript.g4中大致是这样定义的
```
block
: '{' blockStatements '}'
;
blockStatements
: blockStatement*
;
blockStatement
: variableDeclarators ';'
| statement
| functionDeclaration
| classDeclaration
;
```
也就是说block是由blockStatements构成的而blockStatements可以由0到n个blockStatement构成因此可能产生ε。
接下来我们来看看解析block时会发生什么。
假设花括号中一个语句也没有也就是blockStatments实际上产生了ε。那么在解析block时首先读取了一个Token即“{”然后处理blockStatements我们再预读一个Token发现是“}”那这个右花括号是blockStatement的哪个产生式的呢实际上它不在任何一个产生式的First集合中下面是进行判断的伪代码
```
nextToken = tokens.peek(); //得到'}'
nextToken in First(variableDeclarators) ? //no
nextToken in First(statement) ? //no
nextToken in First(functionDeclaration) ? //no
nextToken in First(classDeclaration) ? //no
```
我们找不到任何一个可用的产生式。这可怎么办呢除了可能是blockStatments本身产生了ε之外还有一个可能性就是出现语法错误了。而要继续往下判断就需要用到Follow集合。
像blockStatements的Follow集合只有一个元素就是右花括号“}”。所以我们只要再检查一下nextToken是不是花括号就行了
```
//伪代码
nextToken = tokens.peek(); //得到'}'
nextToken in First(variableDeclarators) ? //no
nextToken in First(statement) ? //no
nextToken in First(functionDeclaration) ? //no
nextToken in First(classDeclaration) ? //no
if (nextToken in Follow(blockStatements)) //检查Follow集合
return Epsilon; //推导出ε
else
error; //语法错误
```
那么怎么计算非终结符x的Follow集合呢
- 扫描语法规则看看x后面都可能跟哪些符号。
- 对于后面跟着的终结符都加到Follow(x)集合中去。
- 如果后面是非终结符就把它的First集合加到自己的Follow集合中去。
- 最后,如果后面的非终结符可能产出ε,就再往后找,直到找到程序终结符号。
这个符号通常记做$意味一个程序的结束。比如在表达式的语法里expression 后面可能跟这个符号expression 的所有右侧分支的后代节点也都可能跟这个符号也就是它们都可能出现在程序的末尾。但另一些非终结符后面不会跟这个符号如blockstatements因为它后面肯定会有“}”。
你可以参考[LLParser](https://github.com/RichardGong/PlayWithCompiler/blob/master/lab/16-18/src/main/java/play/parser/LLParser.java)类的caclFollowSets()方法这里也要用到不动点法做计算。运行程序可以打印出示例语法的的Follow集合。我把程序打印输出的First和follow集合整理如下其实打印输出还包含一些中间节点这里就不展示了
<img src="https://static001.geekbang.org/resource/image/d5/30/d53bee2e3c9f0ce4e0d0eb6df05f3e30.jpg" alt="">
在表达式的解析中我们会综合运用First和Follow集合。比如对于“add1 -&gt; + mul add1 | ε”如果预读的下一个Token是+,那就按照第一个产生式处理,因为+在First(“+ mul add1”)集合中。如果预读的Token是&gt;那它肯定不在First(add1)中而我们要看它是否属于Follow(add1)如果是那么add1就产生一个ε否则就报错。
## LL算法和文法
现在我们已经建立了对First集合、Follow集合和LL算法计算过程的直觉认知。这样再写出算法的实现就比较容易了。用LL算法解析语法的时候我们可以选择两种实现方式。
第一种,还是采用递归下降算法,只不过现在的递归下降算法是没有任何回溯的。无论走到哪一步,我们都能准确地预测出应该采用哪个产生式。
第二种是采用表驱动的方式。这个时候需要基于我们计算出来的First和Follow集合构造一张预测分析表。根据这个表查找在遇到什么Token的情况下应该走哪条路径。
这两种方式是等价的你可以根据自己的喜好来选择我用的是第一种。关于算法我们就说这么多接下来我们谈谈如何设计符合LL(k)特别是LL(1)算法的文法。
我们已经知道左递归的文法是要避免的也知道要如何避免。除此之外我们要尽量抽取左公因子这样可以避免First集合产生交集。举例来说变量声明和函数声明的规则在前半截都差不多都是类型后面跟着标识符
```
statement : variableDeclare | functionDeclare | other;
variableDeclare : type Identifier ('=' expression)? ;
funcationDeclare : type Identifier '(' parameterList ')' block ;
```
具体例子如下:
```
int age
int cacl(int a, int b){
return a + b;
}
```
这样的语法规则如果按照LL(1)算法First(variableDeclare)和First(funcationDeclare)是相同的没法决定走哪条路径。你就算用LL(2)也是一样的要用到LL(3)才行。但对于LL(k) k &gt; 1来说程序开销有点儿大因为要计算更多的集合构造更复杂的预测分析表。
不过这个问题很容易解决,只要把它们的左公因子提出来就可以了:
```
statement: declarator | other;
declarator : declarePrefix variableDeclarePostfix
|functionDeclarePostfix) ;
variableDeclarePostfix : ('=' expression)? ;
functionDeclarePostfix : '(' parameterList ')' block ;
```
这样解析程序先解析它们的公共部分即declarePrefix然后再看后面的差异。这时它俩的First集合一个{ = ; },一个是{ ( },两者没有交集,能够很容易区分。
## 课程小结
本节课我们比较全面地梳理了自顶向下算法。语法解析过程可以看做是对图的遍历过程,遍历时可以采取深度优先或广度优先的策略,这里要注意,你可能在做深度优先遍历的时候,误用广度优先的思路。
针对LL算法我们通过实例分析了First集合和Follow集合的使用场景和计算方式。掌握了这两个核心概念特别是熟悉它们的使用场景你会彻底掌握LL算法。
## 一课一思
处理ε是LL算法中的关键点。在你熟悉的语言中哪些语法会产生ε你在做语法解析的时候会怎样处理它们欢迎在留言区分享你的思考。
最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友。
本节课的示例代码我放在了文末,供你参考。
- lab/1618算法篇的示例代码[码云](https://gitee.com/richard-gong/PlayWithCompiler/tree/master/lab/16-18) [GitHub](https://github.com/RichardGong/PlayWithCompiler/tree/master/lab/16-18)
- LLParser.javaLL算法的语法解析器[码云](https://gitee.com/richard-gong/PlayWithCompiler/blob/master/lab/16-18/src/main/java/play/parser/LLParser.java) [GitHub](https://github.com/RichardGong/PlayWithCompiler/blob/master/lab/16-18/src/main/java/play/parser/LLParser.java)

View File

@@ -0,0 +1,306 @@
<audio id="audio" title="18 | 移进和规约用LR算法推演一个实例" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/5d/c7/5d6271afbc52cfa835bb4744216d12c7.mp3"></audio>
到目前为止我们所讨论的语法分析算法都是自顶向下的。与之相对应的是自底向上的算法比如本节课要探讨的LR算法家族。
LR算法是一种自底向上的算法它能够支持更多的语法而且没有左递归的问题。第一个字母L与LL算法的第一个L一样代表从左向右读入程序。第二个字母R指的是RightMost最右推导也就是在使用产生式的时候是从右往左依次展开非终结符。例如对于“add-&gt;add+mul”这样一个产生式是优先把mul展开然后再是add。在接下来的讲解过程中你会看到这个过程。
自顶向下的算法是递归地做模式匹配从而逐步地构造出AST。那么自底向上的算法是如何构造出AST的呢答案是用移进-规约的算法。
本节课,我就带你通过移进-规约方法自底向上地构造AST完成语法的解析。接下来我们先通过一个例子看看自底向上语法分析的过程。
## 通过实例了解自底向上语法分析的过程
我们选择熟悉的语法规则:
```
add -&gt; mul
add -&gt; add + mul
mul -&gt; pri
mul -&gt; mul * pri
pri -&gt; Int | (add)
```
然后来解析“2+3*5”这个表达式AST如下
<img src="https://static001.geekbang.org/resource/image/1b/70/1ba6be3467aab986c181203f82dbb670.jpg" alt="">
我们分步骤看一下解析的具体过程。
第1步看到第一个Token是Int2。我们把它作为AST的第一个节点同时把它放到一个栈里就是图中红线左边的部分。这个栈代表着正在处理的一些AST节点把Token移到栈里的动作叫做**移进Shift。**
<img src="https://static001.geekbang.org/resource/image/00/2c/00a3c4a21899375d98869b6a8af5672c.jpg" alt="">
第2步根据语法规则Int是从pri推导出来的pri-&gt;Int那么它的上级AST肯定是pri所以我们给它加了一个父节点pri同时也把栈里的Int替换成了pri。这个过程是语法推导的逆过程叫做**规约Reduce。**
Reduce这个词你在学Map-Reduce时可能接触过它相当于我们口语化的“倒推”。具体来讲它是从工作区里倒着取出1到n个元素根据某个产生式组合出上一级的非终结符也就是AST的上级节点然后再放进工作区也就是竖线的左边
这个时候栈里可能有非终结符也可能有终结符它仿佛是我们组装AST的一个工作区。竖线的右边全都是Token也就是终结符它们在等待处理。
<img src="https://static001.geekbang.org/resource/image/64/c8/6499b8cfec0a4b6fdc5e18a47a9cc6c8.jpg" alt="">
第3步与第2步一样因为pri只能是mul推导出来的产生式是“mul-&gt;pri”所以我们又做了一次规约。
<img src="https://static001.geekbang.org/resource/image/48/c5/48db9c66da05591080355ce918af14c5.jpg" alt="">
第4步我们根据“add-&gt;mul”产生式将mul规约成add。至此我们对第一个Token做了3次规约已经到头了。这里为什么做规约而不是停在mul上移进+号是有原因的。因为没有一个产生式是mul后面跟+号而add后面却可以跟+号。
<img src="https://static001.geekbang.org/resource/image/fa/05/fa907d7b0b0101253f59b5168c2e6e05.jpg" alt="">
第5步移进+号。现在栈里有两个元素了分别是add和+。
<img src="https://static001.geekbang.org/resource/image/91/e5/915a18c729b913a740b736c8f35f47e5.jpg" alt="">
第6步移进Int也就是数字3。栈里现在有3个元素。
<img src="https://static001.geekbang.org/resource/image/68/b5/68ce20f1aa631cf16c14abe5923594b5.jpg" alt="">
第7到第8步Int规约到pri再规约到mul。
到目前为止,我们做规约的方式都比较简单,就是对着栈顶的元素,把它反向推导回去。
<img src="https://static001.geekbang.org/resource/image/46/67/4699dbc9c29ca3a0914d3dc2fab19767.jpg" alt="">
第9步我们面临3个选择比较难。
第一个选择是继续把mul规约成add第二个选择是把“add+mul”规约成add。这两个选择都是错误的因为它们最终无法形成正确的AST。
<img src="https://static001.geekbang.org/resource/image/06/dd/068a542af8005f7ab6d0a30c6ad711dd.jpg" alt="">
第三个选择也就是按照“mul-&gt;mul*pri”继续移进 *号 而不是做规约。只有这样才能形成正确的AST就像图中的虚线。
<img src="https://static001.geekbang.org/resource/image/81/3b/819a522455602cf54818fc7a9c81b33b.jpg" alt="">
第10步移进Int也就是数字5。
<img src="https://static001.geekbang.org/resource/image/d1/20/d1c83146b3bda9f13b14148eee0e5520.jpg" alt="">
第11步Int规约成pri。
<img src="https://static001.geekbang.org/resource/image/81/77/818bd7df8fea9c91a4f67629822f3177.jpg" alt="">
第12步mul*pri规约成mul。
注意这里也有两个选择比如把pri继续规约成mul。但它显然也是错误的选择。
<img src="https://static001.geekbang.org/resource/image/16/92/16dddb819a554e55e092388f0c649c92.jpg" alt="">
第13步add+mul规约成add。
<img src="https://static001.geekbang.org/resource/image/9a/54/9a64d2d45f860d727d69ce980b071a54.jpg" alt="">
至此我们就构建完成了一棵正确的AST并且栈里也只剩下了一个元素就是根节点。
整个语法解析过程,实质是**反向最右推导Reverse RightMost Derivation。**什么意思呢如果把AST节点根据创建顺序编号就是下面这张图呈现的样子根节点编号最大是13
<img src="https://static001.geekbang.org/resource/image/f2/59/f2f85727e7c4787d00f0c60d08c0d159.jpg" alt="">
但这是规约的过程如果是从根节点开始的推导过程顺序恰好是反过来的先是13号再是右子节点12号再是12号的右子节点11号以此类推。我们把这个最右推导过程写在下面
<img src="https://static001.geekbang.org/resource/image/c4/3d/c46ca0a9766ee1ee87869fc2e92e313d.jpg" alt="">
在语法解析的时候我们是从底下反推回去所以叫做反向的最右推导过程。从这个意义上讲LR算法中的R带有反向Reverse和最右Reightmost这两层含义。
在最右推导过程中,我加了下划线的部分,叫做一个**句柄Handle**。句柄是一个产生式的右边部分以及它在一个右句型最右推导可以得到的句型中的位置。以最底下一行为例这个句柄“Int”是产生式“pri-&gt;Int”的右边部分它的位置是句型“Int + Int * Int”的第一个位置。
简单来说,句柄,就是产生式是在这个位置上做推导的,如果需要做反向推导的话,也是从这个位置去做规约。
针对这个简单的例子我们可以用肉眼进行判断找到正确的句柄做出正确的选择。不过要把这种判断过程变成严密的算法做到在每一步都采取正确的行动知道该做移进还是规约做规约的话按照哪个产生式这就是LR算法要解决的核心问题了。
那么,如何找到正确的句柄呢?
## 找到正确的句柄
我们知道,最右推导是从最开始的产生式出发,经过多步推导(多步推导记做-&gt;*),一步步形成当前的局面 也就是左边栈里有一些非终结符和终结符右边还可以预看1到k个Token
```
add -&gt;* 栈 | Token
```
我们要像侦探一样根据手头掌握的信息反向推导出这个多步推导的路径从而获得正确的句柄。我们依据的是左边栈里的信息以及右边的Token串。对于LR(0)算法来说我们只依据左边的栈就能找到正确的句柄对于LR(1)算法来说我们可以从右边预看一个Token。
我们的思路是根据语法规则复现这条推导路径。以第8步为例下图是它的推导过程橙色的路径是唯一能够到达第8步的路径。知道了正向推导的路径自然知道接下来该做什么在第8步我们正确的选择是做移进。
<img src="https://static001.geekbang.org/resource/image/09/25/0958e017881e271edbc1362034425825.jpg" alt="">
为了展示这个推导过程,我引入了一个新概念:**项目Item。**
Item代表带有“.”符号的产生式。比如“pri-&gt;(add)”可以产生4个Item“.”分别在不同的位置。“.”可以看做是前面示意图中的竖线,左边的看做已经在栈里的部分,“.”右边的看做是期待获得的部分:
```
pri-&gt;.(add)
pri-&gt;(.add)
pri-&gt;(add.)
pri-&gt;(add).
```
上图其实是一个NFA利用这个NFA我们表达了所有可能的推导步骤。每个Item或者状态在接收到一个符号的时候就迁移到下一个状态比如“add-&gt;.add+mul”在接收到一个add的时候就迁移到“add-&gt;add.+mul”再接收到一个“+”就迁移到“add-&gt;add+.mul”。
在这个状态图的左上角我们用一个辅助性的产生式“start-&gt;add”作为整个NFA的唯一入口。从这个入口出发可以用这个NFA来匹配栈里内容比如在第8步的时候栈以及右边下一个Token的状态如下其中竖线左边是栈的内容
```
add + mul | *
```
在NFA中我们从start开始遍历基于栈里的内容能找到图中橙色的多步推导路径。在这个状态迁移过程中导致转换的符号分别是“ε、add、+、ε、mul”忽略其中的ε就是栈里的内容。
在NFA中我们查找到的Item是“mul-&gt;mul.*pri”。这个时候“.”在Item的中间。因此下一个操作只能是一个Shift操作也就是把下一个Token*号,移进到栈里。
如果“.”在Item的最后则对应一个规约操作比如在第12步栈里的内容是
```
add + mul | $ //$代表Token串的结尾
```
<img src="https://static001.geekbang.org/resource/image/4d/62/4dc5366506b8884d7af97b62fbaade62.jpg" alt="">
这个时候的Item是“add-&gt;add+mul.”。对于所有点符号在最后面的Item我们已经没有办法继续向下迁移了这个时候需要做一个规约操作也就是基于“add + mul”规约到add也就是到“add-&gt;.add+mul”这个状态。对于任何的ε转换其逆向操作也是规约比如图中从“add-&gt;.add+mul”规约到“start-&gt;.add”。
但做规约操作之前我们仍然需要检查后面跟着的Token是不是在Follow(add)中。对于add来说它的Follow集合包括{$ + }。如果是这些Token那就做规约。否则就报编译错误。
所以,现在清楚了,我们能通过这个有限自动机,跟踪计算出正确的推导过程。
当然了,在[16讲](https://time.geekbang.org/column/article/137286)里我提到每个NFA都可以转换成一个DFA。所以你可以直接在上面的NFA里去匹配也可以把NFA转成DFA避免NFA的回溯现象让算法效率更高。转换完毕的DFA如下
<img src="https://static001.geekbang.org/resource/image/a7/f4/a7cc157aca99e16e50b62011848d0af4.jpg" alt="">
在这个DFA中我同样标注了在第8步时的推导路径。
为了更清晰地理解LR算法的本质我们基于这个DFA再把语法解析的过程推导一遍。
第1步移进一个Int从状态1迁移到9。Item是“pri-&gt;Int.”。
<img src="https://static001.geekbang.org/resource/image/8d/b3/8d3595409dc45886c36368621ad549b3.jpg" alt="">
第2步依据“pri-&gt;Int”做规约从状态9回到状态1。因为现在栈里有个pri元素所以又迁移进了状态8。
<img src="https://static001.geekbang.org/resource/image/69/ed/69c24bc62939a114dc6bc922d63011ed.jpg" alt="">
第3步依据“mul-&gt;pri”做规约从状态8回到状态1再根据栈里的mul元素进入状态7。**注意,**在状态7的时候下一步的走向有两个可能的方向分别是“add-&gt;mul.”和“mul-&gt;mul.*pri”这两个Item代表的方向。
基于“add-&gt;mul.”会做规约而基于“mul-&gt;mul.*pri”会做移进这就需要看看后面的Token了。如果后面的Token是 *号,那其实要选第二个方向。但现在后面是+号,所以意味着这里只能做规约。
<img src="https://static001.geekbang.org/resource/image/9e/cf/9ea1bbad681f1b23d83b6c8bb65feecf.jpg" alt="">
第4步依据“add-&gt;mul”做规约从状态7回到状态1再依据add元素进入状态2。
<img src="https://static001.geekbang.org/resource/image/a6/4e/a618509b0c0c96f8705b6016f9aeb04e.jpg" alt="">
第5步移进+号。这对应状态图上的两次迁移首先根据栈里的第一个元素add从1迁移到2。然后再根据“+”从2到3。Item的变化是
>
<p>状态1start-&gt;.add<br>
状态1add-&gt;.add+mul<br>
状态2add-&gt;add.+mul<br>
状态3add-&gt;add+.mul</p>
你看通过移进这个加号我们实际上知道了这个表达式顶部必然有一个“add+mul”的结构。
<img src="https://static001.geekbang.org/resource/image/52/22/5237305c41a308914748b62a60829d22.jpg" alt="">
第6到第8步移进Int并一直规约到mul。状态变化是先从状态3到状态9然后回到状态3再进到状态4。
<img src="https://static001.geekbang.org/resource/image/aa/e1/aa333e0cb2a6483f0850f37bb7c01fe1.jpg" alt="">
<img src="https://static001.geekbang.org/resource/image/92/bd/923202cc491e8958f5d38ae1a76af7bd.jpg" alt="">
第9步移进一个*。根据栈里的元素迁移路径是1-&gt;2-&gt;3-&gt;4-&gt;5。
<img src="https://static001.geekbang.org/resource/image/73/58/73d05c03c8d29f8c69b8e611696eec58.jpg" alt="">
第10步移进Int进入状态9。
<img src="https://static001.geekbang.org/resource/image/7f/87/7f0b98204bf0a19341acad593cb28987.jpg" alt="">
第11步根据“pri-&gt;Int”规约到pri先退回到状态5接着根据pri进入状态6。
<img src="https://static001.geekbang.org/resource/image/f5/a9/f51998b10db36da51129a3a8332d7fa9.jpg" alt="">
第12步根据“mul-&gt;mul*pri”规约到mul从而退回到状态4。
<img src="https://static001.geekbang.org/resource/image/b8/ea/b8275d44114195013807b3c9b3148dea.jpg" alt="">
第13步根据“add-&gt;add+mul”规约到add从而退回到状态2。
<img src="https://static001.geekbang.org/resource/image/8c/35/8c65fdb023f39abcfe0311e76f722635.jpg" alt="">
从状态2再根据“start-&gt;add”再规约一步就变成了start回到状态1解析完成。
现在我们已经对整个算法的整个执行过程建立了直觉认知。如果想深入掌握LR算法我建议你把这种推导过程多做几遍自然会了然于胸。建立了直觉认知以后接下来我们再把LR算法的类型和实现细节讨论一下。
## LR解析器的类型和实现
LR算法根据能力的强弱和实现的复杂程度可以分成多个级别分别是LR(0)、SLR(k)即简单LR、LALR(k)Look ahead LR和LR(k)其中k表示要在Token队列里预读k个Token。
<img src="https://static001.geekbang.org/resource/image/76/69/76bb2ef4002f8e207c229978931b2669.jpg" alt="">
我来讲解一下这四种类型算法的特点,便于你选择和使用。
**LR(0)不需要预看右边的Token仅仅根据左边的栈就能准确进行反向推导。**比如前面DFA中的状态8只有一个Item“mul-&gt;pri.”。如果处在这个状态那接下来操作是规约。假设存在另一个状态它也只有一个Item点符号不在末尾比如“mul-&gt;mul.*pri”那接下来的操作就是移进把下一个输入放到栈里。
但实际使用的语法规则很少有这么简单的。所以LR(0)的表达能力太弱能处理的语法规则有限不太有实用价值。就像在前面的例子中如果我们不往下预读一个Token仅仅利用左边工作区的信息是找不到正确的句柄的。
比如在状态7中我们可以做两个操作
- 对于第一个Item“add-&gt;mul.”,需要做一个规约操作。
- 对于第二个Item“mul-&gt;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 -&gt; exp
exp -&gt; lvalue = rvalue
exp -&gt; rvalue
lvalue -&gt; Id
lvalue -&gt; *rvalue
rvalue -&gt; lvalue
```
这个语法说的是关于左值和右值的情况,我们曾在语义分析的时候说过。在这个语法里,右值只能出现在赋值符号右边。
在状态2如果下一个输入是“=”,那么做移进和规约都是可以的。因为“=”在rvalue的Follow集合中。
<img src="https://static001.geekbang.org/resource/image/0c/cd/0cf9a256a0fc6b4681de8381e4cdb9cd.jpg" alt="">
怎么来处理这种冲突呢仅仅根据Follow集合来判断是否Reduce不太严谨。因为在上图状态2的情况下即使后面跟着的是“=”,我们仍然不能做规约。因为你一规约,就成了一个右值,但它在等号的左边,显然是跟我们的语法定义冲突的。
办法是Follow集合拆了把它的每个成员都变成Item的一部分。这样我们就能做更细致的判断。如下图所示这样细化以后我们发现在状态2中只有下一个输入是“$”的时候才能做规约。这就是LR(1)算法的原理,它更加强大。
<img src="https://static001.geekbang.org/resource/image/5b/cb/5baeddc5b854173b1ec2a4c5bdef33cb.jpg" alt="">
但LR(1)算法也有一个缺点就是DFA可能会很大。在语法分析阶段DFA的大小会随着语法规则的数量呈指数级上升一个典型的语言的DFA状态可能达到上千个这会使语法分析的性能很差从而也丧失了实用性。
**LALR(k)是基于这个缺点做的改进。**它用了一些技巧能让状态数量变得比较少但处理能力没有太大的损失。YACC和Bison这两个工具就是基于LALR(1)算法的。
## 课程小结
今天我们讲了自底向上的LR算法的原理包括移进-规约如何寻找正确的句柄如果基于NFA和DFA决定如何做移进和规约。
LR算法是公认的比较难学的一个算法。好在我们已经在前两讲给它做了技术上的铺垫了包括NFA和DFAFirst和Follow集合。这节课我们重点在于建立直观理解特别是如何依据栈里的信息做正确的反推。这个直觉认知很重要建立这个直觉的最好办法就是像本节课一样根据实例来画图、推导。这样在你真正动手写算法的时候就胸有成竹了
到今天为止,我们已经把前端技术中的关键算法都讲完了。**不过我还是想强调一下,**如果想真正掌握这些算法,必须动手实现一下才行,勤动手才是王道。
## 一课一思
在讲自顶向下的算法时我提到递归思维是重要的计算机科学思维方式。而自底向上的方法也是另一种重要的思维方式。那么请结合你的经验思考一下在你的领域内是否有一些问题用自底向上的方法能更好地解决。LR算法的移进-规约思想,能否在解决其他自底向上的问题中发挥作用?欢迎在留言区分享你的经验和思考。
最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友。
本节课的示例代码我放在了文末,供你参考。
- lab/16-18算法篇的示例代码[码云](https://gitee.com/richard-gong/PlayWithCompiler/tree/master/lab/16-18) [GitHub](https://github.com/RichardGong/PlayWithCompiler/tree/master/lab/16-18)
- LLParser.javaLL算法的语法解析器[码云](https://gitee.com/richard-gong/PlayWithCompiler/blob/master/lab/16-18/src/main/java/play/parser/LRParser.java) [GitHub](https://github.com/RichardGong/PlayWithCompiler/blob/master/lab/16-18/src/main/java/play/parser/LRParser.java)