C# 詞法分析器(三)正則表達式

系列導航html

  1. (一)詞法分析介紹
  2. (二)輸入緩衝和代碼定位
  3. (三)正則表達式
  4. (四)構造 NFA
  5. (五)轉換 DFA
  6. (六)構造詞法分析器
  7. (七)總結

正則表達式是一種描述詞素的重要表示方法。雖然正則表達式並不能表達出全部可能的模式(例如「由等數量的 a 和 b 組成的字符串」),可是它能夠很是高效的描述處理詞法單元時要用到的模式類型。git

1、正則表達式的定義

正則表達式能夠由較小的正則表達式按照規則遞歸地構建。每一個正則表達式 rr 表示一個語言 L(r)L(r),而語言能夠認爲是一個字符串的集合。正則表達式有如下兩個基本要素:github

  1. ϵϵ 是一個正則表達式, L(ϵ)=ϵL(ϵ)=ϵ,即該語言只包含空串(長度爲 0 的字符串)。
  2. 若是 aa 是一個字符,那麼 aa 是一個正則表達式,而且 L(a)={a}L(a)={a},即該語言只包含一個長度爲 11 的字符串 aa。

由小的正則表達式構造較大的正則表達式的步驟有如下四個部分。假定 rr 和 ss 都是正則表達式,分別表示語言 L(r)L(r) 和 L(s)L(s),那麼:正則表達式

  1. (r)|(s)(r)|(s) 是一個正則表達式,表示語言 L(r)L(s)L(r)∪L(s),即屬於 L(r)L(r) 的字符串和屬於 L(s)L(s) 的字符串的集合( L(r)L(s)={s|sL(r) or sL(s)}L(r)∪L(s)={s|s∈L(r) or s∈L(s)})。
  2. (r)(s)(r)(s) 是一個正則表達式,表示語言 L(r)L(s)L(r)L(s),即從 L(r)L(r) 中任取一個字符串,再從 L(s)L(s) 中任取一個字符串,而後將它們鏈接後獲得的全部字符串的集合( L(r)L(s)={st|sL(r) and tL(s)}L(r)L(s)={st|s∈L(r) and t∈L(s)})。
  3. (r)(r)∗ 是一個正則表達式,表示語言 L(r)L(r)∗,即將 L(r)L(r) 鏈接 00 次或屢次後獲得的語言。
  4. (r)(r) 是一個正則表達式,表示語言 L(r)L(r)。

上面這些規則都是由 Kleene 在 20 世紀 50 年代提出的,在以後有出現了不少針對正則表達式的擴展,他們被用來加強正則表達式表述字符串模式的能力。這裏採用是相似 Flex 的正則表達式擴展,風格則相似於 .Net 內置的正則表達式:算法

正則表達式 描述
x 單個字符 x。
. 除了換行之外的任意單個字符。
[xyz] 一個字符類,表示 'x','y','z' 中的任意一個字符。
[a-z] 一個字符類,表示 'a' 到 'z' 之間的任意一個字符(包含 'a' 和 'z')。
[^a-z] 一個字符類,表示除了 [a-z] 以外的任意一個字符。
[a-z-[b-f]] 一個字符類,表示 [a-z] 範圍減去 [b-f] 範圍的字符,等價於 [ag-z]。
r* 將任意正則表達式 r 重複 0 次或屢次。
r+ 將 r 重複 1 次或屢次。
r? 將 r 重複 0 次或 1 次,即「可選」的 r。
r{m,n} 將 r 重複 m 次至 n 次(包含 m 和 n)。
r{m,} 將 r 重複 m 次或屢次(大於等於 m 次)。
r{m} 將 r 重複剛好 m 次。
{name} 展開預先定義的正則表達式 「name」,能夠經過預先定義一些正則表達式,以實現簡化正則表達式。
"[xyz]\"foo" 原義字符串,表示字符串「[xyz]"foo」,用法與 C# 中定義字符串基本相同。
\X 表示 X 字符轉義,若是 X 是 'a','b','t','r','v','f','n' 或 'e',表示相應的 ASCII 字符;若是 X 是 'w','W','s','S','d' 或 'D',則表示相應的字符類;不然表示字符 X。
\nnn 表示使用八進制形式指定的字符,nnn 最多由三位數字組成。
\xnn 表示使用十六進制形式指定的字符,nn 剛好由兩位數字組成。
\cX 表示 X 指定的 ASCII 控制字符。
\unnnn 表示使用十六進制形式指定的 Unicode 字符,nnnn 剛好由四位數字組成。
\p{name} 表示 name 指定的 Unicode 通用類別或命名塊中的單個字符。
\P{name} 表示除了 name 指定的 Unicode 通用類別或命名塊以外的單個字符。
(r) 表示 r 自己。
(?r-s:pattern)

應用或禁用子正則表達式中指定的選項。選項能夠是字符 'i','s' 或 'x'。編程

'i' 表示不區分大小寫;'-i' 表示區分大小寫。
's' 表示容許 '.' 匹配換行符;'-s' 表示不容許 '.' 匹配換行符。
'x' 表示忽略模式中的空白和註釋,除非使用 '\' 字符轉義或者在字符類中,或者使用雙引號("") 括起來;'-x' 表示不忽略空白。編程語言

如下下兩列中的模式是等價的:函數

(?:foo) (foo)
(?i:ab7) ([Aa][Bb]7)
(?-i:ab) (ab)
(?s:.) [\u0000-\uFFFF]
(?-s:.) [^\n\r]
(?ix-s: a . b) ([Aa][^\n\r][Bb])
(?x:a b) ("ab")
(?x:a\ b) ("a b")
(?x:a" "b) ("a b")
(?x:a[ ]b) ("a b")
(?x:a
    (?#comment)
    b
    c)
(abc)
(?#comment) 表示註釋,註釋中不容許出現右括號 ')'。
rs r 與 s 的鏈接。
r|s r 與 s 的並。
r/s 僅當 r 後面跟着 s 時,才匹配 r。這裏 '/' 表示向前看,s 並不會被匹配。
^r 行首限定符,僅當 r 在一行的開頭時才匹配。
r$ 行尾限定符,僅當 r 在一行的結尾時才匹配。這裏的行尾能夠是 '\n',也能夠是 '\r\n'。
<s>r 僅噹噹前是上下文 s 時才匹配 r。
<s1,s2>r 僅噹噹前是上下文 s1 或 s2 時才匹配 r。
<*>r 在任意上下文中匹配 r。
<<EOF>> 表示在文件的結尾。
<s1,s2><<EOF>> 表示在上下文 s1 或 s2 時的文件的結尾。

這裏與字符類和 Unicode 通用類別相關的知識請參考 C# 的正則表達式語言 - 快速參考中的「字符類」小節。大部分的正則表達式表示方法也與 C# 中的相同,有所不一樣的向前看(r/s)、上下文(<s>r)和文件結尾(<<EOF>>)會在以後的文章中解釋。post

利用上面的表格中列出擴展正則表達式,就能夠比較方便的定義須要的模式了。不過有些須要注意的地方:flex

  1. 這裏的定義不支持 POSIX Style 的字符類,例如 [:alnum:] 之類的,與 Flex 不一樣。
  2. $ 匹配行尾,便可以匹配 \n 也能夠匹配 \r\n,也與 Flex 不一樣。
  3. 字符集的相減是 C# 風格的 [a-z-[b-f]],而不是 Flex 那樣的 [a-c]{-}[b-z]。
  4. 向前看中的 $ 只表示 '$',而再也不匹配行尾,例如 a/b$ 僅當 "a" 後面是 "b$" 時才匹配 "a"。

2、正則表達式的表示

雖然上面定義了正則表達式的規則,但它們表示起來卻很簡單,我使用 Cyjb.Compilers.RegularExpressions 命名空間下的 8 個類來表示任意的正則表達式,其類圖以下所示:

圖 1 正則表達式類圖

其中,Regex 類是正則表達式的基類,CharClassExp 表示字符類(單個字符),LiteralExp 表示原義文本(多個字符組成的字符串),RepeatExp 表示正則表達式重複(能夠重複上限至下限之間的任意次數),AlternationExp 表示正則表達式的並(r|s),ConcatenationExp 表示正則表達式的鏈接(rs),AnchorExp 表示行首限定、行尾限定和向前看,EndOfFileExp 表示文件的結尾(<<EOF>>)。

將 CharClassExp、LiteralExp、RepeatExp、AlternationExp、ConcatenationExp 這些類進行嵌套,就能夠表示大部分正則表達式了;AnchorExp 單獨拿出來是由於它只能做爲最外層的正則表達式,而不能位於其它正則表達式內部;EndOfFileExp 則是專門用於 <<EOF>> 的。這裏並未考慮上下文,由於上下文的處理並不在正則表達式這裏,而是在以後的「終結符符定義」部分。

正則表達式的表示比較簡單,但爲了更加易用,有必要提供從字符串(例如 "abc[0-9]+")轉換爲相應的正則表達式的轉換方法。RegexCharClass 類是System.Text.RegularExpressions.RegexCharClass 類的包裝,用於表示一個字符類,我對其中的某些函數進行了修改,以符合我這裏的正則表達式定義。RegexOptions 類和 RegexParser 類則是用於正則表達式解析的類,具體的解析算法太過複雜,就很少加解釋。

3、正則表達式

正則表達式構造好後,就須要使用它去匹配詞素。一個詞法分析器可能須要定義不少正則表達式,還可能包括上下文以及行首限定符,處理起來仍是比較複雜的。爲了簡便起見,我會首先討論怎麼用一條正則表達式去匹配字符串,在以後的文章中再討論如何用組合多條正則表達式去匹配詞素。

使用正則表達式匹配字符串,通常都會用到有窮自動機(finite automata)的表示方法。有窮自動機是識別器(recognizer),只能對每一個可能的輸入回答「是」或「否」,表示時候與此自動機相匹配。或者說,不斷的讀入字符,直到有窮自動機回答「是」,此刻就正確的匹配了一個字符串。

有窮自動機分爲兩類:

  • 不肯定的有窮自動機(Nondeterministic Finite Automata,NFA)對其邊上的標號沒有任何限制。一個符號標記離開同一狀態的多條邊,而且空串 ϵϵ 也能夠做爲標號。
  • 肯定的有窮自動機(Deterministic Finite Automata,DFA)對於每一個狀態及自動機輸入字母表中的每一個符號有且只有一條離開該狀態、以該符號爲標號的邊。

NFA 和 DFA 能夠識別的語言集合是相同的(後面會說到 NFA 如何轉換爲等價的 DFA),而且這些語言的集合正好是可以用正則表達式描述的語言集合(正則表達式能夠轉換爲等價的 NFA)。所以,採用有窮自動機來識別正則表達式描述的語言,也是很天然的。

3.1 不肯定的有窮自動機 NFA

一個不肯定的有窮自動機(NFA)由如下幾個部分組成:

  1. 一個有窮的狀態集合 SS。
  2. 一個輸入符號集合 ΣΣ,即輸入字母表(input alphabet)。咱們假設空串 ϵϵ 不是 ΣΣ 中的元素。
  3. 一個轉換函數(transition function),它爲每一個狀態和 Σ{ϵ}Σ∪{ϵ} 的每一個符號都給出了相應的後繼狀態(next state)的集合。
  4. SS 中的一個狀態 s0s0 被指定爲開始狀態,或者說初始狀態。
  5. SS 的一個子集 FF 被指定爲接受狀態(或者說終止狀態)的集合。

下圖就是一個能識別正則表達式 (a|b)*baa 的語言的 NFA,邊上的字母就是該邊的標號。

圖 2 NFA 實例

NFA 的匹配過程很直觀,從起始狀態開始,每讀入一個符號,NFA 就能夠沿着這個符號對應的邊前進到下一個狀態(ϵϵ 邊不用讀入符號也能夠前進,固然也能夠不前進),就這樣不斷讀入符號,直到全部符號都讀入進來,若是最後到達的是接受狀態,那麼匹配成功,不然匹配失敗。

在狀態 1 上,有兩條標號爲 b 的邊,一條指向狀態 1,一條指向狀態 2,這就使自動機產生了不肯定性——當到達狀態 1 時,若是讀入的字符是 'b',那麼並不能肯定應該轉移到狀態 1 仍是 2,此時就須要使用集合保存全部可能的狀態,把它們都嘗試一遍才能夠。

接下來嘗試用這個 NFA 去匹配字符串 "ababaa"。

步驟 當前節點 讀入字符 轉移到節點
1 {0, 1} a {1}
2 {1} b {1, 2}
3 {1, 2} a {1, 3}
4 {1, 3} b {1, 2}
5 {1, 2} a {1, 3}
6 {1, 3} a {1, 4}

此時字符串已經所有讀入,最後到達了狀態 1 和 4,其中狀態 4 是一個接受狀態,所以 NFA 返回結果「是」。

使用 NFA 進行模式匹配的時間複雜度是 O(k(n+m))O(k(n+m)),其中 kk 爲要匹配的字符串的長度,nn 爲 NFA 中的狀態數,mm 爲 NFA 中的轉移數。可見,NFA 的效率與輸入字符串的長度和 NFA 的大小成正比,效率並不高。

3.2 肯定的有窮自動機 DFA

肯定的有窮自動機(DFA)是 NFA 的一個特例,其中:

  1. 沒有輸入 ϵϵ 之上的轉換動做。
  2. 對每一個狀態 ss 和每一個輸入符號 aa,有且只有一條標號爲 aa 的邊離開。

所以,NFA 抽象的表示了用來識別某個語言中串的算法,而相應的 DFA 則是具體的識別串的算法。

下圖是一樣識別正則表達式 (a|b)*baa 的語言的 DFA,看起來比 NFA 的要複雜很多。

圖 3 DFA 實例

DFA 的匹配過程則更加簡單,由於沒有了 ϵϵ 轉換和不肯定的轉換,只要從起始狀態開始,每讀入一個符號,就直接沿着這個符號對應的邊前進到下一個狀態(這個狀態是惟一的),就這樣不斷讀入符號,直到全部符號都讀入進來,若是最後到達的是接受狀態,那麼匹配成功,不然匹配失敗。

接下來嘗試用這個 DFA 去匹配字符串 "ababaa"。

步驟 當前節點 讀入字符 轉移到節點
1 0 a 0
2 0 b 1
3 1 a 2
4 2 b 1
5 1 a 2
6 2 a 3

此時字符串已經所有讀入,最後到達了狀態 3,是一個接受狀態,所以 DFA 返回結果「是」。

使用 DFA 進行模式匹配的時間複雜度是 O(k)O(k),其中 kk 爲要匹配的字符串的長度,可見,DFA 的效率只與輸入字符串的長度有關,效率很是高。

3.3 爲何使用 DFA

上面介紹的 NFA 和 DFA 識別語言的能力是相同的,但在詞法分析中實際使用的都是 DFA,是有下面幾種緣由。

  1. NFA 的匹配效率比不過 DFA 的,詞法分析器顯然運行的越快越好。
  2. 雖然 DFA 的構造則要花費很長時間,通常是 O(r3)O(r3),最壞狀況下可能會是 O(r22r)O(r22r),但在詞法分析器這一特定領域中,DFA 只須要構造一次,就能夠屢次使用,並且 Flex 能夠在生成源代碼的時候就構造好 DFA,耗點時間也沒有關係。
  3. DFA 在最壞狀況下可能會使狀態個數呈指數增加,《編譯原理》上給出了一個例子 (a|b)a(a|b)n1(a|b)∗a(a|b)n−1,識別這個正則表達式的 NFA 具備 n+1n+1 個狀態,而 DFA 卻至少有 2n2n 個狀態,不過這麼特殊的狀況在編程語言中基本不會見到,不用擔憂這一點。

不過 NFA 仍是有用的,由於 DFA 要利用 NFA,經過子集構造法獲得;將正則表達式轉換爲 NFA,也有助於理解如何處理多條正則表達式和處理向前看。下一篇文章就開始介紹 NFA 的表示以及如何將正則表達式轉換爲 NFA。

相關代碼均可以在這裏找到,一些基礎類(如輸入緩衝)則在這裏

相關文章
相關標籤/搜索