Regex是一種描述詞素的重要表示方法。雖然Regex並不能表達出所有可能的模式(例如“由等數量的 a 和 b 組成的字串”),但是它可以非常高效的描述處理詞法單元時要用到的模式類型。
一、Regex的定義
Regex可以由較小的Regex按照規則遞迴地構建。每個Regex r 表示一個語言 L(r) ,而語言可以認為是一個字串的集合。Regex有以下兩個基本要素:
1.ϵ 是一個Regex, L(ϵ)=ϵ ,即該語言只包含空串(長度為 0 的字串)。
2.如果 a 是一個字元,那麼 a 是一個Regex,並且 L(a)={a} ,即該語言只包含一個長度為 1 的字串 a 。
由小的Regex構造較大的Regex的步驟有以下四個部分。假定 r 和 s 都是Regex,分別表示語言 L(r) 和 L(s) ,那麼:
1.(r)|(s) 是一個Regex,表示語言 L(r)∪L(s) ,即屬於 L(r) 的字串和屬於 L(s) 的字串的集合( L(r)∪L(s)={s|s∈L(r) or s∈L(s)} )。
2.(r)(s) 是一個Regex,表示語言 L(r)L(s) ,即從 L(r) 中任取一個字串,再從 L(s) 中任取一個字串,然後將它們串連後得到的所有字串的集合( L(r)L(s)={st|s∈L(r) and t∈L(s)} )。
3.(r)∗ 是一個Regex,表示語言 L(r)∗ ,即將 L(r) 串連 0 次或多次後得到的語言。
4.(r) 是一個Regex,表示語言 L(r) 。
上面這些規則都是由 Kleene 在 20 世紀 50 年代提出的,在之後有出現了很多針對Regex的擴充,他們被用來增強Regex表述字串模式的能力。這裡採用是類似 Flex 的Regex擴充,風格則類似於 .Net 內建的Regex:
Regex |
描述 |
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* |
將任意Regex 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} |
展開預先定義的Regex “name”,可以通過預先定義一些Regex,以實現簡化Regex。 |
"[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) |
應用或禁用子Regex中指定的選項。選項可以是字元 '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) 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# 的Regex語言 - 快速參考中的“字元類”小節。大部分的Regex表示方法也與 C# 中的相同,有所不同的向前看(r/s)、上下文(<s>r)和檔案結尾(<<EOF>>)會在之後的文章中解釋。
利用上面的表格中列出擴充Regex,就可以比較方便的定義需要的模式了。不過有些需要注意的地方:
- 這裡的定義不支援 POSIX Style 的字元類,例如 [:alnum:] 之類的,與 Flex 不同。
- $ 匹配行尾,即可以匹配 \n 也可以匹配 \r\n,也與 Flex 不同。
- 字元集的相減是 C# 風格的 [a-z-[b-f]],而不是 Flex 那樣的 [a-c]{-}[b-z]。
- 向前看中的 $ 只表示 '$',而不再匹配行尾,例如 a/b$ 僅當 "a" 後面是 "b$" 時才匹配 "a"。
二、Regex的表示
雖然上面定義了Regex的規則,但它們表示起來卻很簡單,我使用 Cyjb.Compiler.RegularExpressions 命名空間下的 8 個類來表示任意的Regex,其類圖如下所示:
圖 1 Regex類圖
其中,Regex 類是Regex的基類,CharClassExp 表示字元類(單個字元),LiteralExp 表示原義文本(多個字元組成的字串),RepeatExp 表示Regex重複(可以重複上限至下限之間的任意次數),AlternationExp 表示Regex的並(r|s),ConcatenationExp 表示Regex的串連(rs),AnchorExp 表示行首限定、行尾限定和向前看,EndOfFileExp 表示檔案的結尾(<<EOF>>)。
將 CharClassExp、LiteralExp、RepeatExp、AlternationExp、ConcatenationExp 這些類進行嵌套,就可以表示大部分Regex了;AnchorExp 單獨拿出來是因為它只能作為最外層的Regex,而不能位於其它Regex內部;EndOfFileExp 則是專門用於 <<EOF>> 的。這裡並未考慮上下文,因為內容相關的處理並不在Regex這裡,而是在之後的“終結符符定義”部分。
Regex的表示比較簡單,但為了更加易用,有必要提供從字串(例如 "abc[0-9]+")轉換為相應的Regex的轉換方法。RegexCharClass 類是System.Text.RegularExpressions.RegexCharClass 類的封裝,用於表示一個字元類,我對其中的某些函數進行了修改,以符合我這裡的Regex定義。RegexOptions 類和 RegexParser 類則是用於Regex解析的類,具體的解析演算法太過複雜,就不多加解釋。
三、Regex
Regex構造好後,就需要使用它去匹配詞素。一個詞法分析器可能需要定義很多Regex,還可能包括上下文以及行首限定符,處理起來還是比較複雜的。為了簡便起見,我會首先討論怎麼用一條Regex去匹配字串,在之後的文章中再討論如何用組合多條Regex去匹配詞素。
使用Regex匹配字串,一般都會用到有窮自動機(finite automata)的表示方法。有窮自動機是辨識器(recognizer),只能對每個可能的輸入回答“是”或“否”,表示時候與此自動機相匹配。或者說,不斷的讀入字元,直到有窮自動機回答“是”,此刻就正確的匹配了一個字串。
有窮自動機分為兩類:
不確定的有窮自動機(Nondeterministic Finite Automata,NFA)對其邊上的標號沒有任何限制。一個符號標記離開同一狀態的多條邊,並且空串 $\epsilon$ 也可以作為標號。確定的有窮自動機(Deterministic Finite Automata,DFA)對於每個狀態及自動機輸入字母表中的每個符號有且只有一條離開該狀態、以該符號為標號的邊。
NFA 和 DFA 可以識別的語言集合是相同的(後面會說到 NFA 如何轉換為等價的 DFA),並且這些語言的集合正好是能夠用Regex描述的語言集合(Regex可以轉換為等價的 NFA)。因此,採用有窮自動機來識別Regex描述的語言,也是很自然的。
3.1 不確定的有窮自動機 NFA
一個不確定的有窮自動機(NFA)由以下幾個部分組成:
一個有窮的狀態集合 $S$。一個輸入符號集合 $\Sigma$,即輸入字母表(input alphabet)。我們假設空串 $\epsilon$ 不是 $\Sigma$ 中的元素。一個轉換函式(transition function),它為每個狀態和 $\Sigma \cup \{ \epsilon \}$ 的每個符號都給出了相應的後繼狀態(next state)的集合。$S$ 中的一個狀態 $s_0$ 被指定為開始狀態,或者說初始狀態。$S$ 的一個子集 $F$ 被指定為接受狀態(或者說終止狀態)的集合。
就是一個能識別Regex (a|b)*baa 的語言的 NFA,邊上的字母就是該邊的標號。
圖 2 NFA 執行個體
NFA 的匹配過程很直觀,從起始狀態開始,每讀入一個符號,NFA 就可以沿著這個符號對應的邊前進到下一個狀態($\epsilon$ 邊不用讀入符號也可以前進,當然也可以不前進),就這樣不斷讀入符號,直到所有符號都讀入進來,如果最後到達的是接受狀態,那麼匹配成功,否則匹配失敗。
在狀態 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))$,其中 $k$ 為要匹配的字串的長度,$n$ 為 NFA 中的狀態數,$m$ 為 NFA 中的轉移數。可見,NFA 的效率與輸入字串的長度和 NFA 的大小成正比,效率並不高。
3.2 確定的有窮自動機 DFA
確定的有窮自動機(DFA)是 NFA 的一個特例,其中:
沒有輸入 $\epsilon$ 之上的轉換動作。對每個狀態 $s$ 和每個輸入符號 $a$,有且只有一條標號為 $a$ 的邊離開。
因此,NFA 抽象的表示了用來識別某個語言中串的演算法,而相應的 DFA 則是具體的識別串的演算法。
是同樣識別Regex (a|b)*baa 的語言的 DFA,看起來比 NFA 的要複雜不少。
圖 3 DFA 執行個體
DFA 的匹配過程則更加簡單,因為沒有了 $\epsilon$ 轉換和不確定的轉換,只要從起始狀態開始,每讀入一個符號,就直接沿著這個符號對應的邊前進到下一個狀態(這個狀態是唯一的),就這樣不斷讀入符號,直到所有符號都讀入進來,如果最後到達的是接受狀態,那麼匹配成功,否則匹配失敗。
接下來嘗試用這個 DFA 去匹配字串 "ababaa"。
步驟當前節點讀入字元轉移到節點10a020b131a242b151a262a3
此時字串已經全部讀入,最後到達了狀態 3,是一個接受狀態,因此 DFA 返回結果“是”。
使用 DFA 進行模式比對的時間複雜度是 $O(k)$,其中 $k$ 為要匹配的字串的長度,可見,DFA 的效率只與輸入字串的長度有關,效率非常高。
3.3 為什麼使用 DFA
上面介紹的 NFA 和 DFA 識別語言的能力是相同的,但在詞法分析中實際使用的都是 DFA,是有下面幾種原因。
NFA 的匹配效率比不過 DFA 的,詞法分析器顯然啟動並執行越快越好。雖然 DFA 的構造則要花費很長時間,一般是 $O(r^3)$,最壞情況下可能會是 $O(r^22^r)$,但在詞法分析器這一特定領域中,DFA 只需要構造一次,就可以多次使用,而且 Flex 可以在產生原始碼的時候就構造好 DFA,耗點時間也沒有關係。DFA 在最壞情況下可能會使狀態個數呈指數增長,《編譯原理》上給出了一個例子 $(a|b)*a(a|b)^{n-1}$,識別這個Regex的 NFA 具有 $n+1$ 個狀態,而 DFA 卻至少有 $2^n$ 個狀態,不過這麼特殊的情況在程式設計語言中基本不會見到,不用擔心這一點。
不過 NFA 還是有用的,因為 DFA 要利用 NFA,通過子集構造法得到;將Regex轉換為 NFA,也有助於理解如何處理多條Regex和處理向前看。下一篇文章就開始介紹 NFA 的表示以及如何將Regex轉換為 NFA。