上回我们已经学习了语法分析第一阶段——词法分析的原理和工具,介绍了正则表达式、正则语言和DFA等工具。今次我们要开始涉及编译器前端最重要的阶段——语法分析。简单而言,这一步就要完整地分析整个编程语言的语法结构。上回说到词法分析的结果是将输入的字符串分解成一个个的单词流,也就是诸如关键字、标识符这样有特定意义的单词。一种完整的编程语言,必须在此基础上定义出各种声明、语句和表达式的语法规则。观察我们所熟悉的编程语言,其语法大都有某种递归的性质。例如四则运算与括号的表达式,其每个运算符的两边,都可以是任意的表达式。比如1+a是表达式,(1+a)*(2 – c)也是表达式,((a+b) + c) * (d – e)也是表达式。再比如if语句,其if的块和else的块中还可以再嵌套if语句。我们在词法分析中引入的正则表达式和正则语言无法描述这种结构,如果用DFA来解释,DFA只有有限个状态,它没有办法追溯这种无限递归。所以,编程语言的表达式,并不是正则语言。我们要引入一种表现能力更强的语言——上下文无关语言。
要介绍上下文无关语言,我们先来了解一下定义上下文无关文法的工具——产生式的写法。我们还是使用编程语言的表达式作为例子,但这次我们假设表达式只有三种——单个表示变量名标识符、括号括起来的表达式和两个表达式相加。比如a是一个变量表达式,a+b是两个变量表达式相加的表达式,(a+b)是一个括号表达式。我们用符号E来表示一个表达式,那么这三种表达式分别可以定义为:
E → id E → E + E E → ( E ) |
这种形式的定义就叫做产生式。出现在→左侧符号E称作非终结符(nonterminal symbol),代表可以继续产生新符号的“文法变量”。 符号→表示非终结符可以“产生”的东西。而上述产生式中的蓝色id、+、(等符号,是具有固定意义的单词,它们不再会产生新的东西,称作终结符(terminal symbol)。注意,非终结符可以出现在产生式的右侧,这就是具有递归性质文法的来源。产生式经过一系列的推导,就能够生成各种完全由终结符组成的句子。比如,我们演示一下表达式(a + b) + c的推导过程:
E => E + E => (E) + E => (E + E) + E => (a + E) + E => (a + b) + E => (a + b) + c
推导过程中的=>代表将当前句型中的一个非终结符替换成产生式右侧的内容。以上推导过程中,我们每次都将句型中最左边一个非终结符展开,所以这种推导称为最左推导。当然也有最右推导,不同之处就算是每次将句型中最右边的非终结符展开:
E => E + E => E + c => (E) + c => (E + E) + c => (E + b) + c => (a + b) + c
可见,同一个结果可以具有多种不同的推导过程。使用最左推导时,句型的左侧逐渐变得只有终结符;而最右推导正好相反,推导过程中句型的右侧逐渐变得只有终结符,最终结果都是整个句子变为终结符。所有符合文法定义的句子,都可以用文法的产生式推导出来。
我们语法分析的目的是解析输入的单词流(a + b) + c,得到它的语法分析树。先来看看语法分析树是什么样的。还是以(a + b) + c为例,语法分析树是这样的:
语法分析树的每一个节点都是一个非终结符或者终结符,其中终结符都是树的叶子结点(没有子节点),而非终结符都是有子节点的。一旦我们得到了语法分析树,就可以很容易地进行后续的语义分析,比如这个表达式的语义是“先将a和b代表的变量相加,再把所得的结果与c代表的变量相加”。那么语法分析树是怎么得到的呢,其实刚才的产生式推导过程,就可以顺便建立语法分析树,只要在展开非终结符的同时,在语法分析树中相应的节点下加入非终结展开的结果即可生成。下面我们用动画演示上述产生式通过最左推导和最右推导产生(a + b) + c语法分析树的过程:
最左推导 | 最右推导 |
我们可以看到最左推导和最右推导的语法分析树是一样的,这证明用相同的文法解析同样的输入也至少存在两种不同的分析方法。后续篇章介绍的递归下降法就是一种最左推导的分析方法,而另一类非常流行的LR分析器则是基于最右推导的分析方法。目前流行的编译器开发方式是在语法分析阶段构造一棵真正的语法分析树,然后再通过遍历语法树的方法进行后续的分析,所以最左推导和最右推导的过程对我们来讲区别不大。
我们刚才举的例子中,表达式(a + b) + c只能有一种语法分析树。但另外一些语法分析的输入,可能存在多种语法分析树, 这称为歧义。刚才的文法其实就是有歧义的(在哪里?请大家思考一下),但为了更清楚地表达歧义的危害,我们再举一个新的例子,它在前面例子中增加了乘法:
E → id E → E + E E → E * E E → ( E ) |
如果用上述产生式推导出表达式a * b + c,就有两种可能的最左推导:
最左推导1:E => E + E => E * E + E => a * E + E => a * b + E => a * b + c
最左推导2:E => E * E => a * E => a * E + E => a * b + E => a * b + c
这两种推导的语法树是不一样的:
推导1 | 推导2 |
我们刚才讨论了,语法分析树将用于下一步的语义分析。而在语义分析中,上述两个语法树的不同主要体现在运算符的优先级上。如果按照推导1的语法树,应该先将a和b相乘,再加上c;而如果按照推导2的语法树,则应该先把b和c相加,再和a相乘。很明显,这两种语义的计算结果是可以不一样的。我们不想编程语言中的同一种表达式有两种语义,所以有歧义的文法是不适合用在语法分析的。实践中应该使用没有歧义的文法来确保同一段程序仅存在唯一一种语法分析树。比如我们可以修改一下上述文法的产生式,让运算符具有左结合的特性,并让乘法一开始就有高于加法的优先级:
F → id F → ( E ) T → T * F T → F E → E + T E → T |
修改文法之后,*号的两侧,不允许直接出现带+号的表达式,而只能出现带括号的表达式和变量名;同时,连续的加法或乘法必须从左侧开始运算。这就限制了推导可能进行的方式。在新文法下表达式a * b + c就只存在一种语法分析树了:
我们最后用在miniSharp的文法就和这一个非常类似。在实践当中,我们通常需要仔细观察和思考所用的文法是否具有歧义。如果有一些文法拿不定主意,我建议大家去参考C# spec,里面对C#的文法进行极其详细的定义,我相信大家看过Spec之后会更加了解一门现代的编程语言的文法。我也将在后续篇章中介绍一些常见语法结构的设计方法。
在本篇的最后,我想再多介绍一点点上下文无关语言。有些同学可能从刚才开始就想问为何这种语言和文法叫做“上下文无关”呢?其实这里的“上下文无关”是指文法中的产生式都可以无条件展开为箭头右侧的内容。另外存在一种上下文相关文法,它的产生式都需要在一定条件下才能展开。上下文相关语言要比上下文无关文法复杂得多,而其没有一种通用的方法可以有效地解析上下文相关语言,因此它也不会用在编程语言的设计当中。同学们也许已经意识到,即使是上下文无关文法和语言,也要比正则表达式和正则语言复杂得多。在此我没有办法详细地描述上下文无关语言的性质,但是我可以给感兴趣的同学稍微科普一下。就像正则表达式存在一种等价的计算模型——有穷自动机——可以用来解析正则语言一样,上下文无关文法也存在一种等价的计算模型——下推自动机(Put-Down Automation, PDA)。下推自动机除了一组有限的状态和状态转换以外,还带有一个无限容量的栈。和有穷自动机不同,下推自动机的状态并不仅根据输入字符和当前状态来进行转移,还要根据栈顶的字符;而且下推自动机还必须决定何时向栈中压入或弹出字符。和有穷自动机类似,下推自动机也存在非确定性下推自动机(NPDA)和确定性下推自动机(DPDA)两种。这两种下推自动机是不等价的。其中非确定性下推自动机对应于整个上下文无关语言,而确定性下推自动机则对应于上下文无关语言的一个真子集。NPDA所具有的“猜测”能力要比NFA强大得多,以至于我们无法很容易地用计算机来模拟。我们只能够模拟DPDA来进行解析。所幸的是,几乎所有编程语言的文法,都是能用一个DPDA所接受的。我们在接下来的篇章中引入的语法分析机制,有些甚至还达不到DPDA的能力,也就是说我们只能处理上下文无关文法中的一小部分。但即使是这一小部分,也足够将C#这样的语法描述出来了。
下一篇中我们将介绍递归下降语法分析器的实现方法。希望大家继续关注我的VBF项目:https://github.com/Ninputer/VBF 和我的微博:http://weibo.com/ninputer 多谢大家支持!