感觉语法分析器在编译器前端是一个较为庞大的东西,因此打算分两篇博客来描述,第一篇着重描述思想,第二篇具体论述实现。
1、语法分析器要做什么
在编写任何一个东西的的时候,都要先弄明白这个玩意儿是做什么的,接受什么输入,产生什么输出。
一个语法分析器要接受词法分析器所产生的词素作为输入,产生一个抽象语法树给中间代码生成器,然后再由中间代码生成器生成中间代码并递交给编译器后端。当然在某些理解中可以把抽象语法树就当做是一种中间代码的表示形式,直接递交给后端。不管怎么说,总之就是语法分析器是一个生成抽象语法树的东西。
值得注意的是,语法分析器不仅要生成抽象语法树,而且还要在这个生成过程中找出各种语法错误并生成和维护符号表。
2、符号表
什么是符号表?符号表有什么用?
所谓符号表就是一个记录各种标识符(也就是终结符号id,词素id)及其属性的表,比如记录一个int变量x的类型为int,它的作用域,记录一个函数名,记录其函数类型,参数列表等。
符号表有作用域,比如一段简单的代码:
因此一个符号表的构造一定是一个树状结构,我们在编译器中用以下结构来描述一个符号表:
代码很简单以至于注释都懒得写了。
通过fatherSymTable来记录此符号表的父表,用于不断的向上回溯查找符号(getSym)使用。
blockid算是给此表一个id,用于打印调试信息时使用。
addSym在此表增加符号。除此之外还有个addSymTables来加入子表。
另外此类还重载了toString()方法,用于debug信息,限于篇幅这个方法没贴到博客里,可在我上传的资源里拿到完整的源文件。
也许在之后的分析描述写代码的过程中我会发现需要给这个类添加新的函数,到那时再对此类进行补充。
接下来看看简单的Symbol类,也就是表示一个符号的类:
分为3个域,id,也就是标识符,type,枚举值已列出,value,根据不用的枚举值定义了不同的value,之后若用到了再贴代码吧。
总共分为5类符号:类名、成员变量、局部变量、函数名和构造函数名。
当然若之后根据需要,或许会使用新的符号也可以灵活的添加。
3、语法树的表示
一棵语法树不能使用普通的树结构因为每个不同的节点的行为、值太多且不同。语法树中的节点为非终结符号或者终结符号,对于其中的id,我们就让它指向符号表中的符号即可,对于非终结符号,每个非终结符号我们都建立一个新的类来描述其行为,对于非id的终结符号,其信息要么不记录(比如无意义的分好括号等),要么简单记录其类型(比如各种运算符)。
所以这种情况下每一个节点的建立都比较灵活,下面举两个例子:
对于产生式:ops --> bitop | logiop | artmop | cprop
我们建立如下的类来描述ops:
这是一个终结符,所以只有一个域来记录运算符的类型。
接下来讲着重分析语法树的展开过程。
一、语法制导翻译、LL与LL(X)语法、左递归等其它
为什么要写一个语法分析器?语法分析器的作用不仅仅是来检查词素列表中的序列是否是一个我们语言的语句,更重要的是只有借助语法分析器得到的抽象语法树,才能够生成中间代码或者具体的目标代码,这个过程叫做语法制导翻译(syntax-directed translation)。在紫龙书(编译原理第二版)的封面上,一个拿盾的骑士正在和一个喷火龙决斗,其中龙的身上写的是Complexity of Complier Design,而骑士的盾上写的则是Syntax Directed Translation,因此把语法制导翻译当作是编译器前端的核心也不为过。
展开语法树的过程实质上也就是将词素不断地对应到我们语言定义的递推式的过程,换个说法其实也就是不断地展开语言的递推式,使之符合已有词素的过程。这个展开的过程从方法上来讲可分为两种:LL和LR。其中第一个字母代表从左到右读词素序列,第二个字母L代表尝试最先展开最左边的非终结符号,R代表尝试从右边开始将词素归约为非终结符好。换言之,LL是一种自顶向下的展开方法,LR是一种自底向上的归约方法,本文采用的技术为LL,所以以下也以讨论LL为主。
为了使编译器能高效迅速,一个良好的语法设计必须是一个LL(1)语法,什么是LL(1)语法呢?举个例子,当我们面对如下推导式的时候:
ids-> id| ----------1
id.ids| -----------2
ids[expr] | -----------3
this
此时我们读到了一个词素id,是展开成1、2、3中的哪种呢?当然目前我们无法判断,因此需要多读入下一个词素才能进行判断。如果读到的是[,则展开成3。如果读到了.则展开成2,否则展开成1。但问题是有些情况下,多读入一个词素或许还不能进行判断,当一个语言的语法中,只要多读入X个词素就能唯一的确定推导式,则称其为LL(X)文法。很遗憾,我们的语法不是LL(1)语法,虽然有很多推导式的处理技巧可以将一个非LL(1)的语法处理成LL(1)的语法,但这样会失去语法的直观性。考虑再三我在“不是很合理但易于理解的语法” 和 “合理高效的不直观的语法” 之间选择了前者。因此既然我们的语法并非LL(1)的,因此在语法分析的过程中,我们只是不断的去尝试展开,如果不成功,则回溯。虽然这是比较低效的,但文法中的大多数推导式并不复杂,所以处理的时间完全可以接受。
考虑如下 推导式:
expr --> (expr) ------------1
ids| ------------2
number| -----------3
literal| ------------4
func-call| ------------5
expr ops expr|
这个推导式不满足LL(1),假设当前读到了一个id,目前可供选择的有2、4、5,然后又读入了一个“。”,目前可供选择的还是2、4、5,又读入了一个id,可供选择的还是2、4、5,然后读入了一个“(” ,这时候才能确定唯一的展开式func-call。但这个表达式除了不满足LL(1)之外还有其它的问题:左递归。假设expr上来就尝试去展开成5的形式,因为是递归展开的过程,5中最左边的expr又会尝试展开成5的形式,然后这个过程就不断递归下去最终导致stack overflow。虽然有很多方法和技巧可以改变推导式的形式来消除左递归,但是依然本着易于理解的原则,我们在语法分析中通过使用朴素的笨办法来避免这种情况的发生。所谓的笨办法就是:
(1)按优先级先展开1234,然后都失败再展开成5。
(2)设置最大展开深度为200,超过了直接报错。
虽然很笨很低效,但勉强够用了。
二、语法分析器结构
语法分析器在实现上分以下几个部分,第一部分为SyntaxTreeGenerator,负责读入词素,和词法分析器以及后端程序进行交流,算是语法分析器的对外接口。其次使用GlobalVars来存储各种全局数据,记录分析过程中的各种信息。最后就是各种节点,每个节点在分析的过程中若需要其它信息则通过GlobalVars来解耦。接下来通过几个例子来具体说明这些节点是如何展开语法分析的:
(1)id
(2)vardeclare
来个稍微复杂点的,局部变量的定义
(3)memberfundeclare
来个再复杂点的,成员函数定义:
通过以上的分析,我们可以总结出每一个节点的构造规则:
1、尝试将此节点按一定的顺序展开
2、其每一个部分当作该节点的成员变量
3、在展开的时候和符号表进行适当的交互
按照类似的思路,我们当我们完成所有节点后,编译器的前端也已经差不多了,下图是上篇博文中日志里的示例程序得到的语法树,可以看到即便是一个简单的示例程序,其语法树也相当复杂。