代码改变世界

编译原理(基础篇)

2014-10-10 10:05  郭志通  阅读(5072)  评论(0编辑  收藏  举报

写在前面

编译器也是一个程序:输入字符串,输出目标代码(有没有人会想编译器是用什么编译的)。从总体上看有如下过程:

  1. 词法分析:读入源码字节,将其组成有意义的TOKEN流。
  2. 语法分析:根据TOKEN流构建树形的中间表示。
  3. 语义分析:检查是否和语言的定义一直,并且会收集信息放入语法树中以便在随后的代码生成过程中使用。
  4. 中间代码生成:根据语法树生成低级的中间表示。
  5. 代码优化:优化中间代码。

在做项目的过程中,用的最多的也就是对配置文件(讲的好听一点就是DSL)进行分析,然后执行自定义的操作,那么下面就来看这部分的基础知识。

词法分析

在词法分析中涉及到的概念:

  1. DFA:有穷、确定状态机。
  2. NFA:有穷、不确定状态机。
  3. 词法单元:表示某种词法单位的抽象符号(比如关键字、字符串),由一个词法单元名称和一个可选的属性值组成。
  4. 模式:词法单元的词素可能具有的形式。
  5. 词素:源代码中的一个字符串序列。
  6. 字母表:有限的符号集合。
  7. 串:由字符表中的字符组成的有穷序列。
  8. 语言:字符表上一个任意可数的串集合,其上最基本的操作有并、连接、闭包。
  9. 接受/终结状态:找到一个词素。
  10. 初始状态:在读入任何输入符号之前,状态转换图总是位于他的开始状态。
  11. 可行前缀:
  12. 有效项集:
  13. LR(0)状态机:
  14. SLR:

这里词法单元、词素的理解可能比较麻烦一些:

下面接着来看词法分析的重点:状态机的匹配过程以及各种表示之间的转换。

NFA和DFA是什么?

在状态图上转换的时候,简单来说就是根据当前状态、当前字符来决定下一个状态是什么,比如要匹配(a|b)*ab的字符串对应的状态图如下:

这是一个NFA,因为在状态1处当输入'a'时无法决定下一个状态到底应该是1还是2。一个状态图对应一张转换表,比如上图对应的转换表如下:

DFA的区别在于在某个状态输入特定字符时,下一个状态是确定的。换个角度讲:DFA是NFA的一个特例(再换个角度:DFA是一个结果,NFA是一个过程)。下面来看DFA的匹配过程:

输入一个以eof结尾的字符串x,DFA的开始状态位S0,接受状态集为F,转换函数为move。如果DFA接受返回"yes",否则返回"no"。

那么代码大概的样子是:

s = s0;
c = nextChar();
while(c != eof){
	s = move(s, c);
	c = nextChar();
}
return s in F ? "yes" : "no";

在DFA上面进行匹配显然要比在NFA上匹配简单、迅速很多。

从NFA到DFA

子集构造法是一种比较直观、简单的转换方法:让构造得到的DFA的每个状态对应于NFA的一个状态的集合。理论上这样得到的DFA中的状态数将是NFA状态数的指数级别,但是在实际的语言中两者的区别并不是很大。涉及到下面几种概念:

  1. ε-closure(s):从NFA中状态s开始通过ε得到的状态的集合。
  2. ε-closure(T):从T中的某个状态s开始通过ε得到的状态的集合。
  3. move(T, a):从T中某个状态s开始通过a得到的状态集合。

在最开始Dstatus中只有ε-closure(s0)一种状态,并未未加标记,接下来的过程如下:

while(在Dstatus中有一个未标记的状态T){
	给T加上标记;
	for(每个输入符号a) {
		U = ε-closure(move(T, a));
		if(U 不在Dstates中)
			将U加入到Dstatus中// 不加标记
		Dtran[T, a] = U;// 转换表
	}
}

从直观上来看子集构造法其实相当于在根据字符将目标的状态进行合并:

当然这么粗糙的描述忽略掉了不少细节,其中closure集合的构造和NFA到DFA的转换都是非常暴力的搜索过程,怪不得大学老师不管碰到什么问题都会说:用搜索啊。。。最后来看一个从NFA转化为DFA的时候状态数变为指数倍的例子,对于(a|b)*a(a|b)n-1对应的语言族所描述的是倒数第n个字母为a,其余的字母为a或b,这样很容易构造出n+1个状态的NFA,如下:

在很多时候在加入数目的条件之后DFA的数据总会暴增,这里的很容易可以看到DFA的状态将大于2^N,比较简单就不再证明了。 

从正则到NFA

我们可以将正则表达是看做是最基本的原子归并而成,那么归并的过程中的操作包括:

  1. r=s|t:通过ε实现。
  2. r=st:将s的接受状态和t的初始状态进行合并。
  3. r=s*:将s的接受状态和初始状态相连,从而达到循环的效果。

直观地用图来表示NFA的归并过程如下:

通过这种方式将正则表达式(a|b)*a转换得到的NFA状态图如下:

后面可以看一下正则表达式匹配的原理是不是这样的。

向前看运算符

实际上并不总是根据当前状态和下一步输入的字符就能知道下一个状态是多少,需要知道更多的字符(比如:当关键字IF也可以作为变量的时候):

在这里当IF后面有对应的字符的时候,才说明这是一个终结状态。此时的难点在于:到达终结状态5时对应的词素是啥? 在IF之后的其实都是向前看的部分,那么在这里插入向前看操作符:

从start到空的路径是x、从空到5的路径为y,那么在找到所有的路径中找到最长的x作为词素。如果在同一个NFA中有多个向前看这样解决起来就会非常非常困难。

从正则到DFA

同样是从正则表达式的AST入手,不过此时需要一些预处理,加工四个集合如下:

  1. nullable(n):节点n的子表达式中包含空字符串。
  2. firstpos(n):节点n为根的子表达式中某个串的第一个符号的位置的集合。
  3. lastpos(n):节点n为根的子表达式中某个串的末一个符号的位置的集合。
  4. followpos(p):如果L((r)#)中的某个串x=a1..an,使得我们在解释为什么x属于L((r)#)时,可以将x中的某个ai和AST中的位置p匹配,且位置ai+1和位置q匹配,那么q在该集合中。

对于下图中的(a|b)*a的cat节点来说:

其中nullable(n)=false因为所有的字符串都是以a结尾,没有空串)。对于ab第一个字符位置为1,对于ba第一个字符位置为2,对于a第一个字符位置为3,那么firstpos(n)={1,2,3}。所有串肯定是在位置3处结束的,那么lastpost(n)={3}。followpos是相对比较麻烦的,通过跟上面类似的方法可以得到followpos(1)={1,2,3},具体的计算方法如下:

  1. 如果n是一个cat节点,其左右子节点分别为c1、c2,那么对于lastpos(c1)中的每个位置i,firstpos(c2)中的所有位置都在followpos(i)中。
  2. 如果n是一个star节点,并且i是lastpos(n)中的一个位置,那么firstpos(n)中所有的位置都在followpos(i)中。

对于其他的三个集合可以通过父节点、子节点之间的关系很快的获取到。那么从正则表达式得到DFA的方法如下:

// 构造D的状态集Dstates和D的转换函数Dtran。
初始化Dstates,使之只包含未标记状态firstpos(n0),其中n0是(r)#的抽象语法树的根节点
while(Dstates中存在未标记状态S){
	标记S;
	for(每个输入符号a){
		令U为S中和a对应的所有位置p的followpos(p)的并集;
		if(U不在Dstates中)
			将U作为未标记的状态加入到Dstates中;
		Dtran[S,a] = U;
	}
}

这个过程可以简单理解如下:

  1. 用位置的集合来构造状态;
  2. 利用预计算得到的followpos作为转换关系;

实际上这样操作的时候计算量、代码量应该都不小。虽然DFA看起来比NFA优秀一些,但是还有进步的空间,接着往下看。

状态数最小化

从NFA到DFA之后,在匹配的时候不需要根据相同的输入符号来判断不同的目标状态,但是DFA中有些状态实际上是等价的,那么此时就想办法将这些等价的状态合并掉,从而使得状态数目最小化。那么两个状态如果不等价是指:

如果从状态s、t出发,沿着标号为x的路径到达两个状态,分别为接受状态和非接受状态,那么s、t为不等价的。

先来一个最小话的例子:

最小话的过程就是将状态数根据进行分组的过程,同一个组内的状态是等价的,过程如下:

  1. 根据接受、非接受分成两组;
  2. 遍历分组、字符,如果同一组内的状态根据该字符到达了不同的组,那么继续将当前的组进行分割;
  3. 重复执行2,直到没有变化;
  4. 每个组中选择一个代表状态,重新构造DFA,最小化完成;

可以证明:任何一个正则表达式都有唯一的状态数最少的DFA

语法分析

在语法分析中的一些概念:

  1. 上下文无关文法
  2. 推导
  3. 语法分析树
  4. 二义性:同一个句子有多个最左推导或最右推导;
  5. 左递归
  6. 左公因子
  7. 自顶向下、自底向上
  8. LL、LR
  9. 规约

语法分析器从词法分析器获取一个由词法单元组成的串,并验证这个串可以由源语言的文法生成(如果不能则提供易于理解的方式报告错误并能够从常见的错误中恢复并继续处理程序的其他部分),然后构造出一棵语法分析树并把它传给编译器的其他部分进一步处理。常用的方法有两种:

  1. 自顶向下 --> 推导
  2. 自底向上 --> 规约  

消除二义性没有什么万能的方法,只能具体问题具体分析,在下面的左边语法中的二义性在于类似if E1 then if E2 then S1 else S2这样的语句中的else不知道对应哪个if,那么重写规则进行限制即可:

另外一个头疼的问题是左递归。

左递归

先来看一个实际的例子,对于E->E + T|T进行重写得到:

这样就可以将这种直接左递归马上去掉,但是对于多层的左递归并没有这样直观,解决办法如下:

按照某个顺序将非终结符号排序为:A1、A2、A3....
for(从1到n的每个i){
	for(从1到i-1的每个j){
		将每个形如Ai->Ajx的产生式替换为产生式组Ai->t1x|t2x|t3x|...,其中Aj->t1|t2|t3|...是所有Aj产生式;
	}
	消除Ai产生式之间的立即左递归
}

将等式左边的非终结符和右边的第一个非终结符连起来看成是一条有向边,这样一组语法就可以看成是一个图。那么存在左递归也就等价于该图中存在环。经过上面的过程中图中的边都是朝相同的方向(非终结符号的顺序),也就不会存在环了,所以也就将左递归消除了。

自顶向下

一个递归下降语法分析程序由一组方法组成,每个非终结符有一个对应的方法,程序的执行从开始符号对应的方法开始。如果这个过程的方法扫描了整个输入串,那么说明语法分析完成。一个简单的例子如下:

void A(){
	选择一个A的产生式,A->X1,X2,X3....
	for(i = 1 to k){
		if(Xi 是一个非终结符号)
			调用过程Xi();
		else if(Xi 等于当前输入符号a)
			读入下一个输入符号
		else
			/* 发现不匹配,需要回溯 */
	}
}

在这里可以看到用递归下降来分析含有左递归的文法会直接进入死循环。在通过输入符号来选择产生式的时候需要用到两个集合:

  1. FIRST(α):可以有α推导得到的串的首字符的集合。
  2. FOLLOW(A):可能在某些句型中紧跟在A右边的终结符号的集合。

那么接下来通过这两个集合来构造预测分析表M,方法如下:

  1. 对于FIRST(α)中的每个终结符a,将A->α加入到M[A, a]中;
  2. 如果ε在FIRST(α)中,那么对FOLLOW(A)中的每个终结符b,将A->α加入到M[A,b]中。

对于LL(1)的文法,分析表中的每个条目都唯一地指定一个产生式或者标明一个语法错误,语法分析器在执行的过程中根据该表选择产生式即可。

自底向上

一个自底向上的语法分析过程对应于为一个输入串构造语法分析树的过程,它从叶子节点(底部)开始逐渐向上到达根部(顶部),可以看做是将一个串规约为文法开始符号的过程,下面是一个简单的例子:

移入-规约语法分析技术是自底向上语法分析的一种形式,它用一个栈来保存文法符号,设涉及到的操作有:

  1. 移入:将下一个输入符号移到栈的顶部。
  2. 归纳:被规约的符号串的右端必然是栈的顶端,语法分析器在栈中确定这个串的左端并决定哪个非终结符号来替换这个串。
  3. 接受:宣布语法分析过程成功完成。
  4. 报错:发现一个语法错误,并调用一个错误恢复子例程。

用这种方法来做id*id这个例子的过程如下:

这里比较麻烦的问题是如何知道什么时候应该移入、什么时候应该规约,一个LR语法分析器通过维护一些状态来表明我们在语法分析过程中所处的位置,从而做出移入-规约的决定,这些状态就是项的集合。产生式A->XYZ产生了四个项:

  1. A->.XYZ
  2. A->X.YZ
  3. A->XY.Z
  4. A->XYZ.

比如A->X.YZ说明我们刚刚在输入中看到了一个由X推导得到的串,并且我们希望接下来看到一个能从YZ推导得到的串。那么可以构造一个状态机如下:

在项的集合上有两种操作:

  1. CLOSURE:在语法分析过程中的某点上,我们认为接下来可能会在输入中看到一个能够推导得到的子串。
  2. GOTO:根据输入符号在闭包之间的转换。

SLR(简单LR语法分析技术)的中心思想是根据文法构造出自动机,假如串r使自动机从开始状态0运行到某个状态j,那么如果下一个输入符号为a且状态j有一个在a上的转换,此时移入a;否则根据状态j的项(产生式)进行规约。同样id*id的执行过程如下:

在写LR的分析程序的时候会涉及到两个表:

  1. ACTION:在遇到终结符的时候可能的操作;
  2. GOTO:规约之后状态的变化,也就是说当规约为某个非终结符之后栈顶状态应该是什么;

整个程序看起来如下图:

在有了ACTION和GOTO之后执行过程就比较简单了,那么现在的问题是如何将他们构造出来(其实利用项集之间的转换很容易解决)。文法的LR(0)自动机可以刻画出可能出现在栈中的文法符号串,栈中的内容一定是某个最右句型的前缀。然而不是所有的最右句型的前缀都可以出现在栈中:

E => F*id => (E)*id

这里((E(E)都可以是栈中存放的内容,但是(E)*则不会,因为(E)是句柄,会在移入*之前将其规约为F。那么将可以出现在栈中的最右句型前缀称为可行前缀

如果我们在某个文法的LR(0)自动机中从初始状态开始沿着标号为某个可行前缀γ的路径到达一个状态,那么该状态对应的项集就是γ的有效项集。实质上,有效项集包含了所有能够从栈中收集到的有用信息。

a

 

 

 

 

 

 

 

 

 

----- updating -----