前言#
在编译原理中语法分析可以说是编译器前端的核心。语法分析的输出抽象语法树更是一座建立在编译器前端和后端之间非非非非非常重要的桥梁。
我们知道编译器可以分为前后端而前后端又可以分为多个模块每个模块环环相扣体现出一种面向过程的编程思想。每一个模块的输入仅仅是上一个模块的输出而语法分析的产出物抽象语法树是连接前后端的唯一桥梁所有编译器后端的模块都必须依靠抽象语法树抽象语法树必须提供足够的信息以供后端许多模块使用所以设计好一个抽象语法树是十分重要的。一般来说一棵抽象语法树需要提供每条语法在源文件中的行号列号以及文件号等诸多信息当然了抽象语法树的设计很具有特殊性没有一套国际上通用的模式来套而是需要设计者根据需求定制。
语法分析器以词法分析器的产出(TOKENS)作为输入在语法规则限制下使用不同的分析算法产出满足语法的抽象语法树。而产出的语法树还需要经过语义分析器来进行类型检查这才算完成了编译器前端的工作。产出的抽象语法树之所以需要经过一次严格的类型检查是因为语法分析的过程使用的是一种与上下文无关的语法规则即上下文无关文法来进行分析接下来我们给出上下文无关文法的定义。
上下文无关文法#
定义CFG(N, T, S, R)
N - Nonterminal - 非终结符
T - Terminal - 终结符
S - Start Character - 开始符号
R - Syntax Rule - 语法规则
详细定义请参见https://en.wikipedia.org/wiki/Context-free_grammar
简单的说我们在制作编译器的过程中会遇到的上下文无关文法是长这样的
Copyarith_exp: exp PLUS exp | exp MINUS exp | exp TIMES exp | exp DIVIDE exp
好了基础的部分到这为止接下来才是本文的重要内容。
前面提到语法分析器有不同的算法可以进行语法分析我们就来谈一谈这些有印象但是不太了解的算法以及为什么会有这么多不同的算法。
一般来说语法分析的算法分为两种自顶向下的算法和自底向上的算法而这两类算法又有很多不同的实现方式我们只谈最主流的方式
自顶向下
递归下降算法
LL(1)
自底向上
LR(0)
LR(1)
这些算法按具体的实现方式又可以分为分析栈方式、分析表方式
其中自顶向下的方式就是分析栈的方式自底向上的方式就是分析表的方式。所谓分析栈的方式其实就是算法的过程类似于树的后序遍历所谓分析表的方式就是我之前一篇文章正则表达式匹配可以更快更简单 (but is slow in Java, Perl, PHP, Python, Ruby, ...)里提到的有限自动机DFA的方式。
自顶向下#
自顶向下的算法其基本思想就是枚举、穷举使用树的后序遍历穷举文法可以产出的所有句子然后跟输入做比较能够匹配成功说明语法正确。当然了我说的穷举所有结果不是真的把所有结果都计算出来其中会有一些优化的比如说后序遍历的过程中发现产生的第一个字母和输入的第一个字母不匹配会直接回溯而不是还傻傻的算下去。
文字化描述
给定文法CFG和待匹配句子s回答s能否从CFG推导出来
算法从G的开始符号出发随意推出某个句子t比较s和t
若 t == s 则回答 “是”
若 t != s 则回答 “否”
代码描述
Copytokens[]; // 所有tokeni = 0;stack = [S] // S是开始符号while (!stack.empty()) if (stack.top() is a terminal t) if (t == tokens[i]) pop(); //成功 i++; else backtrack(); //回溯 else if (stack.top() is a non-terminal T) pop(); push(next possible choice); // 请注意 possible
举个例子
给定CFG
CopyS -> N V N N -> s -> t -> g -> wV -> e -> d
待匹配句子 gdw
这个算法有很多问题首当其冲的就是回溯开销太大就像上图当发现匹配错误的时候分析栈需要回溯到原来的样子然后再次遍历这是不能忍的行为。
之前我说了在语法分析这块有很多的算法他们的出现都是为了解决前一个算法遇到的问题接下来我们看看递归下降算法解决了哪些问题。
递归下降算法#
首先介绍下在语法分析这一块程序员有两种实现方式一种是纯手工编码来实现算法然后制作语法分析器第二种方式就是利用语法生成器比如每一台 Linux 上都有的 Yacc Bison 等这些自动生成器会根据一些语法规则来自动生成代码完成语法分析真是爽爆啦。
不过主流的编译器比如 GCC LLVM 其实现方式就是纯手工编码的方式而在纯手工编码的方式中最最常用的就是递归下降算法这是个很有名的算法哦。
递归下降算法具有这些优点
分析高效线性时间
容易实现方便手工编码
错误定位和诊断信息准确准确定位语法错误
说了这么多优点来看看算法长啥样。递归下降算法的基本思想是建立在前面自顶向下算法之上的前面的自顶向下算法的最大弊端就是很多的回溯而如果这时候问你你有什么解决方案一个比较好的解决方案就是预测未来。
看看上面算法的这一句
Copy push(next possible choice); // 请注意 possible
如果我能预测未来我不是选择 possible 而是选择 right 比如我提前看一个符号我发现是 g 那么我就直接选择 push g。上面的算法就可以这样改
Copyparse_S() parse_N() parse_V() parse_N() parse_N() token = tokens[i++] // 前看 if (token == s || token = t ...) return; // OK error("expect s, t, but given ...") parse_V() token = tokens[i++] // 前看 if (token == e || token = d ...) return; // OK error("expect e, d, but given ...")
递归下降算法的基本思想用前看符号指导语法规则的选择对每一个非终结符构造一个分析函数。
我们看一段递归下降算法的代码会发现其实就是分治法(Divide and Conquer)算法经常长这个样子
Copyparse_X() token = nextToken() switch(token) case 1: parse_E(); eat('+'); parse_T(); // ... case 2: // ... ... default: error("expect ..., but given ...");
我们说很多主流编译器都是使用的递归下降算法来进行语法分析但是递归下降算法就真的这么好吗就无敌了吗
考虑以下文法
CopyE -> E + T -> TT -> T * F -> FF -> num
现在我的待匹配句子是 3+4*5 这时候该怎么写一个递归算法
你可能会这样写
Copyparse_E() token = tokens[i++] if (token == num) ? // 是调用 E + T 还是调用 T else error("expect ..., but given ...")
这一下子就把递归下降算法给难住了因为调用 E + T 和调用 T 都可以的这时候唯一的解决办法好像就是都试一遍看看谁满足。不过等等这怎么好像回到回溯的办法了其实这类问题还真是一个大问题不过对于递归算法这是一种可以避免的问题简单点说这不是硬伤而是可以通过聪明的程序员对语法的理解和改造足以解决的。比如对于这个文法我的代码可以这样写
Copyparse_E() parse_T() token = tokens[i++] while (token == '+') parse_T() token = tokens[i++]parse_T() parse_F() token = tokens[i++] while (token == '*') parse_F() token = tokens[i++]
其实这种问题是一类比较经典的问题就是二义性语法的问题这么一提我们当然知道消除二义性文法就是消除左递归和左因子嘛OK这些东西我们下面再谈。
不过写到这里还是要总结一下递归下降算法和自顶向下算法都是树的后序遍历一种是递归的方式另一种是递推的方式。用到的都是分治的思想。
以上提到的都是基于栈的实现方式接下来我们来看看基于表的实现方式也就是表驱动的算法。
LL(1)#
工欲善其事必先利其器。前面说到了语法分析器可以采用手工编码和自动生成器两种方式接下来的几个算法都是自动生成器里最常用的算法会用工具的同时能够理解工具的运行机理也是一件不错的事比如接下来我们要谈的 LL(1) 算法就是 ANTLR 选择的算法。
首先说说这个名字LL(1)第一个L表示从左到右读程序第二个L表示每次优先选择最左边的非终结符推导(1)表示前看一个符号。
LL(1)算法有以下优点
分析高效线性时间
错误定位和诊断信息准确
我们说LL(1)是一个表驱动的算法那怎么个表驱动法呢我们先回顾一下自顶向下的算法
Copytokens[]; // 所有tokeni = 0;stack = [S] // S是开始符号while (!stack.empty()) if (stack.top() is a terminal t) if (t == tokens[i]) pop(); //成功 i++; else backtrack(); //回溯 else if (stack.top() is a non-terminal T) pop(); push(next possible choice); // 请注意 possible
前面我们说来由于最后一行的push是随机选择选择完所有的情况因此导致回溯但是如果我每次都选择正确的情况那就不需要回溯啦。这是我梦想的代码
Copytokens[]; // 所有tokeni = 0;stack = [S] // S是开始符号while (!stack.empty()) if (stack.top() is a terminal t) if (t == tokens[i]) pop(); //成功 i++; else error("..."); //回溯个JB else if (stack.top() is a non-terminal T) pop(); push(next 正确的 choice); // 查表
没错所谓的表驱动就是给你提供一张表通过查表你就能决定下一步往哪走。而表驱动算法的主要工作就是把这张表给你算出来。
那么怎么构造一个分析表呢
其实很简单我通过肉眼就能看出来这个表不外乎就是所有的非终结符作为行所有的终结符作为列然后为每一条语法规则标上行号根据语法规则填表就完事了。比如我要填N这一行通过观察我发现N可以推出 s t g w也就是前看符号可能的情况所以这一行可以填上1 2 3 4其他行类似。
不过这样不是很规范于是乎科学家们引入了 FIRST集 这个概念简单版本的计算公式如下
Copy穷举全部可能的结果找所有可能的开头字母foreach (N -> a...) // a开头的终结符 FIRST(N) += a;foreach (N -> M...) // M开头的非终结符 FIRST(N) += FIRST(M)
一个简单版本的算法如下
Copyforeach (non-terminal N) FIRST(N) = {}while (some set is changing) foreach (规则 N -> T1 T2 ...) if (T1 == a...) FIRST(N) += a; if (T1 == M...) FIRST(N) += FIRST(M)
为什么上面说的是简单的版本呢考虑以下文法
CopyZ -> a -> X Y Z Y -> b -> X -> c -> Y
上述文法中Y和X都有可能推导出空对的上面不是写错了而是真的空。如果XY都推出空那么计算FIRST(Z)的时候就会有 Z->Z 的规则。因此一般情况下还需要计算哪些非终结符是可能推出空的就称其为NULLABLE集其算法如下
CopyNULLABLE = {}while (nullable is still changing) foreach (规则 N -> T1 T2 ...) if (T1 == 空) NULLABLE += N if (T1 T2 ...都可以推出空) NULLABLE += N
在我们知道哪些非终结符是NULLABLE时计算FIRST集的时候就需要考虑NULLABLE符号之后的字母也就是FOLLOW集。我们先来看看一般的FIRST集求法
Copyforeach (nonterminal N) FIRST(N) = {}while (some set is changing) foreach (规则 N -> T1 T2 ... Tn) foreach (Ti from T1 to Tn) if (Ti == a...) // a开头的终结符 FIRST(N) += {a} break if (Ti == M...) // M开头的非终结符 FIRST(N) += FIRST(M) if (M不是NULLABLE) break
先来看一下之前的文法
Copy0: Z -> a1: -> X Y Z2: Y -> b3: ->4: X -> c5: -> Y
现在我们知道了FIRST集的求法对于 Z->a | XYZ 这条规则我们可以算出 FIRST(Z) = {a, b, c} 也就是分析表的 Z 行 a, b, c 列都是有内容的。不过这样的算法只是让我们知道 Z 行哪些列有内容但是内容不够准确我要的是具体使用哪一条规则Z有两条规则。因此我们换一种方式我们依次计算每一条规则的FIRST集比如
0: Z -> a 的FIRST集就是 a
1: Z -> XYZ 的FIRST集就是 a b c
这下子我们就可以准确的在表中填入
a | b | c | |
Z | 0, 1 | 1 | 1 |
现在考虑另外一个问题对于第3条规则Y-> 怎么办这时候就应该看Y后面会出现什么比如由第1条规则可以知道Y后面是Z因此Y后面可以跟a b c因此对于推导出空的规则来说必须还得考虑它的FOLLOW集。
FOLLOW集求法
Copyforeach (nonterminal N) FOLLOW(N) = {}while (some set is changing) foreach (规则 N -> T1 T2 ... Tn) tmp = FOLLOW(N) foreach (Ti from Tn to T1) if (Ti == a...) // a开头的终结符 tmp = {a} if (Ti == M...) // M开头的非终结符 FOLLOW(M) += tmp // 关键步骤 if (M 不是 NULLABLE) tmp = FIRST(M) else tmp += FIRST(M)
对于FIRST集算法很简单一看就能看明白。但是FOLLOW集就比较难看懂当初我上编译原理课的时候也是很难搞懂不妨我们来模拟下算法运行。
给定一个规则 N -> T1 T2 ... Tn a
算法会遍历这条规则然后从后向前依次计算右手边。
刚开始tmp = FOLLOW(N) = {}
遇到了 a 终结符tmp = {a}
遇到了 Tn 非终结符FOLLOW(Tn) += tmp;
如果 Tn 不是NULLABLEtmp = FIRST(Tn)转第6条
如果 Tn 是NULLABLEtmp += FiRST(Tn)转第7条
----------------到此为止FOLLOW(N) = {} FOLLOW(Tn) = {a}
遇到了 Tn-1 非终结符FOLLOW(Tn-1) += tmp到此为止FOLLOW(Tn-1) = FIRST(Tn)
遇到了 Tn-1 非终结符FOLLOW(Tn-1) += tmp到此为止FOLLOW(Tn-1) = {a} + FIRST(Tn)
这个例子跑一遍就能轻松理解FOLLOW集的求法了。嘿嘿编译原理虽说是一门理论性非非非非非常强的课但是要想学好她必须实践动手动笔不能光动眼动脑。
现在任给一个文法我们都可以写出她的FIRST集、NULLABLE集、FOLLOW集了而且我们知道了应该按照一条一条的规则来算这些集合才方便准确地填表。这时候我们不妨给每一条规则再额外定义一个集合叫做 FIRST_S 集定义这个集合是方便编程。这个集合会计算每一条规则可以推出的首字母算法可以这样
Copyforeach (规则 N) FIRST_S(N) = {}foreach (规则 N -> T1 T2 ... Tn) foreach (Ti from T1 to Tn) if (Ti == a...) // FIRST_S(N) += {a} return if (Ti == M...) // FIRST_S(N) += FIRST(M) if (M 不是 NULLABLE) return FIRST_S(N) += FOLLOW(N) // 前面都没返回意味T1 T2 ... Tn整体可以推出空于是乎要加上FOLLOW集
最后总结下给出文法的运算结果
NULLABLE = {X, Y};
X | Y | Z | |
FIRST | {b, c} | {b} | {a, b, c} |
FOLLOW | {a, b, c} | {a, b, c} | {} |
0 | 1 | 2 | 3 | 4 | 5 | |
FIRST_S | {a} | {a, b, c} | {b} | {a, b, c} | {a, b, c} | {c} |
LL(1)分析表
a | b | c | |
Z | 1 | 1 | 0, 1 |
Y | 3 | 2, 3 | 3 |
X | 4, 5 | 4 | 4 |
总结一下我们现在构造了分析表帮助了我们做正确的选择也就是说原来算法中的
Copy push(next 正确的 choice); // 查表
变成了
Copy push(table[T, tokens[i]); // 查表
不过细心的读者可能会发现不对啊上面的表项中并不是一对一的比如负对角线上的状态都是两个的到时候该怎么选择呢不是还要回溯吗
没错这个确实是个问题或者说这个文法不是LL(1)文法。等等那我们说了这么多还是没能解决问题确实是的严格来说LL(1)文法不能构造出有二义性的文法的分析表也就是二义性文法的分析表通过算法算出来她的某些表项是有超过1的。那怎么办呢
可以证明有左递归或者左因子的文法都不是LL(1)文法证明方法想一想就知道了。因此一般来说这种问题只能交给程序员来解决前面在递归下降算法的时候也提到过可以通过消除左递归和左因子的方法来消除文法的二义性。那么现在我们可以来总结下LL(1)文法的缺点了
能分析的文法类型有限只能分析无二义性的LL(1)文法
往往需要文法的改写
有些朋友会说了那改写就改写我都知道了怎么消除二义性了消就完事了。不过有时候这是件很复杂的事情而且修改掉的文法不具有可读性举个例子在前面我们提到了一个加减法的文法
CopyE -> E + T -> TT -> T * F -> FF -> num
这个文法虽然有左递归不是LL(1)文法但是可读性很好。如果我们把左递归消除了她会变成这样
CopyE -> T E` E` -> + T E` -> T -> F T` T` -> * F T` -> F -> n
变丑了表达性很差。所以这种自顶向下的算法貌似到头了无路可走了。这时候新事物就来取代旧事物接下来我们一起来看看另外一种更强有力的方式自底向上的分析算法。
自底向上#
自底向上算法也被称作移进-规约算法(shitf-reduce)主要是因为算法中涉及了两个常用的核心操作shift和reduce。这种算法和上面提到的自顶向下分析算法刚好完全相反不过和自顶向下算法一样这种算法也有运行高效、广泛被自动生成器使用的优点。我们所熟知的YACC Bison都是使用的自底向上的分析算法这种自底向上的分析策略是LR系列算法的核心思想这种算法相较于LL算法具有支持语法更多、不需要修改原来语法的左递归等优点。
接下来看一下算法的思想前面提到算法有两个核心操作shift和reduce所谓reduce就是根据语法规则把右边的式子归成左边的非终结符shift则不规约继续展开右边的式子。具体来看一个例子
CopyE -> E + T -> TT -> T * F -> FF -> num
LR算法处理 3+4*5 的顺序是
其实从下往上看整个过程就是最右推导的逆过程。忘了解释这里的LR第二个R就是最右推导的意思。上面的点号左边表示已处理的字符右边表示待处理的字符。
LR(0)#
在文章开头我们提到LR算法的实现方式就是有限自动机DFA而我们要构造的分析表也就是状态转移表。我先给出一个具体的例子来展示算法运行过程然后在给出具体算法。
假设我们的文法是
Copy0: S -> A$1: A -> xxB2: B -> y
给定输入 xxy((表示EOF)可以画出DFA
对应的LR(0)分析表也就是DFA状态转移表
ACTION | GOTO | ||||
状态\符号 | x | y | $ | A | B |
1 | s2 | g6 | |||
2 | s3 | ||||
3 | s4 | g5 | |||
4 | r2 | r2 | r2 | ||
5 | r1 | r1 | r1 | ||
6 | accept |
给出算法
Copystack = []push($) // EOFpush(1) // 初始化状态while (true) token t = nextToken() state s = stack[top] if (ACTION[s, t] == "s"+i) push(t) push(i) else if (ACTION[s, t] == "r"+j) pop(第j条规则的右边全部符号) state s = stack[top] push(X) // 把第j条规则的左边非终结符入栈 push(GOTO[s, X]) // 对应的状态入栈 else error("...")
不过LR(0)算法也会有自己的问题比如一段程序的可以生成的状态会有很多多到内存装不下想想Linux这种级别的代码量而很多的状态还会导致错误定位不准确。除此之外还可能会导致一个在某一个状态里面既可以选择shift也可以选择reduce这就产生了冲突。因此产生了一种SLR的算法不过只是解决的部分问题感兴趣的小伙伴可以自行查阅资料。我们主要还是讲一些主流的算法。接下去的LR(1)算法才算是LR系列算法中最被广泛使用的算法。
LR(1)#
首先考虑一个C语言的赋值语句的一个DFA
在2号状态的时候如果我们读入 = 那么我们该 shift 还是 reduce 呢我们不妨看一看R后面可不可能出现=如果R后面出现=不满足语法规则那我们就能指定shift而不是reduce。所以我们可以计算FOLLOW(R)但不幸的是FOLLOW(R)里面包含=你可以自己观察一下。我们刚才描述的这一套做法就是SLR算法的做法但是我们也可以看到计算FOLLOW集来消除shift-reduce冲突是不够好的。而LR(1)解决了这个问题我们可以看一下
看状态2这里有一个shift-reduce冲突但是由于引入了后面的符号这个在读入=时状态2并不会reduce而是进行shift。状态2的reduce只发生在这时候读入的是$也就是末尾指定的符号。
对于 R -> L. ,$
$相当于一个前看符号只有和这个前看符号相等的输入才能进行reduce。
一般来说X -> A.B ,a
表示A现在在栈顶而剩余的输入能够匹配 Ba。当状态变成X -> AB. ,a
时a作为一个前看符号能够知道只有遇到a进行reduce这样才满足语法规则。在分析表的ACTION[s, a]这一栏会填入"reduce X-> AB"。
那为什么加上这一项就能解决问题了呢对于X -> A.B ,a
你不妨这样来理解当前栈顶是A我期待看到的是Ba。为什么期待看到的是a呢因为这是前看符号我从语法规则中提前看1个字母发现a可能出现于是乎我期待着a出现。
前看符号的计算是这样的
Copy对 X->A.BC ,a 推出 B->.D ,b 其中 b 是 FIRST_S(Ca)
语言总是有差错不妨来看看这个前看符号怎么推出来的
给出文法
CopyS` -> S$ S -> L = R S -> R L -> *R L -> id R -> L
CopyS`->S$
CopyS`->.S ,$
CopyS`->.S ,$S->.L=R ,$S->.R ,$
CopyS`->.S ,$S->.L=R ,$S->.R ,$L->.*R ,=L->.id .=R->.L ,$
CopyS`->.S ,$S->.L=R ,$S->.R ,$L->.*R ,=L->.id .=R->.L ,$L->.*R ,$L->.id ,$
最后一点二义性文法无法使用LR分析算法分析但是有几类特殊的二义性文法很容易理解因此语法自动生成器也可以识别比如优先级、结合性等。
例如
CopyE->E+E.E->E.+EE->E.*E
这个时候指定YACC对于加法进行左结合优先级低于乘法两个设定YACC就会在遇到+时首先按第一条规则reduce遇到*时按第三条规则shift。
在我的 Tiger Compiler 的语法分析模块中就有这样的设定感兴趣的小伙伴可以star一下我还在持续开发中...
Copy%left PLUS MINUS%left TIMES DIVIDE
总结#
语法分析里很多的算法都是在解决前一个算法的问题之上提出来的十分有趣不过理论学起来还是挺枯燥无味的编译原理是一门实践+理论的课必须自己动手算一算才能更好理解算法的精髓。
Reference#
编译原理, 华保健, 中国科学技术大学.
Context-free grammar, https://en.wikipedia.org/wiki/Context-free_grammar ,from Wikipedia, the free encyclopedia.
作者trav
出处https://www.cnblogs.com/trav/p/10458580.html
共同学习,写下你的评论
评论加载中...
作者其他优质文章