mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2026-05-10 19:54:28 +08:00
mod
This commit is contained in:
403
极客时间专栏/编译原理之美/实现一门脚本语言 · 算法篇/16 | NFA和DFA:如何自己实现一个正则表达式工具?.md
Normal file
403
极客时间专栏/编译原理之美/实现一门脚本语言 · 算法篇/16 | NFA和DFA:如何自己实现一个正则表达式工具?.md
Normal 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 Automaton,NFA)。<br>
|
||||
**其次,**基于NFA处理字符串,看看它有什么特点。<br>
|
||||
**然后,**把非确定的有限自动机转换成确定的有限自动机(Deterministic Finite Automaton,DFA)<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("regex1",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+”:
|
||||
|
||||
>
|
||||
没有办法跳过s,s至少经过一次。
|
||||
|
||||
|
||||
<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 ε -> 2”的意思是从状态0通过ε转换,到达状态2 :
|
||||
|
||||
```
|
||||
NFA states:
|
||||
0 ε -> 2
|
||||
ε -> 8
|
||||
ε -> 14
|
||||
2 i -> 3
|
||||
3 n -> 5
|
||||
5 t -> 7
|
||||
7 ε -> 1
|
||||
1 (end)
|
||||
acceptable
|
||||
8 [a-z]|[A-Z] -> 9
|
||||
9 ε -> 10
|
||||
ε -> 13
|
||||
10 [0-9]|[a-z]|[A-Z] -> 11
|
||||
11 ε -> 10
|
||||
ε -> 13
|
||||
13 ε -> 1
|
||||
14 [0-9] -> 15
|
||||
15 ε -> 14
|
||||
ε -> 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("trying state : " + state.name + ", index =" + 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 < 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的ε闭包”,简单一点儿,我们称之为s0,s0包含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("trying DFAState : " + state.name + ", index =" + 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 < 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.java(DFA的状态):[码云](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)
|
||||
295
极客时间专栏/编译原理之美/实现一门脚本语言 · 算法篇/17 | First和Follow集合:用LL算法推演一个实例.md
Normal file
295
极客时间专栏/编译原理之美/实现一门脚本语言 · 算法篇/17 | First和Follow集合:用LL算法推演一个实例.md
Normal 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->mul+add”产生式为例,它会先把mul这个非终结符展开,比如替换成pri,然后再把它的第一个非终结符pri展开。只有把这条分支都向下展开之后,才会回到上一级节点,去展开它的兄弟节点。
|
||||
|
||||
递归下降算法就是深度优先的,这也是它不能处理左递归的原因,因为左边的分支永远也不能展开完毕。
|
||||
|
||||
而针对“add->add+mul”这个产生式,**广度优先**会把add和mul这两个都先展开,这样就形成了四条搜索路径,分别是mul+mul、add+mul+mul、add+pri和add+mul*pri。接着,把它们的每个非终结符再一次展开,会形成18条新的搜索路径。
|
||||
|
||||
所以,广度优先遍历,需要探索的路径数量会迅速爆炸,成指数级上升。哪怕用下面这个最简单的语法,去匹配“2+3”表达式,都需要尝试20多次,更别提针对更复杂的表达式或者采用更加复杂的语法规则了。
|
||||
|
||||
```
|
||||
//一个很简单的语法
|
||||
add -> pri //1
|
||||
add -> add + pri //2
|
||||
pri -> Int //3
|
||||
pri -> (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 : ('>=' | '>' | '<=' | '<') 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又引用了expression(pri->(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能够产生的终结符 ‘int’、‘long’和‘double’也在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 -> + mul add1 | ε”,如果预读的下一个Token是+,那就按照第一个产生式处理,因为+在First(“+ mul add1”)集合中。如果预读的Token是>号,那它肯定不在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 > 1来说,程序开销有点儿大,因为要计算更多的集合,构造更复杂的预测分析表。
|
||||
|
||||
不过这个问题很容易解决,只要把它们的左公因子提出来就可以了:
|
||||
|
||||
```
|
||||
statement: declarator | other;
|
||||
declarator : declarePrefix (variableDeclarePostfix
|
||||
|functionDeclarePostfix) ;
|
||||
variableDeclarePostfix : ('=' expression)? ;
|
||||
functionDeclarePostfix : '(' parameterList ')' block ;
|
||||
|
||||
```
|
||||
|
||||
这样,解析程序先解析它们的公共部分,即declarePrefix,然后再看后面的差异。这时,它俩的First集合,一个{ = ; },一个是{ ( },两者没有交集,能够很容易区分。
|
||||
|
||||
## 课程小结
|
||||
|
||||
本节课我们比较全面地梳理了自顶向下算法。语法解析过程可以看做是对图的遍历过程,遍历时可以采取深度优先或广度优先的策略,这里要注意,你可能在做深度优先遍历的时候,误用广度优先的思路。
|
||||
|
||||
针对LL算法,我们通过实例分析了First集合和Follow集合的使用场景和计算方式。掌握了这两个核心概念,特别是熟悉它们的使用场景,你会彻底掌握LL算法。
|
||||
|
||||
## 一课一思
|
||||
|
||||
处理ε是LL算法中的关键点。在你熟悉的语言中,哪些语法会产生ε,你在做语法解析的时候会怎样处理它们?欢迎在留言区分享你的思考。
|
||||
|
||||
最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友。
|
||||
|
||||
本节课的示例代码我放在了文末,供你参考。
|
||||
|
||||
- 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.java(LL算法的语法解析器):[码云](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)
|
||||
306
极客时间专栏/编译原理之美/实现一门脚本语言 · 算法篇/18 | 移进和规约:用LR算法推演一个实例.md
Normal file
306
极客时间专栏/编译原理之美/实现一门脚本语言 · 算法篇/18 | 移进和规约:用LR算法推演一个实例.md
Normal 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->add+mul”这样一个产生式,是优先把mul展开,然后再是add。在接下来的讲解过程中,你会看到这个过程。
|
||||
|
||||
自顶向下的算法,是递归地做模式匹配,从而逐步地构造出AST。那么自底向上的算法是如何构造出AST的呢?答案是用移进-规约的算法。
|
||||
|
||||
本节课,我就带你通过移进-规约方法,自底向上地构造AST,完成语法的解析。接下来,我们先通过一个例子看看自底向上语法分析的过程。
|
||||
|
||||
## 通过实例了解自底向上语法分析的过程
|
||||
|
||||
我们选择熟悉的语法规则:
|
||||
|
||||
```
|
||||
add -> mul
|
||||
add -> add + mul
|
||||
mul -> pri
|
||||
mul -> mul * pri
|
||||
pri -> Int | (add)
|
||||
|
||||
```
|
||||
|
||||
然后来解析“2+3*5”这个表达式,AST如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/1b/70/1ba6be3467aab986c181203f82dbb670.jpg" alt="">
|
||||
|
||||
我们分步骤看一下解析的具体过程。
|
||||
|
||||
第1步,看到第一个Token,是Int,2。我们把它作为AST的第一个节点,同时把它放到一个栈里(就是图中红线左边的部分)。这个栈代表着正在处理的一些AST节点,把Token移到栈里的动作叫做**移进(Shift)。**
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/00/2c/00a3c4a21899375d98869b6a8af5672c.jpg" alt="">
|
||||
|
||||
第2步,根据语法规则,Int是从pri推导出来的(pri->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->pri”,所以我们又做了一次规约。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/48/c5/48db9c66da05591080355ce918af14c5.jpg" alt="">
|
||||
|
||||
第4步,我们根据“add->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->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->Int”的右边部分,它的位置是句型“Int + Int * Int”的第一个位置。
|
||||
|
||||
简单来说,句柄,就是产生式是在这个位置上做推导的,如果需要做反向推导的话,也是从这个位置去做规约。
|
||||
|
||||
针对这个简单的例子,我们可以用肉眼进行判断,找到正确的句柄,做出正确的选择。不过,要把这种判断过程变成严密的算法,做到在每一步都采取正确的行动,知道该做移进还是规约,做规约的话,按照哪个产生式,这就是LR算法要解决的核心问题了。
|
||||
|
||||
那么,如何找到正确的句柄呢?
|
||||
|
||||
## 找到正确的句柄
|
||||
|
||||
我们知道,最右推导是从最开始的产生式出发,经过多步推导(多步推导记做->*),一步步形成当前的局面 (也就是左边栈里有一些非终结符和终结符,右边还可以预看1到k个Token)。
|
||||
|
||||
```
|
||||
add ->* 栈 | 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->(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串的结尾
|
||||
|
||||
```
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/4d/62/4dc5366506b8884d7af97b62fbaade62.jpg" alt="">
|
||||
|
||||
这个时候的Item是“add->add+mul.”。对于所有点符号在最后面的Item,我们已经没有办法继续向下迁移了,这个时候需要做一个规约操作,也就是基于“add + mul”规约到add,也就是到“add->.add+mul”这个状态。对于任何的ε转换,其逆向操作也是规约,比如图中从“add->.add+mul”规约到“start->.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->Int.”。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8d/b3/8d3595409dc45886c36368621ad549b3.jpg" alt="">
|
||||
|
||||
第2步,依据“pri->Int”做规约,从状态9回到状态1。因为现在栈里有个pri元素,所以又迁移进了状态8。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/69/ed/69c24bc62939a114dc6bc922d63011ed.jpg" alt="">
|
||||
|
||||
第3步,依据“mul->pri”做规约,从状态8回到状态1,再根据栈里的mul元素进入状态7。**注意,**在状态7的时候,下一步的走向有两个可能的方向,分别是“add->mul.”和“mul->mul.*pri”这两个Item代表的方向。
|
||||
|
||||
基于“add->mul.”会做规约,而基于“mul->mul.*pri”会做移进,这就需要看看后面的Token了。如果后面的Token是 *号,那其实要选第二个方向。但现在后面是+号,所以意味着这里只能做规约。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9e/cf/9ea1bbad681f1b23d83b6c8bb65feecf.jpg" alt="">
|
||||
|
||||
第4步,依据“add->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>状态1:start->.add<br>
|
||||
状态1:add->.add+mul<br>
|
||||
状态2:add->add.+mul<br>
|
||||
状态3:add->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->2->3->4->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->Int”规约到pri,先退回到状态5,接着根据pri进入状态6。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f5/a9/f51998b10db36da51129a3a8332d7fa9.jpg" alt="">
|
||||
|
||||
第12步,根据“mul->mul*pri”规约到mul,从而退回到状态4。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b8/ea/b8275d44114195013807b3c9b3148dea.jpg" alt="">
|
||||
|
||||
第13步,根据“add->add+mul”规约到add,从而退回到状态2。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8c/35/8c65fdb023f39abcfe0311e76f722635.jpg" alt="">
|
||||
|
||||
从状态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。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/76/69/76bb2ef4002f8e207c229978931b2669.jpg" alt="">
|
||||
|
||||
我来讲解一下这四种类型算法的特点,便于你选择和使用。
|
||||
|
||||
**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。
|
||||
|
||||
**SLR(Simple 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集合中。
|
||||
|
||||
<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和DFA,First和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.java(LL算法的语法解析器):[码云](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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user