詞法分析——使用正則文法


(周游[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寫過)。

 

/****************
 *
 * 這里的代碼刪掉了。
 *
 ****************/



 

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM