正則表示式幾乎每個程式設計師都會用到,對於這麼常見的一個語言,有沒有想過怎麼去實現一個呢?乍一想,也許覺得困難,實際上實現一個正則表示式的引擎並沒有想像中的複雜,《編譯原理》一書中有一章專門講解了怎麼基於狀態機來構建基本的正則表示式引擎,它講這個初衷是為詞法分析服務,不過書裡的東西相對偏理論了些,實現起來還是要費些功夫的,只是它到底指明瞭一條路,當然,書裡只針對基本的語法進行了分析講解,對於在實際中很多非常有用的擴充套件語法,它就基本沒有涉及了,這些擴充套件的語法中有些是比較好實現的,有些則比較難。
基本的正則表示式
正則表示式由字元與元字元組成,整個表示式用於描述符合某些特定特徵的一類字串,比如說表示式:abc,它表示 "abc" 這個字串,由 'a', 'b', 'c' 三個字元按順序連線在一起。基本的正則表達比較簡單,其主要包括以下規則與元字元(meta-character):
- 連線符,該操作符沒有對應的符號表示,比如對於上述的表示式 "abc",我們預設 a 與 b, b 與 c 之間有一個連線符。
- 或操作符,由 '|' 表示,該操作符表示它左右兩邊的正則表示式是一個或的關係,待匹配的字元只要符合其中一個,就是符合條件的。
- 重複操作符,共有三個:分別是 '+', '*', '?',分別用於表示將它前面的正則表示式的單元重複至少一次,至少0次,0次或1次。
- 集合,用 '[]' 圍起來,表示所有符合的字元。
- 任意字元,用'.'表示,該字元表示匹配任意字元。
- 單元或者說組,用括號'(',')'表示,該字元用於將一組正則表示式當成一個單元,使得其它的操作將該單元作為一個整體,比如說 (ab)+ 表示重複 "ab" 至少一次。
以上這些元字元從功能上來說,可分進一步劃分為以下三類:
- 用於表示一類字元,包括基本字元,及元字元:'$', '^', '.', [a-z]集合,
- 用於表示一種重複的操作,如'*', '?', '+',統稱為操作符。
- 用於表示各個表示式間的組合關係,只有兩個,或('|')及與(即連線符, 沒有具體的符號表示), 統稱為關係符。
當操作符與關係符同時作用於某一與表示式時,這兩者間有優先順序的差異,最弱的是或,其次是與,最弱的是重複操作符,比如表示式 abc|efg,其等價於 (abc)|(efg)。
擴充套件的正則表示式
由前面的說明,我們可以發現基本的正則表示式相對來說是比較弱的,語法上也很簡單,容易實現的同時不可避免地相對功能偏弱,於是就有了擴充套件的語法,擴充套件的語法相對複雜了些,這兒就不一一介紹,具體可以參考維基百科上的條目,對於本文來說,主要想實現其中的幾個語法,分別是:
- 重複,用{min,max}表示,該語法表示將前面的單元重複 min 到 max 次,是個閉區間。
- 頭和尾,分別用'^','$'表示,表示字串以該正則表示式描述的樣子開頭和結尾。
- 向後引用(back reference),用\1,\2等反斜槓加數字表示,這些符號表示引用前面單元中已經匹配好的內容,如([ab]cc)cd\1, 其中的\1在匹配時就會等於前面括號裡的表示式匹配到的內容。
之所以考慮加入這幾個語法,主要是因為它們太常用也太有用了,具體到實現上,前面兩個還比較容易,向後引用這個功能卻是很麻煩的,而且實現起來效率很低,後面會介紹。
ε-NFA
實現正則表示式引擎,目前來說流行的做法主要有兩種,一種是各大語言裡(perl, python,etc)常用的回溯法(backtracking),一種是龍書裡說的基於狀態機的做法。二者的實現各有優劣,回溯法相對來說實現功能較容易,但演算法效率很低,狀態機的實現,最大的優點是效率很高,但對於擴充套件的語法實現起來比較困難,而且程式碼相對不好理解。
對於基本的正則表示式語法來說,用狀態機實現是很理想的,效能很高,而且比較容易實現,龍書裡所說的ε-NFA(non-deterministic finite automata)是這樣一種狀態機,首先就是某些狀態對同一個輸入,它可以有多個不同的轉換,然後就是除了一般狀態機所具有的狀態與具體轉換之外,還加入了一種叫作ε的狀態及ε轉變:
如上圖所示,狀態3就是我們所說的ε狀態,該狀態只能通過ε轉換從別的狀態轉過來,也只能通過ε轉換轉到其它狀態,其中,ε轉換指的是不需要任務輸入就可以進行的轉換。ε狀態與ε轉換的加入讓狀態機的構建更加容易與清晰,同時在某些情況下也使得一些特殊功能更加好實現,但是ε狀態過多也是有壞處的,它使得狀態機的狀態轉換變複雜變冗餘了,因此應該儘可能的少用。
從正則表示式到ε-NFA##
一條完整的正則表示式可以看成是一系列小的正則表示式的組合,這些組合的關係根據前面的介紹主要可以概括為如下幾種:
- 單個字元,這是正則表示式的基本單元,如‘a', 'b','c'等。
- 連線(concat),表示將兩個正則表示式連線起來,是一個並的關係。
- 或組合,表示將兩個正則表示式用'|'連線起來。
- 重複,表示將前面的正則表示式重複指定的次數,如:?, +, *,{2, 4}等。
將正則表示式轉換為ε-NFA的原則就是先從小的正則表示式開始,先將單個字元轉為各個小的ε-NFA,再將這些ε-NFA根據組合關係拼湊成完整的ε-NFA。對於單個字元來說,它的ε-NFA很簡單,只有兩個狀態,一個轉換:
concat組合則主要是將兩個ε-NFA用ε轉換連起來:
接下來是或組合:
對於重複組合來說,情況稍微複雜,對於其中'?', '+', '*',我們只需要在子ε-NFA的開始與結束狀態之間加入ε轉換則可,如下所示:
重複一次或0次
重複至少一次
重複任意次
對於擴充套件語法中的指定重複次數,我們可以採取將狀態直接複製的做法,比較暴力,但管用,如:(a){2, 4},我們得到如下ε-NFA
注意其中後三組不同顏色的狀態,它們是從第一組狀態複製過來的。擴充套件的語法裡,還包括如:{0,≌}這樣的重複,我們只要把狀態按最小的重複次數複製一遍,然後和?,+,*一樣加ε轉換就行了:如{2,≌}
正則表示式的語法樹
前面描述了怎麼將小的ε-NFA組合成大的ε-NFA,我們知道,關鍵是先從小的正則表示式開始,但是具體在面對正則表示式時,我們怎麼把一條完整的正則表達拆成小的正則表示式呢?
為了將大正則拆成小正則,我們可以藉助語法樹的幫助,所謂的語法樹在這裡是指這樣的一棵樹,它的內部結點是操作符,節點的子樹則是該操作符的運算元,而葉結點則是具體的符號,在這裡操作符只有三種:或(or), concat, 重複(統一用star表示), ,如:我們可以將(ab)+cd(e|f)轉換為如下一棵語法樹:
顯然對於任意一個內部結點來說,它的左右子樹,就分別代表了一個小的正則表示式,而葉子結點則是最小的,解釋這樣一棵樹顯然簡單多了。至於怎樣構建語法樹,仔細想想,在正則表示式裡,表示式與操作符是右結合的,如:a+, 然後兩個表示式之間要麼是是concat組合,要麼是或組合,所以我們在構造語法樹時,可以考慮從右往左,依次將各個小的表示式,操作符分別抽出來,然後對該小的正則表示式構建語法樹則可。
TreeNode* ConstructSynTree(const char* reg_start, const char* reg_end)
{
const char* right_exp = ExtractExpression(reg_start, reg_end);
int operator = GetOperator(right_exp - 1);
TreeNode* node = CreateInteriorNode(operator);
TreeNode* left_child = ConstructSynTree(reg_start, right_exp - 2);
TreeNode* right_child = ConstructSynTree(right_exp, reg_end);
node->left = left_child;
node->right = right_child;
return node;
}
詳細程式碼可以參考這裡。
部分擴充套件語法的實現
前面講的內容主要是針對基本的正則表示式語法,原理主要來自龍書的介紹,只是在實現上我儘可能減少了ε狀態,因為沒有涉及擴充套件語法,這些演算法實現起來是很簡單的,大概只需要一千行左右程式碼就可以寫出來,而且效率是很高的,只是因為太簡單使用起來不方便,只能玩玩,下面我講一下怎麼實現前面提到幾個擴充套件語法。
首先是關於重複,這個比較簡單,前面已經講了,至於匹配頭(^),匹配尾($),這個實現也比較容易,但我們需要增加一些鋪助的ε狀態:
- 如果正則表示式中存在匹配頭時,則在開始狀態前增加一個ε狀態,該狀態只有一個向外的ε轉換。
- 如果正則表示式中不存在匹配尾時,則增加一個ε狀態,該狀態對任何輸入都轉換為自己。
舉個例子,對於“^a",我們構建出如下ε-NFA:
而至於向後引用,這個語法在現實中是很實用的,因此我才想著要把它加進來,但等到真正實現時,才發現這個功能卻出乎意料的難以實現,根據這篇文件的介紹,正則表示式中向後引用的實現是一個NP完全問題,到目前來說,還沒有發現高效的實現方法,而我面對的問題已經不是高效不高效的問題,而是在一個簡單的ε-NFA狀態機框架上要加入這個功能都是比較痛苦的,至於我現在的實現,已經把原先的狀態機給hack了才做出來,程式碼也寫得很難看了,接下來得再想想看能不能把實現設計的好一點。
要想實現這個向後引用,關鍵在於及時把前面括號裡的正則表示式所捕獲的內容儲存下來,而一般來說,狀態機的狀態本身應該是沒有狀態的,它不應該記住它在前一個狀態做了什麼事情,這些限制都讓實現很為難。
但是為了捕獲括號裡的正則表示式所匹配的內容,我們又必須清楚地知道,當前狀態機是否進入了某個括號的正則表示式對應的狀態,以及什麼時候退出了該括號所對應的狀態,為達到這個效果,我們在構建狀態機時,可以引入兩個特殊的ε狀態,其中一個狀態稱作ε-unit-start,用來表示,下次輸入如果導致當前狀態發生轉換,則需要開始儲存後續的輸入,另一個狀態稱作ε-unit-end,用來表示,如果進入了該狀態,則如果後續輸入導致該狀態被轉換出去,則應該停止儲存後續的輸入。
舉個例子,對於a(bc)e\1,我們得到如下的語法樹及ε-NFA:
語法樹
相應的ε-NFA,注意其中淺色的狀態2和7,這兩狀態分別是上面提到的ε-unit-start和ε-unit-end,當狀態機執行起來進入這兩狀態時,就分別檢查是否該開始儲存輸入,和停止儲存輸入。
上面的想法看起來在簡單情況下比較好處理,但實現起來有很多細節需要注意,因為是ε-NFA,對於每一個輸入,狀態機可能會得到好多個新的狀態,因此:
- 有時我們可能在同一時間進入ε-unit-start和ε-unit-end。
- 有時可能好幾個ε-unit-start與ε-unit-start同時出現。
- 有時還沒有進入ε-unit-start, 卻發現先進入ε-unit-end了。
- 甚至有時進入ε-unit-start後,卻發現永遠都不會進入對應的ε-unit-end了。
這些都需要一一處理好,特別是類似(a*),(a)*, a(cd)*fe這種有重複操作符的表示式,括號裡可能捕獲不到任何內容。
我實現基本的正則表示式,只花了二三天時間,但為了使現這個向後引用,卻反覆修改,二三個星期才搞好。。。
現在程式碼差不多寫好了,有興趣的可以去瞄瞄,先從unit test看起,程式碼可讀性可能不是太好,得做好心理準備,不過話說回來,讀難讀的程式碼才是真的考驗啊。