词法分析——使用正则文法

(周游[http://www.cnblogs.com/naturemickey]版权所有,未经许可请勿转载)


在我的前一篇文章《按编译原理的思路设计的一个计算器》中,大致讲了编译器的结构及构造思路。

这次把词法分析的部分单独拿出来细讲一下。

 

一、什么是词法分析

词法分析是编译器的第一个阶段。它输入一段程序的文本,输出这段文本中的每个词法单元。

还是按前一篇文章的例子来说,我们输入一短程序文本(10 + pow(2, 3)) * sqrt(4) - 1给词法分析程序,词法分析程序会把相邻的可构成单个词法单元的字母合并成词法单元列表,如下:

( 10 + pow ( 2 , 3 ) ) * sqrt ( 4 ) - 1



这就是词法分析所做的全部工作。

 

二、什么是正则文法

在上一篇文章中,对于词法分析的部分,我并没有使用正则文法,这是因为,上一篇文章中我们实现的语言非常简单,很容易就可以手工画出一个DFA图。但如果我们要实现的语言相对比较复杂,就不太容易直接画出这个图了,这样我们就需要借助于其它更简单的方式来表示词法结构,并使用一套算法把我们的表示变成DFA。

相对比较通用的表示词法结构的方法就是“正则文法”。

举一个正则文法的例子——以下文法与大多数开发语言中的数字的表示非常类似:

digit                       -> 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9

unsigned_integer  -> digit digit *

unsigned_number -> unsigned_integer (( . unisgned_integer ) | Ɛ ) 

1.在文法中“->”符号左边是一个文法表达式的名子,右边是文法表达式。

2.第一个表达式的意思是:一个digit是一个一位的十进制数字——0或1或2或3……或9。

3.第二个表达式的意思是:一个unsigned_integer是由一个digit开头,后面跟关0个或多个digit——这里的“*”符号表示0个或多个——其实这就是说,至少一个数字,最多不限位数的数字连起来,就是一个unsigned_integer。

4.第三个表达式看起来比较复杂,但其实稍微解释一下便不难理解——小括号括起来的部分就是一组结构,例如( . unsigned_integer )就是说.和unsigned_integer连起来是一个组。这里面有一个希腊字母Ɛ,这个字母表示空,也就是不存在的意思。这样(( . unisgned_integer ) | Ɛ )就表示这样一个结构:可以为一个小数点后面根着很多数字,也可以为空。那么unsigned_integer (( . unisgned_integer ) | Ɛ )就表示:开始可以由1个或多个数字,后面什么都没有或者根着小数(其实就是大家熟悉的double类型的最简单的表示)。

 

这个表示方式极其类似于“正则表达式”的形式,只是比正则表达式多了两种东西:

1.在正则文法的一个表达式的表示中可以引用这个文法已经定义的其它表达式的名称,例如:unsigned_integer就引用了digit,而正则一个表达式就是一个整体,不能引用其它的表示了。

2.在正则文法的一个表达式中,可以有“空”的表示,正则表达式就没有空的表示了。

不过反过来说,在正则文法中,你可以不在一个表达式中引用其它表达式,也可以不使用空的表示,这样正则文法就变成了一系列正则表达式了,例如:前面例子中的digit digit *,如果我们去点表达式引用,就可以表示成(0|1|2|3|4|5|6|7|8|9)(0|1|2|3|4|5|6|7|8|9)*,这样的表示与原来是等价的,同时也是一个合法的正则表达式。

 

下面我们用正则文法来描述一下前一篇文章中的计算表达式语言的词法,那么大致会是下面的样子:

INT   -> (0|1|2|3|4|5|6|7|8|9)(0|1|2|3|4|5|6|7|8|9)*

NUM -> INT | (INT .) | (INT . INT) | (. INT)

FUN -> (pow) | (sqrt)

VAR -> (a | b | ... | z | A | B | ... | Z)(a | b | ... | z | A | B | ... | Z | 0 | 1 | 2 | ... | 8 | 9) *

ADD -> +

SUB -> -

MUL -> \*

DIV  -> /

LBT -> \(

RBT -> \)

COMMA -> ,

BLANKS -> (\t | \  | \n | \r) *

上面我用到的...,这个省略号并不是正则文法的一部分,而是因为中间字符太长,所以简化一下表示。

通常基于perl形式的正则表达式都会有这样一些内置的简化表示,例如:

\d 或 [0-9] 可简化的表示 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 。

\w 或 [_a-zA-Z0-9] 可以简化的表示 所有“大小写字母”及“数字”及“下划线”。

这就是正则表达式中的元字符的使用了,我们可以看到,如果正则文法中没有元字符,那么我们使用起来就太麻烦了。

关于这些元字符,我们后续部分再讲,现在我们暂时先麻烦一点,使用只有少量元字符的正则方法。

另外,在上面的文法表示中,并没有表示哪些是终结状态,哪些是非终结状态,例如,INT在我们的计算器中是非终结状态的。不过这个并不重要,我们只要在实际写程序时,为状态加一个属性就可以了。

所以我们暂且抛开某些不重要的东西(例如:某些不是必须的元字符;终结状态和非终结状态),关注正则文法到DFA的转换过程吧。

 

三、正则文法转换为NFA和DFA的方法

正则文法转换成DFA的算法也是有很多的,这里我们也只介绍其中一种(这种算法相对其它算法来说,更容易理解,并且和其它算法一样都可以得到最小化的DFA)。

这里还是要先介绍一下NFA和DFA的概念。

读过上一篇文章的同学其实已经比较清楚什么是DFA了——它就是一个状态转换图,有开始状态,有终止状态,有每个状态到达另一个状态的输入条件。

而NFA和DFA其实非常相似,但NFA的状态转换的输入条件可以为Ɛ(即为空),并且,一个状态获得一个输入时,可以到达多个目标状态。

还是画个图来看一下会更清楚一点:比如a*这个正则表达式的DFA图可以这样画:

而同样表示a*这个正则表达式的NFA可以这样画:

或这样画成这样

上面的图中,黑圈表示终止状态,或可接受状态,白圈是非终止状态,箭头以及箭头上的字母表示状态转换的输入及方向,箭头上没有写字母的就是空输入。

空输入的意思是,一个状态直接就可以到达另一个状态,其实也就相当于同时到达多个状态。

说到这里,大家应该差不多可以理解什么是DFA和NFA了,不过还是把它们的定义抄在下面吧!

 

1.什么是NFA?

一个不确定的有穷自动机(NFA)由以下几个部分组成:

a).一个有空的状态集合S。

b).一个输入符号集合Ʃ,即输入字母表。我们假设代表空的Ɛ不是Ʃ的元素。

c).一个转换函数,它为每个状态和Ʃ∪Ɛ中的每个符号都给出了相头的后继状态的集合。

d).S中的一个状态s0被指定为开始状态,或者说寝状态。

e).S的一个子集F,被指定为接受状态(或者说终止状态的)集合。

 

2.什么是DFA?

确定的有穷自动机(简称DFA)是不确定有穷自动机的一个特例,其中:

a).没有输入Ɛ之上的转换动作。

b).对每个状态s和每个输入符号a,有且只有一条标号为a的边离开s。

 

3.用NFA表示一个正则表达式。

正则表达式的NFA表示有几个最基本的形式,任何复杂的正则表达式都可由以下几个形式组合而成:

A)一个识别一个字符a的NFA,如下:

B)识别两个连续字符ab的NFA如下:

C)识别两个字符ab中的任意一个的NFA如下:

D)识别连续任意多个a的NFA如下(a*:kleen closure):

以上四个基本形式中的a或b,如果使用一个完整的NFA来替换,就形成了NFA的递归构造的表示。

我们使用以上的形式,来构造一个稍复杂的正则表达式的NFA形式:((ab)|c)*。

在这里,ab就是前面B的形式,把ab做为一个整体,再|c就是前页C的形式,把(ab)|c当做一个整体,再做*就是D的形式。

画出NFA图就是这个样子:

现在我们已经可以构造出任意一个正则表达式的NFA了,但是正则文法如何用NFA来表示呢?

我手头上有不少关于编译技术的书,都没有说这点。

我是这样来表示的——我不确定是否应该是这样,不过至少是可以运行的——it works!

我使用或的形式连接所有的文法,并保留每个文法的最后一个节点的终止状态。

例如有如下文法:

A -> ab

B -> A | c

C -> B *

这样会有如下的NFA图(我忘记在边上标字母了,不过我想你会明白的)。

这种有多个不同的可接受状态的NFA,没在哪个编译的书中看到过,所以我不清楚这还是不是NFA,不过在下文中为了描述方便,我仍然叫它NFA。

 

这样的NFA在运行时,只要按照贪婪匹配法,一直到匹配不下去的时候,看一下最后一次经过的黑色节点是什么状态,那么到那个黑色节点之前所有的输入就做为识别出来的一个词法元素了。

然后再回到整个NFA的最开始的状态,从上一个词法元素结束的输入之后继续识别新的元素。

即然NFA也是可以运行的,那么,到现在我们并不需要构造一个DFA也可做词法分析的工作了。DFA的好处仅仅是在分析速度上比NFA要速度快一点点。

如果大家想稍详细一点知道NFA是如何运行的,则可直接跳到本文的第(四)部分。

如果想按部就班来读,那么下面就要开始构造DFA了。

 

4.构造一个与某NFA有等价的DFA。

算法永远不只一个,在《龙书三》中是这样介绍子集构造算法的:

先定义在此算法上的三个操作:

操作 描述
Ɛ-closure(s) 能够从NFA的状态s开始只通过Ɛ转换到达的NFA的状态集合
Ɛ-closure(T) 能够从T中某个NFA状态s开始只通过Ɛ转换到达的NFA状态集合
move(T, a) 能够从T中某个状态s出发通过标号为a的转换到达的NFA状态集合

 

 

 

 

算法为如下伪代码的过程:

一开始,Ɛ-closure(s0)是Dstates中唯一状态,且它未加标记;

while(在 Dstates中有一个未标记的状态T){

        给T加上标记; 

        for(每个输入符号a){

                U = Ɛ-closure(move(T, a));

                if(U不在Dstates中)

                        将U加入到Dstates中,且不加标记;

                Dtrun[T, a] = U;

        }

}

这里的Dstates是我们要构造的DFA的状态集合,从上面的算法我们可以知道,这个DFA的每个状态实际上是NFA的一个状态的子集(所以这个算法叫做子集构造造算法),Dtrun是我们要构造的DFA的转换函数。

经过这个算法一个DFA就可以构造出来了。

下面还是举个例子吧:

还是以((ab)|c)*为例,来讲一下:

略!——画图还是太麻烦,用手画还简单一些,这个东西我是打算拿出来做培训时当面讲的,所以这里就偷懒不画了,以后在会议室的白板上手画吧。

 

5.如何最小化一个DFA。

首先还是抄一下《龙书三》中的算法,再稍讲一讲:

a).首先构造包含两个组F和S-F的初始划分P,这两个组分别是D的接受状态组和非接受状态组。

b).应用如下过程来构造新的分划Pnew

    最初,令Pnew = P;

    for ( P 中每个组G){

        将G分划为更小的组,使得两个状态s和t在同一小组中当且公当对于所有的输入符号a,状态s和t在a上的转换都到达P的同一组;

        /*在最坏情况下,每个状态各自组成一个组*/

        在Pnew中将G替换为对G进行分划得到的那些小组;

    }

c).如果Pnew = P,令Pfinal = P并接着执行步骤d);否则,用Pnew替换P并重复步骤b)。

d).在分划Pfinal的每个组中选取一个状态作为该组的代表。这些代表构成了状态最小DFA的状态(以下用D2代表这个最小化的DFA,用D代表最小化前的DFA)。D2的其它部分按如下步骤构建:

    1).D2的开始状态是包含了D的开始状态的组的代表。

    2).D2的接受状态是那些包含了D的接受状态的组的代表。请注意,每个组中要么只包含接受状态,要么只包含非接受状态,因为我们一开始就将这两个状态分开了,而b)步骤中的过程总是通过分解已经构造得到的组来得到新的组。

    3).令s是Pfinal的某个组G的代表,并令D中输入a上离开s的转换到达状态t。令r为t所在组H的代表。那么d2中存在一个从s到r在输入a上的转换。注意,在D中,组G中的每一个状态必然在输入a上进入组H中的某个状态,否则,组G应该已经被b)步骤的过程分割成更小的组了。

 

这个算法在应用时,最大的问题还是在于多个接受状态的情况(在前面我有描述到我对于正则文法的NFA的表示的理解),这样在初始划分时,我的方式是划分为多个组:一个组是所有非接受状态的状态组,其它每个组分别接受不同的可接受状态。

 

6.去除DFA中的死状态。

几本书上都说上面的最小化DFA的算法可能产生死状态(在所有输入符号上都转向自己的非接受状态)。但没有一本书有举出这样的情况的例子,也没有说怎么样可以构造出这样的极端情况,我也从没遇到过死状态的情况 。

所以我对于消除死状态的做法是:

a).首先找到死状态。

b).如果找到了死状态,就抛一个异常出来。

这样在以后如果有幸碰到了一个死状态,那就马上就知道了,我也好长长见识。

 

四、NFA和DFA的运行

关于DFA的运行,在我的前一篇博文中已经有了比较详细的描述,所以在这里就只讲一下NFA的运行。

NFA和DFA的区别只有两个:1.存在输入为Ɛ的边。2.每个状态输入一个字符之后,可能到达多个状态。

针对第一点,我们的处理方式是:当我们到达一个状态节点时,这个节点的输入为Ɛ的边到达的节点也就同时到达了——即,我们每次到达的是一个状态集合。

针对第二点,我们的处理方式是:对于每个可能的方向都走,直到每个方向都走不同为止,看哪个方向能识别的单词最长(贪婪原则),我们就认为识别到了哪个单词——如果我们设计的文法是有冲突的(即:可能有两条路径同时识别到同一个单词),这样我们就要设计一个冲突解决的办法(通常是排在前面的文法优先级更高)。

 

五、基本正则表示之外的元字符

在最基本的正则表示中,我们所需要用到的元字符有两个:一个是|,另一个是*

其它元字符都是可以用最基本的方式来表示的,比如:

?,如:a?识别0个或1个a,但我们也可以这样表示(a|Ɛ)。

+,如:a+识别1个或多个a,但我们也可以这样表示aa*。

这样的元字符只是为了方便我们的表示而存在的。

还有另外一些元字符,比如小括号用于在文法的文本表示中把其中的一部分表示分组,如果我们不用小括号,也一定要用其它符号(但小括号是大家最习惯的),所以这样的元字符是必须的。

有元字符就一定要有转义字符,因为我们要识别的文本可能就包含元字符样子的文本,比如,我们可能需要识别一个语言中包含小括号的,这样我们就要在元字符前加一个反斜杠\(。

很多正则引擎内置了很多转义字符,如:

\d代表一个0到9之间的数字(包括0和9)

\n代表一个换行

\s表示一个空白字符(空格、水平制表符、垂直制表符……)

这些转义字符中有的是存在识别上的冲突的,比如:\w和\d。

如果我们自己写的正则引擎所支持的转义字符存在这种冲突应该怎么办呢?

这个问题在书上并没有写解决办法,但这是一个一定要解决的问题,不然如果存在两个有冲突的转义字符做为输入的路径的话,那就不是DFA了。

我对这个问题的解决办法是……这里暂时省略。

我们在设计自己的正则引擎时,也可以设计为可以让用户自己定义转义字符,这样可以给用户更大的自由度,但这样更难解决冲突。

 

六、正则文法的局限性

文法局限性方面,在我的印象中,好像只有下面的第三项有在一本书中看到过。

这里只列出来,就不细说了。

1.正则文法没有递归的定义方式。

2.正则文法不能识别上下文。

3.正则文法没有计数的能力。

 

七、几个相关算法的证明

太学术化的东西我不擅长!这些证明我不照着书看真是证明不出来,不过要写一个词法分析程序我倒是不需要翻书,直接就可以敲代码了。

所以这个部分就略了吧!

 

先贴一部分比较核心的代码在这里,以后再补充内容(最近JAVA8发布了,为了学习新东西,所以我所有代码都是用JDK8来写的——我还是头一次用JAVA来写一个通用的词法分析工具,以前用C/C++写过,也用Scala写过)。

 

/****************
 *
 * 这里的代码删掉了。
 *
 ****************/



 

 

posted @ 2014-04-15 22:52  周游(Michael Chow)  阅读(4869)  评论(0编辑  收藏  举报