前言
正規表示式是軟體領域為數不多的偉大創作。與之相提並論是分組交換網路、Web、Lisp、雜湊演算法、UNIX、編譯技術、關係模型、物件導向等。正則自身簡單、優美、功能強大、妙用無窮。
學習正規表示式,語法並不難,稍微看些例子,多可照葫蘆畫瓢。但三兩篇快餐文章,鮮能理解深刻。再遇又需一番查詢,竹籃打水一場空。不止正則,其他技術點同樣,需要系統的學習。多讀經典書籍,站在巨人肩膀前行。
本文前半部分複述正則基礎知識,覆蓋常見功能結構;次節探討正則匹配原理、解決思路、常用優化。為避免泛泛而談、自以為是,文中理論示例,多引用《精通正規表示式》 等相關書籍。能力有限,邊學邊悟,不求譁眾取寵,但求實實在在。
第一章 正則基礎
正規表示式嚴謹來講,是一種描述字串結構模式的形式化表達方法。起始於數學領域,流行於 Perl 正則引擎。JavaScript 從 ES 3 引入正規表示式,ES 6 擴充套件對正規表示式支援。
字元組
顧名思義,字元組是一組字元,表示在同一位置可能出現的多種字元。語法是在方括號 [ ]
之間列出可能出現的字元。字元組支援 -
範圍表示法、^
排除型字元組。
範圍表示法中,範圍一般由字元對應碼值確定,碼值小的字元在前,碼值大的字元在後(參考ASCII、Unicode
碼錶)。排除型字元組表示,匹配除[ ]
中之外的任意字元,注意仍然需要匹配一個字元。示例如下。
字元組 | 含義 |
---|---|
[ab] | 匹配 a 或 b |
[0-9] | 匹配 0 或 1 或 2 ... 或 9 |
[^ab] | 匹配 除 a、b 任意字元 |
對於常見 [0-9]、[a-z]
等字元組,正規表示式提供簡記形式。
字元組 | 含義 |
---|---|
\d | 表示 [0-9],數字字元 |
\D | 表示 [^0-9],非數字字元 |
\w | 表示 [_0-9a-zA-Z],單詞字元,注意下劃線 |
\W | 表示 [^_0-9a-zA-Z],非單詞字元 |
\s | 表示 [ \t\v\n\r\f],空白符 |
\S | 表示 [^ \t\v\n\r\f],非空白符 |
. | 表示 [^\n\r\u2028\u2029]。萬用字元,匹配除換行符、回車符、行分隔符、段分隔符外任意字元 |
注意上述 \d、\w、\s
匹配規則,是針對 ASCII
編碼而言。對於 Unicode
字元匹配,參照 ES6擴充套件
段落。
量詞
量詞也稱重複,可以匹配固定或非固定長度字元。其常見形式是 {m,n}
,注意逗號之後絕不能有空格,表示連續出現最少 m 次,最多 n 次。
量詞又可分為,匹配優先量詞(貪婪量詞)、忽略優先量詞(惰性量詞)。對於不確定是否要匹配時,匹配優先量詞會嘗試匹配,忽略量詞則選擇不匹配。
示例如下,貪婪正則匹配 abc
, 惰性正則匹配 a
字元。
const greedyRe = /\w+/
const lazyRe = /\w+?/
greedyRe.exec('abc')
lazyRe.exec('abc')
複製程式碼
常見量詞形式:
匹配優先量詞 | 忽略優先量詞 | 含義 |
---|---|---|
{m,n} | {m,n}? | 表示至少出現 m 次,至多 n 次 |
{m,} | {m,}? | 表示至少出現 m 次 |
{m} | {m}? | 表示必須出現 m 次,等價 {m,m} |
? | ?? | 等價 {0,1} |
+ | +? | 等價 {1,} |
* | *? | 等價 {0,} |
這裡需要注意,.
能匹配幾乎所有字元,而 *
表示可以為任意數目,.*
通常用來表示一組任何字元。如果正則中使用 .*
,而又不真正瞭解其中原理,就有可能事與願違。關於 .*
問題,則匹配原理章節後有詳細討論。
括號
括號在正則中主要有三種用途:分組,將相關的元素歸攏,構成單個元素;多選結構,(...|...)
,規定可能出現的多個子表示式;引用分組,儲存子表示式匹配文字,供之後引用。
分組與多選顯而易見,對於引用分組會存在兩種情形:在 JS API 裡引用,在正規表示式裡引用。前者可通過 $數字
提取相應順序分組,後者通過 \數字
形式引用,即反向引用。注意引用不存在的分組時,正則不會報錯,只是匹配反向引用字元本身。例如 \2
就是匹配 "\2"
, "\2"
表示對 "2"
進行轉義。
示例,將 yyyy-mm-dd
時間,替換 mm/dd/yyyy
格式。
const re = /(\d{4})-(\d{2})-(\d{2})/
const str = '2018-01-12'
const result = str.replace(re, '$2/$3/$1')
console.log(result)
// => '01/12/2018'
複製程式碼
示例,利用反向引用匹配成對標籤,如<h1>title</h1>
。(注:未考慮標籤屬性和標籤巢狀情形)
const re = /<([a-zA-Z0-9]+)>.*?<\/\1>/
const str = '<h1>title</h1>'
const result = re.test(str)
console.log(result)
// => true
複製程式碼
事實上,正規表示式只要出現括號,在匹配時就會把括號內的子表示式儲存起來提供引用。如果不需要引用,儲存資料無疑會影響效能。正規表示式提供 非捕獲分組(non-capturing group)。類似於普通捕獲分組,只是在開括號後緊跟問號和冒號 (?:...)
。
錨點與斷言
正規表示式中有些結構並不真正匹配文字,只負責判斷在某個位置左/右側的文字是否符合要求,被稱為錨點。常見錨點有三類:行起始/結束位置、單詞邊界、環視。在 ES5 中共有 6 個錨點。
錨點 | 含義 |
---|---|
^ | 匹配開頭,多行匹配中匹配行開頭 |
$ | 匹配結尾,多行匹配中匹配行結尾 |
\b | 單詞邊界,\w 與 \W 之間位置 |
\B | 非單詞邊界 |
(?=p) | 該位置後面字元要匹配 p |
(?!p) | 該位置後面字元不匹配 p |
需要注意,\b
也包括 \w
與 ^
之間的位置,以及 \w
與 $
之間的位置。如圖所示。
(?=...)
和 (?!...)
分別表示肯定順序環視(positive lookahead
)和否定順序環視(negative lookahead
),也有翻譯為正向先行斷言和負向先行斷言。比如 (?=p)
可以理解成,接下來的字元與 p 匹配,但不包括 p 匹配的字元。
看個典型示例,數字千位新增分隔符。比如把 "12345678" 變成 "12,345,678"。簡單分析可得出,插入位置必須滿足: “左邊有數字,右邊數字的個數正好是3的倍數”。前者用否定順序環視避免開頭位置,後者可用肯定順序環視判斷。
const re = /(?!^)(?=(\d{3})+$)/g
const str = '123456789'
const result = str.replace(re, ',')
console.log(result)
// => '123,456,789'
複製程式碼
修飾符
修飾符是指匹配時使用的模式規則。ES5 中存在三種匹配模式:忽略大小寫模式、多行模式、全域性匹配模式,對應修飾符如下。
修飾符 | 含義 |
---|---|
i | 不區分大小寫匹配 |
m | 允許匹配多行 |
g | 執行全域性匹配 |
更多修飾符細節,多行匹配模式,^
匹配一行的開頭和字串的開頭,$
匹配行的結束和字串的結束。全域性模式,需要找到所有的匹配項,而不是找到第一個後就停止。
ES6擴充套件
ES6 中正規表示式新增 u
修飾符,含義為 “Unicode 模式”,用來正確處理大於 \uFFFF
的 Unicode 字元。也就是可以處理 4 個位元組的 UTF-16 編碼。
/^\uD83D/u.test('\uD83D\uDC2A')
// => false
/^\uD83D/.test('\uD83D\uDC2A')
// => true
複製程式碼
除了 u
修飾符,ES6 還新增 y
修飾符,叫作 “粘連”(sticky)修飾符。y
也是全域性匹配,但會確保匹配必須從剩餘第一個位置開始。
const s = 'aaa_aa_a'
const r1 = /a+/g
const r2 = /a+/y
r1.exec(s) // ['aaa']
r2.exec(s) // ['aaa']
r1.exec(s) // ['aa']
r2.exec(s) // null
複製程式碼
上述在第二次執行,g
修飾符沒有位置要求,會返回匹配結果。而 y
修飾符要求匹配必須從頭部開始,所以返回 null。將正規表示式修改為 /a+_/y
,y
修飾符會返回結果。
ES 規範中也有對 後行斷言 和 具名分組的提案。詳細可參考《ES6 標準入門》第5章。
經典示例
驗證密碼問題
密碼格式要求,長度 6-12 位,由數字、小寫字母和大寫字母組成,但必須至少包括 2 種字元。如果寫成多個正則來判斷,比較容易,單一正則較為麻煩。為節約篇幅,筆者簡短描述解法。
首先判斷長度 6-12 位的三種字元,可用 [0-9a-zA-Z]{6,12}
判斷。至少包括 2 種字元,可以具體分為大小寫字母、數字與小寫字母、數字與大寫字母三種情形。要求必須包含某類字元,可以使用環視 (?=...)
來描述。比如同時包含數字和小寫字母,可用 (?=.*[0-9])(?=.*[a-z])
判斷。相應判斷多種情況,得到正則如下。
/((?=.*[0-9])(?=.*[a-z])|(?=.*[0-9])(?=.*[A-Z])|(?=.*[a-z])(?=.*[A-Z]))^[0-9A-Za-z]{6,12}$/
複製程式碼
另一種解法,至少包括兩種字元,也就是不能全部都是數字,不能全部都是小寫字母,也不能全部都是大寫字母。要求 “不能全部都是數字” 可用順序否定環視,(?!^[0-9]{6-12}$)
。完整正則如下。
/(?!^[0-9]{6,12}$)(?!^[a-z]{6,12}$)(?!^[A-Z]{6,12}$)^[0-9A-Za-z]{6,12}$/
複製程式碼
去除文字首尾空白字元
去除文字首尾空白字元,最簡單也是效率最高的方式是使用兩條正則替換,/^\s+/
和 /\s+$/
。當然也可通過一條正規表示式解決問題,不過效率會有不同。下文列出三兩條作為示例。
const str = ' hello world '
// 解法 0:兩次替換方式
str.replace(/^\s+/, '').replace(/\s+$/, '')
// 解法 1:使用全域性匹配
str.replace(/^\s+|\s$/g, '')
// 解法 2:使用惰性量詞
str.replace(/\s*(.*?)\s*$/, '$1')
// 解法 3:使用貪婪量詞
str.replace(/^\s*((?:.*\S)?)\s*$/, '$1')
複製程式碼
日期字元匹配
以 yyyy-mm-dd
為例,如 “2017-01-17”。時間匹配需要注意的是資料取值範圍:月份只能在 0-12 之間,正則為 (0?[1-9]|1[012])
;日期一般在 0-31 之間,正則為 (0?[1-9]|[12][0-9]|3[01])
;年份多無限制,\d{4}
即可。完整正則如下:。
/\d{4}-(0?[1-9]|1[012])-(0?[1-9]|[12][0-9]|3[01])/
複製程式碼
匹配路徑
檔案路徑區分系統,UNIX 路徑 和 Windows 路徑。UNIX 路徑示例:“/usr/local”,“/usr/bin/node” 。
這裡需要注意的地方是,檔案或目錄名不能包含 “\ / : * % < > | " ?” 、換行符空字元,所以匹配檔名的表示式是 [^\\/:*%<>|"?\r\n]
。路徑內部所有目錄名之後必須有 “/”,所以可以用 [^\\/:*%<>|"?\r\n]+/
匹配。末尾如果是目錄,可能以 “/” 結尾,最後部分為 [^\\/:*%<>|"?\r\n]+/?
。注意 “/” 字元需要轉移,完整正則字面量如下。
/\/([^\/:*%<>|"?\r\n]+\/)*[^\/:*%<>|"?\r\n]+\/?/
複製程式碼
Windows路徑匹配與上同理,開頭需要匹配 “碟符:\”,檔名中不能包含 “\ / : * < > | " ? \n \r” 字元。Windows 路徑名稱示例 “C:\book\regular.pdf”,完整正則如下。
/[a-zA-Z]:\\([^\/:*<>|"?\r\n]+\\)*[^\/:*<>|"?\r\n]+\\?/
複製程式碼
匹配URL
相對來說,匹配URL的表示式比較複雜,主機名、路徑名、引數等部分都有複雜的規範。這裡給出一個相對簡單的正則示例。
/(https?|ftp):\/\/[^/?:]+(:[0-9]{1-5})?(\/?|(\/[^/]+)*(\?[^\s"']+)?)/
複製程式碼
最開始是協議名,可能是 http、https、ftp。然後匹配 “:\”;主機名使用相對簡單的 [^/?:]+
匹配,然後用 (:[0-9]{1,5})?
匹配可能出現的埠部分(埠最大為65536,簡要規定為 5 位數字)。URL 還可能包含路徑以及引數,分別用 (/[^/]+)*
和 (\?[^\s"']+)?
匹配。
匹配成對的HTML Tag
比如匹配 <h3>regular expression</h3>
字串。最常見的辦法就是用 <[^>]+>
匹配 HTML 標籤。如果 tag 中含有 “>” 就不能正常匹配了,例如 <input value=">">
,當然這種情況也很少見。
面對上述情況,可以區分情形。“<...>” 中允許出現引號文字和非引號文字(除 “>” 和 引號之外的任意字元 )。HTML引文可以用單引號,也可以用雙引號,可用 ("[^"]*"|'[^']'*|[^'">])
來匹配除 Tag 名稱以外字元。
對於 HTML 標籤名稱,第一個字元必須是 [a-z]。為匹配完整匹配標籤名稱,用肯定順序環視 (?=[\s>])
保證它之後是空白字元或 “>”。完整表示式如下。
/<([a-z][a-z0-9]*)(?=[\s>])('[^']*'|"[^"]*"|[^'">])*>.*?<\/\1>/
複製程式碼
再多說兩句,上述正規表示式並不支援標籤巢狀情形。例如目標字串 <h1>hello<h1>world</h1></h1>
,實際匹配字元為 <h1>hello<h1>world</h1>
。本質上是因為正規表示式無法匹配任意深度的巢狀結構。
第二章 正則原理
對於固定字串的處理,簡單的字串匹配演算法(類KMP
演算法)相較更快;但如果進行復雜多變的字元處理,正規表示式速度則更勝一籌。那正規表示式具體匹配原理是什麼?打破砂鍋問到底,接下來一探究竟。注意後文會涉及編譯原理相關知識,筆者儘量簡短淺顯描述,如想了解更多可參考文末資料。
有窮自動機
正規表示式引擎實現採用一種特殊理論模型:有窮自動機(Finite Automata
)也叫有限狀態自動機(finite-state machine
)。這種模型具有有限個狀態,可以根據不同條件在狀態之間轉移。對映到正則場景,引擎首先根據表示式生成自動機,而後根據輸入字串在狀態之間遊走,判斷是否匹配。
以正規表示式 a(bb)+a
為例,對應有限狀態自動機如下。
上圖有窮自動機中,S0、S1、...、S4是各個狀態,S0為開始狀態,S4為最終狀態;狀態轉移過程為:當前狀態S0,輸入字元a,則轉移到S1,如果輸入不是a,那麼直接退出。自動機對字串 abbbba
匹配流程如下。
同一個正規表示式對應的有窮自動機不止一臺,這些有窮自動機是等價的。正規表示式 a(bb)+a
對應的所有等價自動機如圖。
根據狀態的確定與否,可以將自動機(正則引擎)分為兩類:一類是確定型有窮自動機(Definite Finite Automata
簡DFA),任何時候所處的狀態都是確定的;另一種是非確定性有窮自動機(Non-definite Finite Automata
簡稱NFA),即某個時刻自動機所處狀態是不確定的。如上圖 NFA 第一個示例,輸入 ab 之後再輸入 b,此時所處狀態是不確定的,可能在 S1,也可能在 S3。
實際上,大多數程式都採用 NFA 引擎,其中也包括 JavaScript
。這是為什麼呢?從正規表示式出發,構建 NFA 的難度要小於 DFA,構建 NFA 的時間更短;再有 NFA 在某一時刻狀態不唯一,這決定在匹配過程中需要儲存可能的狀態,相比之下 DFA 狀態是唯一確定的。故NFA 具有很多 DFA 無法提供的功能,比如捕獲分組 (...)
,反向引用 \num
,環視功能 (?=)
,惰性量詞 +?
、*?
等。後文討論情形也限於 NFA 引擎。
回溯
討論匹配原理,永遠繞不過去 “回溯”。NFA 匹配時,正則引擎並不確定當前狀態,只能將所有可能狀態儲存,逐一嘗試。當一條路走不通時,回溯到先前狀態,選擇其他狀態再次嘗試,如此以往,直到最終完成狀態。不難發現,回溯的過程本質是深度優先搜尋。
以正規表示式 /".*"/
為例,字串 "hello"
匹配過程如下。
從圖中可以看出,在匹配過程中 .*
曾經匹配 hello"
,但為了保證表示式最後一個 "
匹配,不得不回退先前狀態。這種 “嘗試失敗-重新選擇” 的過程就是回溯。假如將字串變為 "hello" world
,回溯次數又將會增加。.*
會優先嚐試匹配剩餘 hello" world
字串,而後不斷回溯。
常見回溯形式
造成回溯主要緣由是 NFA 不能確定當前狀態,需要不斷嘗試前進回溯。常見非確定狀態形式有:貪婪量詞、惰性量詞、分支結構情形。
貪婪量詞
上面的示例其實是貪婪量詞相關,比如 /".*"/
,對於非確定狀態會優先選擇匹配。雖然區域性匹配是貪婪的,但也要滿足整體正確匹配。皮之不存,毛將焉附之理。
惰性量詞
惰性量詞語法上是在貪婪量詞之後加個問號。表示在滿足條件情況下,儘可能少的匹配。雖然惰性量詞不貪,但有時也會出現回溯現象。看個示例。
/^\d+?\d{1,3}$/
複製程式碼
對於目標字串 “12345”,我們很容易能理解:\d+?
首先僅會匹配一個字元,但後續 \d{1,3}$
最多隻能匹配三個字元,此時 $
並不能匹配結尾,當前匹配失敗。後續回溯使 \d+?
匹配兩字元,從而整體匹配。
分支結構
分支結構在 JavaScript
中也是惰性的,比如 /Java|JavaScript/
,匹配 JavaScript
字串時,得到的結果是 Java
。引擎會優先選擇最左端的匹配結果。
分支結構可能前面的子模式形成區域性匹配,如果後面表示式整體不匹配時,仍然會嘗試剩下的分支。相應這種嘗試也可看成一種回溯。
濫用 .* 問題
.
幾乎能匹配任何字元,而 *
又表示 “匹配優先”,所以 .*
經常匹配到文字末尾。當需要整體模式匹配時,只能不斷回溯 “交還” 字串。但有時會出現問題,看個示例。
const re = /".*"/
const str = 'The great "pleasure" in "life" is doing what people say you cannot do'
const result = re.exec(str)
console.log(result[0])
// => "pleasure" in "life"
複製程式碼
瞭解匹配原理之後,結果也就顯而易見。.*
會一直匹配到字串末尾,為了最後雙引號能夠匹配,.*
不斷交還字元直到全域性匹配。但這並不是我們期望的結果,那如何獲得 "pleasure"
字串?
關鍵點在於,我們希望匹配的不是雙引號之間的“任何”文字,而是“除雙引號以外的任何文字”。如果用 [^"]*
取代 .*
,就不會出現上面問題。
再看多字元引文問題,匹配 <b>love</b> is <b>love</b>
標籤 b 中內容。與雙引號字串例子相似,使用 <b>.*</b>
會匹配全部字串。假如僅匹配開頭 b 標籤,<b>[^</b>]*</b>
"排除型字元組"正則顯然不可行。字元組僅排除包括單個字元,而不是一組字元。
解決問題方式有,使用惰性量詞。<b>.*?</b>
正則中 .*?
結構對於可匹配字元優先忽略。但假如字串變為 “<b>love is <b>love</b></b>”,此時上述正則會匹配 “<b>love is <b>love</b>”,可見惰性量詞並不是排除類的完美解決方式。
更優方式,使用排除環視,比如 (?!</b>)
表示只有 “</b>” 不在字串中當前位置時才能成功。把點號替換為 ((?!</?b>).)
得到正規表示式如下:
/<b>((?!</?b>).)*</b>/
複製程式碼
有序多選結構陷阱
有序多選分支結構容許使用者控制期望的匹配,但也有時也會不明就裡出些問題。假如需要匹配 “Jan 31” 之類的日期,首先需要將日期部分拆開。用 0?[1-9]
匹配可能以 0 開頭的前九天,用 [12][0-9]
處理十號到二十九號,用 3[01]
處理最後兩天。連線起來就是。
/Jan (0?[1-9]|[12][0-9]|3[01])/
複製程式碼
但假如以上述表示式匹配 “Jan 31”,有序多選分支只會捕獲 “Jan 3”。其實這裡主要是因為,引擎是以優先選擇最左端的匹配結果為原則。(0?[1-9])
分支可以匹配 3
,後續分支不會繼續嘗試。
需要重新安排多選結構的順序,把能夠匹配的數字最短的放到最後:Jan ([12][0-9]|3[0-1]|0?[1-9])
。通過示例可以看到,如果多選分支是有序的,而能夠匹配同樣文字的分支不止一個,就要小心安排多選分支的先後順序。
避免“指數級”匹配
舉個簡短栗子,假如存在正規表示式 ([^\\"]+)*
,匹配非引號非轉義字元(詳情見《精通正規表示式》P227)。目標字串 makudonarudo
,星號可能會迭代 12 次,每次迭代中 [^\\"]
匹配一個字元,星號還可能迭代 11 次,前 11次 迭代中 [^\\"]
匹配一個字元,最後匹配兩個字元,或者 10、9、8 次等等。
多個量詞修飾同一分組,間接相當於指數級匹配時間複雜度。上例中,長度為 12 的字串存在 4096 種可能(2的12次方)。除DFA 和 傳統型NFA 引擎外,還有一種 POSIX NFA 引擎。其和傳統型NFA引擎主要差別在於,傳統型NFA在遇到第一個完整匹配時會停止。而 POSIX NFA 會嘗試匹配所有情形,尋找最長匹配字元。並且不難想象,如果沒有完整匹配,即使傳統型NFA也需要嘗試所有可能。所以儘可能避免 *、+
量詞巢狀情形。
如何從表示式角度避免指數級匹配,內容較為複雜,犧牲可讀性和可維護性。筆者這裡不再搬運文字,詳情可閱讀《精通正規表示式》P261,消除迴圈章節。
常識性優化措施
-
使用非捕獲型括號
如果不需要引用括號內文字,請使用非捕獲型括號
(?:...)
。這樣不但能夠節省捕獲的時間,而且會減少回溯使用的狀態數量。 -
消除不必要括號
非必要括號有時會阻止引擎優化。比如,除非需要知道
.*
匹配的最後一個字元,否則請不要使用(.)*
。 -
不要濫用字元組
避免單個字元的字元組。例如
[.]
或[*]
,可以通過轉義轉換為\.
和\*\
。 -
使用起始錨點
除非特殊情況,否則以
.*
開頭的正規表示式都應該在最前面新增^
。如果表示式在字串的開頭不能匹配,顯然在其他位置也不能匹配。 -
從量詞中提取必須元素
用
xx*
替代x+
能夠保留匹配必須的 “x”。同樣道理,-{5,7}
可以寫作-----{0,2}
。 -
提取多選結構開頭的必須元素
用
th(?:is|at)
替代(?:this|that)
,就能暴露除必須的 “th”。 -
忽略優先還是匹配優先?
通常,使用忽略優先量詞(惰性)還是匹配優先量詞(貪婪)取決於正規表示式的具體需求。舉例來說,
/^.*:/
不同於^.*?:
,因為前者匹配到最後的冒號,而後者匹配到第一個冒號。總的來說,如果目標字串很長,冒號會比較接近字串的開頭,就是用忽略優先。如果在接近字串末尾位置,就是用匹配優先量詞。 -
拆分正規表示式
有時候,應用多個小正規表示式的速度比單個正則要快的多。“大而全”的正規表示式必須在目標文字中的每個位置測試所有表示式,效率較為低下。典型例子可以參考前文, 去除字串開頭和結尾空白。
-
將最可能匹配的多選分支放在前頭
多選分支的擺放順序非常重要,上文有提及。總的來說,將常見匹配分支前置,有可能獲得更迅速更常見的匹配。
-
避免指數級匹配
從正規表示式角度避免指數級匹配,應儘可能減少
+ *
量詞疊加,比如([^\\"]+)*
。從而減少可能匹配情形,加快匹配速度。
這裡簡述的僅是常見優化措施,當然在提高匹配效率時也需要平衡正規表示式的可讀性。不僅是效率,假如編寫正規表示式晦澀難懂,維護成本相應會也加重。正規表示式可讀性、可維護性也是很重要的一方面。
正則引擎
本節整體描述從正規表示式到DNA的構建過程。限於筆者本身能力有限,在此僅簡單描述正則編譯流程,以及過程中用到的演算法資料結構。具體細節,筆者也未曾實踐,就不再信口胡言。下文主要參考 編譯原理 課程視訊。
簡短回顧正則引擎工作流程,接受一條宣告式的規範(正規表示式),通過詞法分析自動生成器,生成詞法分析程式碼。如下圖所示,自動生成器將正規表示式變成 NFA,再轉換成 DFA 資料結構(DFA引擎),再到詞法分析演算法程式碼。
RE轉換NFA,從正規表示式到非確定有限狀態自動機,主要採用 Thompson 演算法。Thompson演算法基於對正規表示式的結構做歸納,對基本的RE 直接構造,對複合的 RE 遞迴構造。遞迴演算法,比較容易實現。
NFA轉換DFA,從非確定有限狀態自動機到確定有限狀態自動機,主要採用 子集構造演算法。DFA最小化,目的希望DFA儘可能小,效率儘可能高。一般採用 Hopcroft 最小化演算法。
結語
正規表示式尤其複雜規則,初看頭昏腦漲。不過熟悉基本結構,拆分不同模組,也就慢慢理解。
囉裡囉嗦,拙文一篇。看完書記錄下,希望時間匆匆而過,回首有可回味之地。此篇文章以筆者目前功力,寫起來有些趕鴨子上架強人所難。但愧知識短淺,誠恐貽笑大方。不過另一方面來說,不斷挑戰自我,逃離舒適區,也算是一種成長。
多說幾句,近來發現自己,一起筆就有種老氣橫秋的趕腳。用老話說,少年不識愁滋味,為賦新詞強說愁。這可能與筆者的性格有關,不喜鬧,偏安靜。每篇文章竭盡向正經八股文靠攏,不希望文字中透露幼稚氣。文章自己慢慢讀來,也會發現有些地方不自然,刻意為之,後續儘量提高。
筆者能力有限,行文難免有所疏忽,如有誤導,敬請斧正。
參考文件
-
《精通正規表示式(第三版)》
-
《JavaScript權威指南》(第10章)
-
《正則指引》
-
《正規表示式必知必會》
打個廣告,歡迎關注筆者公眾號