语法分析的那些算法

前言

在编译原理中,语法分析可以说是编译器前端的核心。语法分析的输出,抽象语法树,更是一座建立在编译器前端和后端之间非非非非非常重要的桥梁。

我们知道,编译器可以分为前后端,而前后端又可以分为多个模块,每个模块环环相扣,体现出一种过程式的编程思想。每一个模块的输入仅仅是上一个模块的输出,而语法分析的产出物,抽象语法树,是连接前后端的唯一桥梁,所有编译器后端的模块都必须依靠抽象语法树,抽象语法树必须提供足够的信息以供后端许多模块使用,所以设计好一个抽象语法树是十分重要的。一般来说,一棵抽象语法树需要提供每条语法在源文件中的行号列号,以及文件号等诸多信息,当然了,抽象语法树的设计很具有特殊性,没有一套国际上通用的模式来套,而是需要设计者根据需求定制。

语法分析器以词法分析器的产出(TOKENS)作为输入,在语法规则限制下,使用不同的分析算法,产出满足语法的抽象语法树。而产出的语法树还需要经过语义分析器来进行类型检查,这才算完成了编译器前端的工作。产出的抽象语法树之所以需要经过一次严格的类型检查,是因为语法分析的过程使用的是一种与上下文无关的语法规则,即上下文无关文法来进行分析,接下来我们给出上下文无关文法的定义。

上下文无关文法

定义:CFG(N, T, S, R)

N - Nonterminal - 非终结符

T - Terminal - 终结符

S - Start Character - 开始符号

R - Syntax Rule - 语法规则

详细定义请参见:https://en.wikipedia.org/wiki/Context-free_grammar

简单的说,我们在制作编译器的过程中,会遇到的上下文无关文法是长这样的:

arith_exp: exp PLUS exp
         | exp MINUS exp
         | exp TIMES exp
         | exp DIVIDE exp

好了,基础的部分到这为止,接下来才是本文的重要内容。

前面提到,语法分析器有不同的算法可以进行语法分析,我们就来谈一谈这些有印象但是不太了解的算法,以及为什么会有这么多不同的算法。

一般来说,语法分析的算法分为两种,自顶向下的算法和自底向上的算法,而这两类算法又有很多不同的实现方式,我们只谈最主流的方式:

自顶向下:

  1. 递归下降算法

  2. LL(1)

自底向上:

  1. LR(0)

  2. LR(1)

这些算法按具体的实现方式,又可以分为:分析栈方式、分析表方式:

parser1

其中自顶向下的方式就是分析栈的方式,自底向上的方式就是分析表的方式。所谓分析栈的方式,其实就是算法的过程类似于树的后序遍历,所谓分析表的方式,就是我之前一篇文章正则表达式匹配可以更快更简单 (but is slow in Java, Perl, PHP, Python, Ruby, ...)里提到的有限自动机DFA的方式。

自顶向下

自顶向下的算法其基本思想就是枚举、穷举,使用树的后序遍历,穷举文法可以产出的所有句子,然后跟输入做比较,能够匹配成功,说明语法正确。当然了,我说的穷举所有结果不是真的把所有结果都计算出来,其中会有一些优化的,比如说后序遍历的过程中发现产生的第一个字母和输入的第一个字母不匹配,会直接回溯而不是还傻傻的算下去。

文字化描述:

给定文法CFG和待匹配句子s,回答s能否从CFG推导出来?

算法:从G的开始符号出发,随意推出某个句子t,比较s和t:

  1. 若 t == s ,则回答 “是”
  2. 若 t != s ,则回答 “否”

代码描述:

tokens[];  // 所有token
i = 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:

S -> N V N
N -> s
   -> t
   -> g
   -> w
V -> e
   -> d

待匹配句子 gdw

parser2

这个算法有很多问题,首当其冲的就是回溯开销太大,就像上图,当发现匹配错误的时候,分析栈需要回溯到原来的样子,然后再次遍历,这是不能忍的行为。

之前我说了,在语法分析这块有很多的算法,他们的出现都是为了解决前一个算法遇到的问题,接下来我们看看递归下降算法解决了哪些问题。

递归下降算法

首先介绍下,在语法分析这一块,程序员有两种实现方式,一种是纯手工编码来实现算法,然后制作语法分析器,第二种方式就是利用语法生成器,比如每一台 Linux 上都有的 Yacc Bison 等,这些自动生成器会根据一些语法规则来自动生成代码完成语法分析,真是爽爆啦。

不过主流的编译器,比如 GCC LLVM ,其实现方式就是纯手工编码的方式,而在纯手工编码的方式中,最最常用的就是递归下降算法,这是个很有名的算法哦。

递归下降算法具有这些优点:

  1. 分析高效(线性时间)
  2. 容易实现(方便手工编码)
  3. 错误定位和诊断信息准确(准确定位语法错误)

说了这么多优点,来看看算法长啥样。递归下降算法的基本思想是建立在前面自顶向下算法之上的,前面的自顶向下算法的最大弊端就是很多的回溯,而如果这时候问你,你有什么解决方案?一个比较好的解决方案就是预测未来。

看看上面算法的这一句:

		push(next possible choice); // 请注意 possible

如果我能预测未来,我不是选择 possible ,而是选择 right ,比如我提前看一个符号,我发现是 g ,那么我就直接选择 push g。上面的算法就可以这样改:

parse_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),算法经常长这个样子

parse_X()
	token = nextToken()
	switch(token)
	case 1: parse_E(); eat('+'); parse_T(); // ...
	case 2: // ...
	...
	default: error("expect ..., but given ...");

我们说很多主流编译器都是使用的递归下降算法来进行语法分析,但是递归下降算法就真的这么好吗?就无敌了吗?

考虑以下文法:

E -> E + T
   -> T
T -> T * F
   -> F
F -> num

现在我的待匹配句子是 3+4*5 ,这时候该怎么写一个递归算法?

你可能会这样写:

parse_E()
	token = tokens[i++]
	if (token == num)
		? // 是调用 E + T 还是调用 T
	else
		error("expect ..., but given ...")

这一下子就把递归下降算法给难住了,因为调用 E + T 和调用 T 都可以的,这时候唯一的解决办法好像就是都试一遍,看看谁满足。不过,等等,这怎么好像回到回溯的办法了?其实这类问题还真是一个大问题,不过对于递归算法,这是一种可以避免的问题,简单点说,这不是硬伤,而是可以通过聪明的程序员对语法的理解和改造足以解决的。比如对于这个文法,我的代码可以这样写:

parse_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)算法有以下优点:

  1. 分析高效(线性时间)
  2. 错误定位和诊断信息准确

我们说LL(1)是一个表驱动的算法,那怎么个表驱动法呢?我们先回顾一下自顶向下的算法:

tokens[];  // 所有token
i = 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是随机选择,选择完所有的情况,因此导致回溯,但是如果我每次都选择正确的情况,那就不需要回溯啦。这是我梦想的代码:

tokens[];  // 所有token
i = 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); // 查表

没错,所谓的表驱动就是给你提供一张表,通过查表你就能决定下一步往哪走。而表驱动算法的主要工作就是把这张表给你算出来。

parser3

那么怎么构造一个分析表呢?

其实很简单,我通过肉眼就能看出来,这个表不外乎就是所有的非终结符作为行,所有的终结符作为列,然后为每一条语法规则标上行号,根据语法规则填表就完事了。比如我要填N这一行,通过观察,我发现N可以推出 s t g w,也就是前看符号可能的情况,所以这一行可以填上1 2 3 4,其他行类似。

不过这样不是很规范,于是乎,科学家们引入了 FIRST集 这个概念,简单版本的计算公式如下:

穷举全部可能的结果,找所有可能的开头字母
foreach (N -> a...) // a开头的终结符
	FIRST(N) += a;

foreach (N -> M...) // M开头的非终结符
	FIRST(N) += FIRST(M)

一个简单版本的算法如下:

foreach (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)

为什么上面说的是简单的版本呢?考虑以下文法:

Z -> a
   -> X Y Z
Y -> b
   ->
X -> c
   -> Y

上述文法中,Y和X都有可能推导出空,对的,上面不是写错了,而是真的空。如果XY都推出空,那么计算FIRST(Z)的时候,就会有 Z->Z 的规则。因此,一般情况下,还需要计算哪些非终结符是可能推出空的,就称其为NULLABLE集,其算法如下:

NULLABLE = {}
while (nullable is still changing)
	foreach (规则 N -> T1 T2 ...)
		if (T1 == 空)
			NULLABLE += N
		if (T1 T2 ...都可以推出空)
			NULLABLE += N

在我们知道哪些非终结符是NULLABLE时,计算FIRST集的时候,就需要考虑NULLABLE符号之后的字母,也就是FOLLOW集。我们先来看看一般的FIRST集求法:

foreach (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

先来看一下之前的文法:

0: Z -> a
1:    -> X Y Z
2: Y -> b
3:    ->
4: X -> c
5:    -> 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

这下子,我们就可以准确的在表中填入:

abc
Z0, 111

现在考虑另外一个问题,对于第3条规则,Y-> ,怎么办?这时候就应该看Y后面会出现什么,比如由第1条规则可以知道,Y后面是Z,因此Y后面可以跟a b c,因此对于推导出空的规则来说,必须还得考虑它的FOLLOW集。

FOLLOW集求法:

foreach (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

算法会遍历这条规则,然后从后向前依次计算右手边。

  1. 刚开始,tmp = FOLLOW(N) = {}
  2. 遇到了 a ,终结符,tmp =
  3. 遇到了 Tn ,非终结符,FOLLOW(Tn) += tmp;
  4. 如果 Tn 不是NULLABLE,tmp = FIRST(Tn),转第6条
  5. 如果 Tn 是NULLABLE,tmp += FiRST(Tn),转第7条
  6. ----------------到此为止,FOLLOW(N) = {} ,FOLLOW(Tn) =
  7. 遇到了 Tn-1 ,非终结符,FOLLOW(Tn-1) += tmp,到此为止,FOLLOW(Tn-1) = FIRST(Tn)
  8. 遇到了 Tn-1 ,非终结符,FOLLOW(Tn-1) += tmp,到此为止,FOLLOW(Tn-1) = {a} + FIRST(Tn)

这个例子跑一遍,就能轻松理解FOLLOW集的求法了。嘿嘿,编译原理虽说是一门理论性非非非非非常强的课,但是要想学好她,必须实践,动手动笔不能光动眼动脑。

现在,任给一个文法,我们都可以写出她的FIRST集、NULLABLE集、FOLLOW集了,而且我们知道了应该按照一条一条的规则来算这些集合,才方便准确地填表。这时候,我们不妨给每一条规则再额外定义一个集合,叫做 FIRST_S 集,定义这个集合是方便编程。这个集合会计算每一条规则可以推出的首字母,算法可以这样:

foreach (规则 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};

XYZ
FIRST{b, c}{b}{a, b, c}
FOLLOW{a, b, c}{a, b, c}{}
012345
FIRST_S{a}{a, b, c}{b}{a, b, c}{a, b, c}{c}

LL(1)分析表:

abc
Z110, 1
Y32, 33
X4, 544

总结一下,我们现在构造了分析表,帮助了我们做正确的选择,也就是说,原来算法中的

		push(next 正确的 choice); // 查表

变成了

		push(table[T, tokens[i]); // 查表

不过,细心的读者可能会发现,不对啊,上面的表项中,并不是一对一的,比如负对角线上的状态都是两个的,到时候该怎么选择呢???不是还要回溯吗???

没错,这个确实是个问题,或者说,这个文法不是LL(1)文法。等等,那我们说了这么多,还是没能解决问题?确实是的,严格来说,LL(1)文法不能构造出有二义性的文法的分析表,也就是二义性文法的分析表通过算法算出来,她的某些表项是有超过1的。那怎么办呢?

可以证明,有左递归或者左因子的文法都不是LL(1)文法,证明方法想一想就知道了。因此一般来说,这种问题只能交给程序员来解决,前面在递归下降算法的时候也提到过,可以通过消除左递归和左因子的方法来消除文法的二义性。那么现在我们可以来总结下LL(1)文法的缺点了:

  1. 能分析的文法类型有限(只能分析无二义性的LL(1)文法)
  2. 往往需要文法的改写

有些朋友会说了,那改写就改写,我都知道了怎么消除二义性了,消就完事了。不过有时候这是件很复杂的事情,而且修改掉的文法不具有可读性,举个例子,在前面我们提到了一个加减法的文法:

E -> E + T
   -> T
T -> T * F
   -> F
F -> num

这个文法虽然有左递归,不是LL(1)文法,但是可读性很好。如果我们把左递归消除了,她会变成这样:

E -> 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则不规约,继续展开右边的式子。具体来看一个例子:

E -> E + T
   -> T
T -> T * F
   -> F
F -> num

LR算法处理 3+4*5 的顺序是:

parser4

其实从下往上看,整个过程就是最右推导的逆过程。忘了解释,这里的LR第二个R就是最右推导的意思。上面的点号左边表示已处理的字符,右边表示待处理的字符。

LR(0)

在文章开头我们提到,LR算法的实现方式就是有限自动机DFA,而我们要构造的分析表也就是状态转移表。我先给出一个具体的例子来展示算法运行过程,然后在给出具体算法。

假设我们的文法是:

0: S -> A$
1: A -> xxB
2: B -> y

给定输入 xxy$ ($表示EOF),可以画出DFA:
parser5

对应的LR(0)分析表,也就是DFA状态转移表:

ACTIONGOTO
状态\符号xy$AB
1s2g6
2s3
3s4g5
4r2r2r2
5r1r1r1
6accept

给出算法:

stack = []
push($) // EOF
push(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:

parser6

在2号状态的时候,如果我们读入 = ,那么我们该 shift 还是 reduce 呢?我们不妨看一看R后面可不可能出现=,如果R后面出现=不满足语法规则,那我们就能指定shift,而不是reduce。所以我们可以计算FOLLOW(R),但不幸的是,FOLLOW(R)里面包含=(你可以自己观察一下)。我们刚才描述的这一套做法就是SLR算法的做法,但是我们也可以看到,计算FOLLOW集来消除shift-reduce冲突是不够好的。而LR(1)解决了这个问题,我们可以看一下:

parsered

看状态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出现。

前看符号的计算是这样的:

对 X->A.BC      ,a
推出 B->.D      ,b
其中 b 是 FIRST_S(Ca)

语言总是有差错,不妨来看看这个前看符号怎么推出来的:

给出文法:

S` -> S$
S -> L = R
S -> R
L -> *R
L -> id
R -> L
S`->S$

👇

S`->.S     ,$

👇

S`->.S     ,$
S->.L=R   ,$
S->.R      ,$

👇

S`->.S     ,$
S->.L=R   ,$
S->.R      ,$
L->.*R    ,=
L->.id     .=
R->.L     ,$

👇

S`->.S     ,$
S->.L=R   ,$
S->.R      ,$
L->.*R    ,=
L->.id     .=
R->.L     ,$
L->.*R    ,$
L->.id     ,$

最后一点,二义性文法无法使用LR分析算法分析,但是有几类特殊的二义性文法很容易理解,因此语法自动生成器也可以识别,比如优先级、结合性等。

例如:

E->E+E.
E->E.+E
E->E.*E

这个时候,指定YACC对于加法进行左结合,优先级低于乘法两个设定,YACC就会在遇到+时,首先按第一条规则reduce,遇到*时,按第三条规则shift。

在我的 Tiger Compiler 的语法分析模块中就有这样的设定,感兴趣的小伙伴可以star一下,我还在持续开发中...

%left PLUS MINUS
%left TIMES DIVIDE

总结

语法分析里很多的算法都是在解决前一个算法的问题之上提出来的,十分有趣,不过理论学起来还是挺枯燥无味的,编译原理是一门实践+理论的课,必须自己动手算一算才能更好理解算法的精髓。

Reference

编译原理, 华保健, 中国科学技术大学.

Context-free grammar, https://en.wikipedia.org/wiki/Context-free_grammar ,from Wikipedia, the free encyclopedia.

posted @ 2019-03-02 17:17  trav  阅读(4290)  评论(0编辑  收藏  举报