[轉貼]構造可配置的詞法分析器(已完結)
先來段前言。
今天跟某vczh在群裡面聊天的時候,他突然很詭祕的說要我看看他的空間連線。
然後翻開一看,我靠,一連串1-7的標題。
從尾到頭倒讀一通,才發現寫的挺清楚的,比一般的教科書都要到位。
不愧是要去google/msra實習的編譯器狂人。(此人成天琢磨編譯器)
遂轉發,希望對有志瞭解編譯器工作原理的人們有所幫助。
雖然這小子說不能修改,但是有的玩意貼的時候還是稍微動一下好發一點。
不跟他打招呼了,嘿嘿。
構造可配置詞法分析
華南理工大學計算機軟體學院軟體工程05級本科
2007-11-8
本文詳細描述了通過正則表示式構造通用詞法分析器的整個演算法流程。如果有需要的話請在評論留下自己的Email,或通過QQ向本人索取文章及附件。
文件使用doc格式。附件帶有文章所有圖片的jpg及vsd格式。
構造可配置詞法分析器
陳梓瀚
華南理工大學計算機軟體學院軟體工程05級本科
2007-11-8
一、問題概述
隨著計算機語言的結構越來越複雜,為了開發優秀的編譯器,人們已經漸漸感到將詞法分析獨立出來做研究的重要性。不過詞法分析器的作用卻不限於此。回想一下我們的老師剛剛開始向我們講述程式設計的時候,總是會出一道題目:給出一個填入了四則運算式子的字串,寫程式計算該式子的結果。除此之外,我們有時候建立了比較複雜的配置檔案,譬如
當然,這些問題大部分已經得到了解決,而且歷史上也有人做出了各種各樣專門的或者通用的工具(Lex、正則表示式引擎等)來解決這一類問題。我們在使用這種工具的時候,為了更加高效地書寫配置,或者我們在某種特殊情況下需要自己製作類似的工具,就需要了解詞法分析背後的原理。本文將給出一個構造通用詞法分析工具所需要的原理。由於實現的程式碼過長,本文將不附帶實現。
究竟什麼是“把一個字串斷成一些記號”呢?我們先從四則運算式子入手。一個四則運算式子是一個字元數列,可是我們關心的物件實際上是操作符、括號和數字。於是此法分析的作用就是把一個字串斷開成我們關心的帶有屬性的記號。舉個例子:(11+22)*(33+44)是一個合法的四則運算式子,如果輸入是(左括號,”(“) (數字,”11”) (一級操作符,”+”) (數字,”22”) (右括號,”)”) (二級操作符,”*”) (左括號,”(“) (數字,”33”) (一級操作符,”+”) (數字,”44”) (右括號,”)”)的話,我們在檢查結構的時候只需要關心這個記號的屬性(也就是左括號、右括號、數字、操作符等)就行了,具體計算的時候才需要關心這個記號實際上的內容。如果式子裡邊有空格的話,我們也僅僅需要把空格當成是一種記號型別,在詞法分析得出結果之後,將具有空格屬性的記號丟棄掉就可以了,接下去的步驟不需變化。
但需要注意的是,詞法分析得到的結果是沒有層次結構的,所有的記號都是等價的物件。我們在計算表示式的時候把+和*看成了不同層次的操作符,類似的結構是具有巢狀的層次的。詞法分析不能得出巢狀層次結構的資訊,最多隻能得到關於重複結構的資訊。
二、正則表示式
我們現在需要尋找一種可以描述記號型別的工具,在此之前我們首先研究一下常見的記號的結構。為了表示出具有某種共性的字串的集合,我們需要書寫出一些能代表字串集合的規則。這個集合中的所有成員都將被認為是一種特定型別的記號。
首先,規則可以把一個特定的字元或者是空字串認為是一種型別的記號的全部。上文所說到的四則運算式子的例子,“左括號”這種型別的記號就僅僅對應著字元”(“,其他的字元或者字串都不可能是“左括號”這個型別的記號。
其次,規則可以進行串聯。串聯的意思是這樣的,我們可以讓一個字串的字首符合某一個指定的規則,剩下的部分的字首符合第二個規則,剩下的部分的字首符合第三個規則等等,一直到最後一個部分的全部要符合最後一個規則。如果我們把”function”這個字串作為一個記號型別來處理的話,我們可以把”function”這個字串替換成8個串聯的規則:”f”,”u”,”n”,”c”,”t”,”i”,”o”,”n”。首先,字串”function”的字首”f”符合規則”f”,剩下的部分”unction”的字首”u”符合規則”u”,等等,一直到最後一個部分”n”的全部符合規則”n”。
第三,規則可以進行並聯。並聯的意思就是,如果一個字串符合一系列規則中的其中一個的話,我們就說這個字串符合這一些規則的並聯。於是這些規則的並聯就構成了一個新的規則。一個典型的例子就是判斷一個字串是否關鍵字。關鍵字可以是”if”,可以是”else”,可以是”while”等等。當然,一個關鍵字是不可能同時符合這些規則的,不過只要一個字串符合這些規則的其中一個的話,我們就說這個字串是關鍵字。於是,關鍵字這個規則就是”if”、”else”、”while”等規則的並聯。
第四,一個規則可以是可選的。可選的規則實際上是屬於並聯的一種特殊形式。加入我們需要規則”abc”和”abcde”並聯,我們會發現這兩個規則有著相同的字首”abc”,而且這個字首恰好就是其中的一個規則。於是我們可以把規則改寫成”abc”與””和”de”的並聯的串聯。但是規則””指定的規則是空串,因此這個規則與”de”的並聯就可以看成是一個可選的規則”de”。
第五,規則可以被重複。有限次的重複可以使用串聯表示,但是如果我們不想限制重複的次數的話,串聯就沒法表示這個規則了,於是我們引入了“重複”。一個典型的例子就是程式設計語言的識別符號。識別符號可以是一個變數的名字或者是其他東西。一門語言通常沒有規定變數名的最大長度。因此為了表示這個規則,就需要將52個字母進行並聯,然後對這個規則進行重複。
上述的5種構造規則的方法中,後面的4個方法被用於把規則組合成為更大的規則。為了給出這種規則的形式化表示,我們引入了一種正規化。這種正規化有以下語法:
1:字元用雙引號包圍起來,空串使用ε代替。
2:兩個規則頭尾連線代表規則的串聯。
3:兩個規則使用 | 隔開代表規則的並聯。
4:規則使用[]包圍代表該規則是可選的,規則使用{}包圍代表該規則是重複的。
5:規則使用()包圍代表該規則是一個整體,通常用於改變操作符 | 的優先順序。
舉個例子,一個實數的規則書寫如下:
{“0”|”1”|”2”|”3”|”4”|”5”|”6”|”7”|”8”|”9”}”.”[{“0”|”1”|”2”|”3”|”4”|”5”|”6”|”7”|”8”|”9”}]。
但是,我們如何表示“不是數字的其他字元呢”?字元的數量是有限的,因此我們可以使用規則的並聯來表示。但是所有的字元實在是太多(ASCII字符集有127個字元,UTF-16字符集有65535個字元),因此後來人們想出了各種各樣的簡化規則書寫的辦法。比較著名的有BNF正規化。BNF正規化經常被用於理論研究,但是更加實用的是正則表示式。
正則表示式的字元不需要用雙引號括起來,但是如果需要表示一些被定義了的字元(如 “|” )的話,就使用轉義字元的方法表示(如 “\|”)。其次,X?代表[X],X+代表{X},X*代表[{X}]。字元集合可以用區間來表示,[0-9]可以表示“0”|”1”|”2”|”3”|”4”|”5”|”6”|”7”|”8”|”9”,[^0-9]則表示“除了數字以外的其他字元”。正則表示式還有各種各樣的其他規則來簡化我們的書寫,不過由於本文並不是“精通正則表示式”,因此我們只保留若干比較本質的操作來進行詞法分析原理的描述。
正則表示式的表達能力極強,小數的規則可以使用[0-9]+.[0-9]來表示,C語言的註釋可以表示為/\*([^\*]|\*+[\*/])*\*+/來表示。
三、有窮狀態自動機
人閱讀正則表示式會比較簡單,但是機器閱讀正則表示式就是一件非常困難的事情了。而且,直接使用正則表示式進行匹配配的話,不僅工作量大,而且速度緩慢。因此我們還需要另外一種專門為機器設計的表達方式。本文在以後的章節中會給出一種演算法把正則表示式轉換為機器可以閱讀的形式,就是這一章節所描述的有窮狀態自動機。
有窮狀態自動機這個名字聽起來比較可怕,不過實際上這種自動機並沒有想象中的那麼複雜。狀態機的這種概念被廣泛的應用在各種各樣的領域中。軟體工程的統一建模語言(UML)有狀態圖,數字邏輯中也有狀態轉移圖。不過這些各種各樣的圖在本質上都跟狀態機沒有什麼區別。我將會通過一個例子來講述狀態的實際意義。
假設我們現在需要檢查一個字串中a的數量和b的數量是否都是偶數。當然我們可以用一個正則表示式來描述它。不過對於這個問題來說,用正則表示式來描述遠遠不如構造狀態機方便。我們可以設計出一個狀態的集合,然後指定集合中的某一個元素為“起始狀態”。其實狀態就是在工作還沒開始的時候,分析器所處的狀態。分析器在每一次進行一項新的工作的時候,都要把狀態重置為起始狀態。分析器每讀入一個字元就修改一次狀態,修改的方法我們也可以指定。分析器在讀完所有的字元以後,必然停留在一個確定的狀態中。如果這個狀態跟我們所期望的狀態一致的話,我們就說這個分析器接受了這個字串,否則我們就說這個分析器拒絕了這個字串。
如何通過設計狀態及其轉移方法來實現一個分析器呢?當然,如果一個字串僅僅包含a或者b的話,那麼分析器的狀態只有四種:“奇數a奇數b”、“奇數a偶數b”、“偶數a奇數b”、“偶數a偶數 b”。我們把這些狀態依次命名為aa、aB、Ab、AB。大寫代表偶數,小寫代表奇數。當工作還沒開始的時候,分析器已經讀入的字串是空串,那麼理所當然的起始狀態應當是AB。當分析器讀完所有字元的時候,我們期望讀入的字串的a和b的數量都是偶數,那麼結束的狀態也應該是AB。於是我們給出這樣的一個狀態圖:
圖3.1
檢查一個字串是否由偶數個a和偶數個b組成的狀態圖
在這個狀態圖裡,有一個短小的箭頭指向了AB,代表AB這個狀態是初始狀態。AB狀態有粗的邊緣,代表AB這個狀態是結束的可接受狀態。一個狀態圖的結束狀態可以是一個或者多個。在這個例子裡,起始狀態和結束狀態剛好是同一個狀態。標有字元”a”的箭頭從AB指向aB,代表如果分析器處於狀態AB並且讀入的字元是a的話,就轉移到狀態aB上。
我們把這個狀態圖應用在兩個字串上,分別是”abaabbba”和”aababbaba”。其中,第一個字串是可以接受的,第二個字串是不可接受的(因為有5個a和4個b)。
分析第一個字串的時候,狀態機所經過的狀態是:
AB[a]aB[b]ab[a]Ab[a]ab[b]aB[b]ab[b]aB[a]AB
分析第二個字串的時候,狀態機所經過的狀態是:
AB[a]aB[a]AB[b]Ab[a]ab[b]aB[b]ab[a]Ab[b]AB[a]aB
第一個字串”abaabbba”讓狀態機在狀態AB上停了下來,於是這個字串是可以接受的。第二個字串”aababbaba”讓狀態機在狀態aB上停了下來,於是這個字串是不可以接受的。
在機器內部表示這個狀態圖的話,我們可以使用一種比較簡單的方法。這種方法僅僅把狀態與狀態之間的箭頭、起始狀態和結束狀態集合記錄下來。對應於這個狀態圖的話,我們就可以把這個狀態圖表示成以下形式:
起始狀態:AB
結束狀態集合:AB
(AB,a,aB)
(AB,b,Ab)
(aB,a,AB)
(aB,b,ab)
(Ab,a,ab)
(Ab,b,AB)
(ab,a,Ab)
(ab,b,aB)
用一個狀態圖來表示狀態機的時候有時候會遇到確定性與非確定性的問題。所謂的確定性就是指對於任何一個狀態,輸入一個字元都可以跳轉到另一個確定的狀態中去。確定性和非確定性的區別有一個直觀的描述:狀態圖的任何一個狀態都可以有不定數量的邊指向另一個狀態,如果在這些邊裡面,存在兩條邊,它們所承載的字元如果相同,那麼這個狀態輸入這個就字元可以跳轉到另外兩個狀態中去,於是該狀態機就是不確定的。如圖所示:
圖3.2
正則表示式ba*b的一個確定的狀態機表示
圖3.3
正則表示式ba*b的一個非確定的狀態機表示
圖3.3中的狀態機的起始狀態讀入字元b後可以跳轉到中間的兩個狀態裡,因此這個狀態機是非確定的。相反,圖3.2中的狀態機,雖然功能跟圖3.3的狀態機一致,但卻是確定的。我們還可以使用一種特殊的邊來進行狀態的轉換。我們用ε邊來表示一個狀態可以不讀入字元就跳轉到另一個狀態上。下圖給出了一個跟圖3.3功能一致的包含ε邊的非確定的狀態機:
圖3.4
正則表示式ba*b的一個帶有ε邊的非確定的狀態機
在教科書中,通常把確定的有窮狀態自動機(有窮狀態自動機也就是本文討論的這種狀態機)稱為DFA,把非確定的有窮狀態自動機稱為NFA,把帶有ε邊的非確定的狀態機稱為ε-NFA。下文中也將採用這幾個術語來指示各種型別的有窮狀態自動機。
在剛剛接觸到ε邊的時候,一個通常的疑問就是這種邊存在的理由。事實上如果是人直接畫狀態機的話,有時候也可以直接畫出一個確定的狀態機,複雜一點的話也可以畫出一個非確定的狀態機,在有些極端的情況下我們需要使用ε邊來更加簡潔的表示我們的意圖。不過ε邊存在的最大的理由就是:我們可以通過使用ε邊來給出一個簡潔的演算法把一個正則表示式轉換成ε-NFA。
四、從正則表示式到ε-NFA
通過第二節所描述的內容,我們知道一個正則表示式的基本元素就是字符集。通過對規則的串聯、並聯、重複、可選等操作,我們可以構造除更復雜的正則表示式。如果從正則表示式構造狀態機的時候也可以用這幾種操作對狀態圖進行組合的話,那麼方法將會變得很簡單。接下來我們將一一對這5個構造正則表示式的方法進行討論。使用下文描述的演算法構造出來的所有ε-NFA都有且只有一個結束狀態。
1:字符集
字符集是正則表示式最基本的元素,因此反映到狀態圖上,字符集也會是構成狀態圖的基本元素。對於字符集C,如果有一個規則只接受C的話,這個規則對應的狀態圖將會被構造成以下形式:
圖4.1
這個狀態圖的初始狀態是Start,結束狀態是End。Start狀態讀入字符集C跳轉到End狀態,不接受其他字符集。
2:串聯
如果我們使用A⊙B表示規則A和規則B的串聯,我們可以很容易的知道串聯這個操作具有結合性,也就是說(A⊙B)⊙C=A⊙(B⊙C)。因此對於n個規則的串聯,我們只需要先將前n-1個規則進行串連,然後把得到的規則看成一個整體,跟最後一個規則進行串聯,那麼就得到了所有規則的串聯。如果我們知道如何將兩個規則串聯起來的話,也就等於知道了如何把n個規則進行串聯。
為了將兩個串聯的規則轉換成一個狀態圖,我們只需要先將這兩個規則轉換成狀態圖,然後讓第一個狀態的結束狀態跳轉到第二個狀態圖的起始狀態。這種跳轉必須是不讀入字元的跳轉,也就是令這兩個狀態等價。因此,第一個狀態圖跳轉到了結束狀態的時候,就可以當成第二個狀態圖的起始狀態,繼續第二個規則的檢查。因此我們使用了ε邊連線兩個狀態圖:
圖4.2
3:並聯
並聯的方法跟串聯類似。為了可以在起始狀態讀入一個字元的時候就知道這個字元可能走的是並聯的哪一些分支並進行跳轉,我們需要先把所有分支的狀態圖構造出來,然後把起始狀態連線到所有分支的起始狀態上。而且,在某個分支成功接受了一段字串之後,為了讓那個狀態圖的結束狀態反映在整個狀態圖的結束狀態上,我們也把所有分支的結束狀態都連線到大規則的結束狀態上。如下所示:
圖4.3
4:重複
對於一個重複,我們可以設立兩個狀態。第一個狀態是起始狀態,第二個狀態是結束狀態。當狀態走到結束狀態的時候,如果遇到一個可以讓規則接受的字串,則再次回到結束狀態。這樣的話就可以用一個狀態圖來表示重複了。於是對於重複,我們可以構造狀態圖如下所示:
圖4.4
5:可選
為可選操作建立狀態圖比較簡單。為了完成可選操作,我們需要在接受一個字元的時候,如果字串的字首被當前規則接受則走當前規則的狀態圖,如果可選規則的後續規則接受了字串則走後續規則的狀態圖,如果都接受的話就兩個圖都要走。為了達到這個目的,我們把規則的狀態圖的起始狀態和結束狀態連線起來,得到了如下狀態圖:
圖4.5
如果重複使用的是0次以上重複,也就是原來的重複加上可選的結果,那麼可以簡單地把圖4.4的Start狀態去掉,讓End狀態同時擁有起始狀態和結束狀態兩個角色,[Start]和[End]則保持原狀。
至此,我們已經將5種構造狀態圖的辦法都對應到了5種構造規則的辦法上了。對於任意的一個正則表示式,我們僅需要把這個表示式還原成那5種構造的巢狀,然後把每一步構造都對應到一個狀態圖的構造上,就可以將一個正則表示式轉換成一個ε-NFA了。舉個例子,我們使用正則表示式來表達“一個字串僅包含偶數個a和偶數個b”,然後把它轉換成ε-NFA。
我們先對這個問題進行分析。如果一個字串僅包含偶數個a和偶數個b的話,那麼這個字串一定是偶數長度的。於是我們可以把這個字串分割成兩個兩個的字元段。而且這些字元段只有四種:aa、bb、ab和ba。對於aa和bb來說,無論出現多少次都不會影響字串中a和b的數量的奇偶性(理由:在一個模2加法系統裡,0是不變項,也就是說對於任何屬於模2加法的數X有X+0 = 0+X = X)。對於ab和ba的話,如果一個字串的開始和結束是ab或者ba,中間的部分是aa或者bb的任意組合,這個字串也是具有偶數個a和偶數個b的。我們現在得到了兩種構造偶數個a和偶數個b的字串的方法。把串聯、並聯、可選、重複等操作應用在這些字串上,仍然會得到具有偶數個a和偶數個b的字串。於是我們可以把正則表示式書寫成以下形式:
((aa|bb)|((ab|ba)(aa|bb)*(ab|ba)))*
根據上文提到的方法,我們可以把這個正則表示式轉換成以下狀態機:
至此,我們已經得到了把一個正則表示式轉換為ε-NFA的方法了。但是隻得到ε-NFA還是不行的,因為ε-NFA的不確定性太大了,直接根據ε-NFA跑的話,每一次都會得到大量的臨時狀態集合,會極大地降低效率。因此,我們還需要一個辦法消除一個狀態機的非確定性。
五、消除非確定性
消除ε邊演算法
我們見到的有窮狀態自動機一共有三種:ε-NFA、NFA和DFA。現在我們需要將ε-NFA轉換為DFA。一個DFA中不可能出現ε邊,所以我們首先要消除ε邊。消除ε邊演算法基於一個很簡單的想法:如果狀態A通過ε邊到達狀態B的話,那麼狀態A無需讀入字元就可以直達狀態B。如果狀態B需要讀入字元x才可以到達狀態C的話,那麼狀態A讀入x也可以到達狀態C。因為從A到C的路徑是A B C,其中A到B不需要讀入字元。
於是我們會得到一個很自然的想法:消除從狀態A出發的ε邊,只需要尋找所有從A開始僅通過ε邊就可以到達的狀態,並把從這些狀態觸出發的非ε邊複製到A上即可。剩下的工作就是刪除所有的ε邊和一些因為消除ε邊而變得不可到達的狀態。為了更加形象地描述消除ε邊演算法,我們從正則表示式(ab|cd)*構造一個ε-NFA,並在此狀態機上應用消除ε邊演算法。
正則表示式(ab|cd)*的狀態圖如下所示:
圖5.1
1:找到所有有效狀態。
有效狀態就是在完成了消除ε邊演算法之後仍然存在的狀態。我們可以在開始整個演算法之前就預先計算出所有有效狀態。有效狀態的特點是存在非ε邊的輸入。同時,起始狀態也是一個有效狀態。結束狀態不一定是有效狀態,但是如果存在一個有效狀態可以僅通過ε邊到達結束狀態的話,那麼這個狀態應該被標記為結束狀態。因此對一個ε-NFA應用消除ε邊演算法產生的NFA可能出現多個結束狀態。不過起始狀態仍然只有一個。
我們可以把“存在非ε邊的輸入或者起始狀態”這個判斷方法應用在圖5.1每一個狀態上,計算出圖5.1中所有的有效狀態。結果如下圖所示。
圖5.2
所有非有效狀態的標籤都被刪除
如果一個狀態同時具有ε邊和非ε邊輸入的話,那麼這個狀態仍然是有效狀態。因為所有的有效狀態在下一步的操作中,都會得到新的輸出和新的輸入。
2:新增所有必要的邊
接下來我們要對所有的有效狀態都應用一個演算法。這個演算法分成兩步。第一步是尋找一個狀態的ε閉包,第二步是把這個狀態的ε閉包看成一個整體,把所有從這個閉包中輸出的邊全部複製到當前狀態上。從標記有效狀態的結果我們得到了圖5.1狀態圖的有效狀態集合是{S/E 3 5 7 9}。我們依次對這些狀態應用上述演算法。第一步,計算S/E狀態的ε閉包。所謂一個狀態的ε閉包就是從這個狀態出發,僅通過ε邊就可以到達的所有狀態的集合。下圖中標記出了狀態S/E的ε閉包:
圖5.3
現在,我們把狀態S/E從狀態S/E的ε閉包中排除出去。因為從狀態A輸出的非ε邊都屬於從狀態A的ε閉包中輸出的非ε邊,複製這些邊是沒有任何價值的。接下來就是找到從狀態S/E的ε閉包中輸出的非ε邊。在圖5.3我們可以很容易地發現,從狀態1和狀態6(見圖5.1的狀態標籤)分別輸出到狀態3和狀態7的標記了a或者b的邊,就是我們所要尋找的邊。接下來我們把這些邊複製到狀態S/E上,邊的目標狀態仍然保持不變,可以得到下圖:
圖5.4
至此,這個演算法在S/E上的應用就結束了,接下來我們分別對剩下的有效狀態{3 5 7 9}分別應用此演算法,可以得到下圖:
圖5.5
紅色的邊為新增加的邊
3:刪除所有ε邊和無效狀態
這一步操作是消除ε邊演算法的最後步驟。我們只需要刪除所有的ε邊和無效狀態就完成了整個消除ε邊演算法。現在我們對圖5.5的狀態機應用第三步,得到如下狀態圖:
圖5.6
不過並不是只有新增的邊才不被刪除。根據定義,所有從有效狀態出發的非ε邊都是不能刪除的邊。
我們通過把消除ε邊演算法應用在圖5.1的狀態機上,得到了圖5.6這個DFA。但是並不是所有的消除ε邊演算法都可以直接從ε-NFA直接得到DFA,這個其實跟正則表示式本身有關。至於什麼正則表示式可以達到這個效果這裡就不深究了。但是因為有可能產生NFA,所以我們還需要一個演算法把NFA轉換成DFA。
從NFA到DFA
NFA是非確定性的狀態機,DFA是確定性的狀態機。確定性和非確定性的最大區別就是:從一個狀態讀入一個字元,確定性的狀態機得到一個狀態,而非確定性的狀態機得到一個狀態的集合。如果我們把NFA的起始狀態S看成一個集合{S}的話,對於一個狀態集合S’,給定一個輸入,就可以用NFA計算出對應的狀態集合T’。因此我們在構造DFA的時候,只需要把起始狀態對應到S’,並且找到所有可能在NFA同時出現的狀態集合,把這些集合都轉換成DFA的一個狀態,那麼任務就完成了。因為NFA的狀態是有限的,所以NFA所有狀態的集合的冪集的元素個數也是有限的,因此使用這個方法構造DFA是完全可能的。
為了形象地表達出這個演算法的過程,我們將構造一個正則表示式,然後給出該