正則表達式(五):淺談兩種匹配操作
在正則表達式中,匹配是最最基本的操作。使用正則表達式,換種說法就是“用正則表達式去匹配文本”。但這只是廣義的“匹配”,細說起來,廣義的“匹配”又可以分為兩類:提取和驗證。所以,本篇文章就來專門講講提取和驗證。
提取
提取可以理解為“用正則表達式遍歷整個字符串,找出能夠匹配的文本”,它主要用來提取需要的數據,常見的任務有:找出文本中的電子郵件地址,找出HTML代碼中的圖片地址、超鏈接地址……提取數據時,首先要注意的,就是準確性。
準確
準確性分為兩方面:完整和精確。前者是要提取出需要的所有文本,不能漏過;后者是要保證提取的結果中沒有不需要的文本,不可出錯。
為保證完整,我們需要考慮足夠多的變體,覆蓋所有情況。一般來說,要提取的數據都只有概念的描述(比如,提取一個電子郵件地址,提取一個身份證號),如果沒有拿到完整規范的特征描述,可能只能憑經驗總結出幾條特征,然后逐步完善,也就是不斷考慮新的情況,照顧到各種情況。
拿“提取文本中的浮點數字符串”為例。最容易想到的情況,就是3.14、3999.2、0.36之類,也就是“數字字符串 + 小數點 + 數字字符串”,所以用表達式『\d+\.\d+』,按照我們上一篇文章說過的“與或非”,三個部分都是必須出現的,所以這個表達式似乎是沒問題了。
但是有些時候,0.7是寫作.7的,上面的表達式無法照顧這種情況,所以必須修改表達式:整數部分是可能出現也可能不出現的,所以小數點之前的\d+應該改為\d*,就成了『\d*\.\d+』。
但是且慢,浮點數還包括負數,比如-0.7,但現在這個表達式無法匹配最開始的符號,所以還應該改成『-?\d*\.\d+』。
但僅僅保證完整性還不夠,提取的另一方面是精確,就是排除掉那些“能夠由正則表達式匹配,但其實并非期望”的字符串,所以我們還需要仔細觀察目前的正則表達式,適當添加限制條件。
仍然用上面的正則表達式作例子,『-?\d*\.\d+』中,『-?』和『\d*』都是可能出現的元素,所以它們可能都不出現,這時候表達式能匹配.7之類,沒有錯;如果只出現了『\d*』能匹配的文本,可以匹配3.14之類,也沒有錯;但是,如果只出現『-?』呢?-.7,通常來說,負的浮點數是應該寫作-0.7的,而-.7顯然是不合法的。所以,這個表達式應該修改為『(-?\d+|\d*)\.\d+』。
事情到這里就完整了嗎?似乎還不是。我們知道有些地方,日期字符串是“2010.12.22”的形式,如果你要處理的文本中不包含這種日期字符串還好,否則,上面的表達式會錯誤匹配2010.12.22或者2010.12.22。為了避免這種情況,我們需要給表達式加上更多的限制。最直接想法就是,限定表達式兩端不能出現點號.,變成『(?!<.)(-?\d+|\d*)\.\d+(?!.)』。
這樣確實避免了2010.12.22的錯誤匹配,但它也造成了新的問題,比如“…the value of π is 3.14. Therefore…”,3.14本來是我們需要提取的浮點數,但加上這個限制之后,因為3.14之后的有一個作為英文句號使用的點號,所以3.14無法匹配。仔細觀察我們要排除的2010.12.22這類字符串,我們發現點號.的另一端仍然是數字,而用作句號的點號,另一端必定不是數字(一般是空白字符,或者就是字符串的開頭/末尾),所以應當把限制條件表達的更精確些,變為『(?!<\d.)(-?\d+|\d*)\.\d+(?!.\d)』。
好了,關于浮點數的匹配就講到這里。回過頭想想得到最后的這個表達式,我們發現,如果要用正則表達式匹配,必須兼顧完整和精確,通常的做法就像這個例子中的一樣:先逐步放寬限制,保證完整;再添加若干限制,保證精確。
效率
提取數據時還有一點需要注意,就是效率。有時要處理的文本非常長,即便進行簡單的字符串查找都很費力,更不用說可能出現各種變體的正則表達式了。這時候就應當盡量減少“變化”的范圍。比如知道文本中只包含一個雙引號字符串,希望將它提取出來,正則表達式寫成了『".*"』。在文本不長時這樣還可以接受,如果文本很長,『.*』這類子表達式就會導致大量的回溯,因為『.*』的匹配過程是這樣的:
觀察匹配過程就會發現,如果字符串很長,而引號字符串又出現在比較靠前的位置,比如"quoted string" and long long long text…,匹配時就需要進行大量的回溯操作,嚴重影響效率。如果這種問題并不是任何情況下都可能發生,但效率確實非常重要的,如果正則表達式編寫不當,可以產生極為嚴重的影響,比如ReDos(正則表達式拒絕服務),具體情況可以參考http://en.wikipedia.org/wiki/ReDoS。
另一方面,正則表達式提取的效率,不僅與正則表達式本身有關,也與調用的API有關。如果文本很大,要提取出的結果很多,集中到一次操作進行,就可能影響性能,所以條件容許(比如只需要逐步提取出來,依次處理),就可以“逐步進行”,下面的表格列出了常用語言中的提取操作。
語言 |
方法 |
備注 |
Java |
Matcher.find() |
只能逐步進行 |
PHP |
preg_match(regex, string, result) |
逐步進行 |
preg_match_all(regex, string, result) |
一次性進行 |
|
.NET |
Regex.match(string) |
逐次進行 |
Regex.matches(string, regex) |
一次性進行 |
|
Python |
re.find(regex, string) |
逐步進行 |
re.finditer(regex, string) |
逐步進行 |
|
re.findall(regex, string) |
一次性進行 |
|
Ruby |
Regexp.match(text) |
只能找到第一次匹配 |
string.index(Regexp, int) |
逐步進行 |
|
string.scan(Regexp) |
一次性進行 |
|
JavaScript |
RegExp.exec(string) |
一次性進行 |
string.match(RegExp) |
一次性進行 |
一次性提取所有匹配結果的操作這里不多說,我們要補充講解的是,在“逐步進行”時,如何真正保證“逐步”?或者說,在第二次調用匹配時,如何保證是“承接”第一次調用,找到下一個匹配結果。通常的做法有幾種,以下分別介紹。例子統一使用字符串為"123 45 6",查找其中的數字字符串,依次輸出123、45、6。
如果采用的是面向對象式處理,表示匹配結果的對象,可能可以“記住”匹配的位置,下次調用時自動“繼續”,Java就是這樣,循環調用Matcher.find()方法,就可以逐個獲得所有匹配,在.NET中,是循環調用Match.NextMatch()。
代碼(以Java為例)
如果不是面向對象式處理,無法記錄匹配的狀態信息,則可以手動指定偏移值。多數語言都有辦法在匹配時指定偏移值,也就是“從字符串的offset位置開始嘗試匹配”。如果要逐一獲得所有匹配,每次將偏移值指定為上一次匹配的結束位置即可。注意,字符串處理時可能有人習慣將偏移值指定為“上一次匹配的起始位置+1”,但正則表達式處理時這樣是不對的,比如正則表達式是『\d+』,而字符串是"123 45 6",第一次匹配的結果是123,如果把偏移值設定為“上一次匹配的起始位置+1”,之后的匹配結果就是23,3……。在PHP、JavaScript、Ruby中,通常采用這種辦法。
代碼(以PHP為例)
$regex="/\\d+/";
$matched = 1;
$oneMatch=array();
$lastOffset = 0;
$matched = preg_match($regex, $string, $oneMatch, PREG_OFFSET_CAPTURE, $lastOffset);
while ($matched == 1) {
$lastOffset = $oneMatch[0][1] + strlen($oneMatch[0][0]);
echo $oneMatch[0][0]."<br />";
$matched = preg_match($regex, $string, $oneMatch, PREG_OFFSET_CAPTURE, $lastOffset);
}
第3種辦法是使用迭代器,Python的re.finditer()會得到一個迭代器,每次調用next(),就會獲得下一次匹配的結果。這種辦法目前只有Python提供,其它語言尚不具備。
代碼(以Python為例)
驗證
另一類“匹配”是數據驗證,也就是“檢查字符串能否完全由正則表達式匹配”,它主要用來測試和保證數據的合法性。比如有些網站要求你設定密碼,密碼只能由數字或小寫字母構成,長度在6到12個字符之間,如果輸入的密碼不符合條件,則會提示你修改,這個任務,一般使用JavaScript的正則表達式來完成。
初看起來,這也是用正則表達式在字符串中查找匹配文本。但仔細想想,兩者又不一樣:一般來說,提取時正則表達式匹配的開始/結束位置都是不確定的,需要逐次試錯,才能決定;驗證時,同樣需要考慮準確性,但效率并不是重點考慮的因素(一把驗證的文本是用戶名、手機號、密碼之類,不會太長),雖然也要求準確性,但匹配的開始/結束位置都是確定的,只要從文本的開頭驗證即可,不用反復推進-嘗試;而且只要發現任何一個“硬性”條件無法滿足(比如長度、錨點),即可失敗退出。
正因為驗證操作有這些特點,有些語言中提供了專門的方法進行正則表達式驗證。如果沒有,我們也可以使用簡單的查找功能,只是在正則表達式的首尾加上匹配字符串起始/結束位置的錨點來定位,這樣既保證表達式匹配的是整個字符串,也可以在無法匹配時盡早判斷失敗退出。
常見語言中的驗證方法
語言 |
驗證方法 |
備注 |
Java |
String.matches(regex) |
專用于驗證,返回boolean值,不需要『^』和『$』 |
PHP |
preg_match(regex, string) != 0 |
preg_match返回匹配成功的次數,需要『^』和『$』 |
.NET |
Regex.IsMatch(string, regex) |
專用于驗證,返回boolean值,不需要『^』和『$』 |
Python |
re.search(regex, string) != None |
成功則返回True,否則返回False,需要『^』和『$』 |
re.match(regex, string) != None |
成功則返回True,否則返回False,需要『$』 |
|
Ruby |
Regexp.match(text) != nil |
Regexp.match(text)返回匹配成功的起始位置,若無法匹配則返回nil,需要『^』和『$』 |
JavaScript |
Regexp.test(string) |
專用于驗證,返回boolean值,需要『^』和『$』 |
前面說過,在驗證時,文本的開始/結束位置是預先知道的,所以驗證的表達式編寫起來更加簡單。比如之前匹配浮點數的表達式,我們首先得到的是『(-?\d+|\d*)\.\d+』,在進行數據提取時,需要在兩端加上環視,防止錯誤匹配其它字符;但是如果是驗證浮點數,就不需要考慮兩端的環視,應該/不應該出現什么字符,直接在首尾加上『^』和『$』即可,所以驗證用的表達式是『^(-?\d+|\d*)\.\d+$』。
我們甚至可以簡單將各個條件疊加起來,直接得到最后的表達式,比如下面這個例子:
需要驗證密碼字符串,前期的分析總結出5條明確的規則:
- 密碼的長度在6-12個字符之間
- 只能由小寫字母、阿拉伯數字、橫線組成
- 開頭和結尾不能是橫線
- 不能全部是數字
- 不容許有連續(2個及以上)的橫線
下面依次列出對應5條規則的表達式:
- 密碼長度在6-12個字符之間:其形式類似『.{6, 12}』
- 只能由小寫字母、阿拉伯數字、橫線組成:所有的字符都只能由『[0-9A-Za-z-]』匹配
- 開頭和結尾不能是橫線:開頭『^(?!-)』,結尾『(?<!-)$』
- 不能全部是數字,也就是說必須出現一個『[^0-9]』或者『\D』
- 不容許有連續(2個及以上)的橫線,也就是說不能出現『--』
如果用來提取數據,就必須把這5條規則糅合到一起。前3條規則比較好辦,可以合并為『^(?!-)[0-9A-Za-z-]{6,12}(?<!-)$』,但它與第4和第5個條件合并都不簡單。
與第4條規則合并的難點在于,我們無法確定這個『[^0-9]』出現的位置,如果簡單改為『^(?!-)[0-9A-Za-z-]{6,12}[^0-9][0-9A-Za-z-]{6,12}(?<!-)$』,看似正確,卻無法保證整個字符串的長度在6-12之間——目前這個表達式的長度在13(6+1+6)到25(12+1+12)之間。這顯然有問題,但照這個方式也確實無法保證整個字符串的長度,因為我們無法跨越『[^0-9]』,為兩端『[0-9A-Za-z-]』的量詞建立關聯,讓它們的和為5-11之間。同樣,與第5條規則的合并也存在這類問題,因為我們無法確認『--』的出現位置。
看起來,把這5條規則糅合成一個正則表達式,找到能夠匹配的文本,真不是件容易的事情。不過,如果我們要做的只是驗證,不妨換個思路:我們要匹配的并不是所有的文本,而是文本的開始位置,它后面的文本滿足5個條件,而每個條件都可以不用實際匹配任何文本,而用環視來滿足。
對應5條規則的環視表達式依次是:
- 密碼長度在6-12個字符之間:『^(?=.{6, 12}$)』
- 只能由小寫字母、阿拉伯數字、橫線組成:『^(?=[0-9A-Za-z-]*$)』
- 開頭和結尾不能是橫線:『^(?!-).*(?<!-)$』
- 不能全部是數字:『^(?=.*[^0-9])』(這里不需要出現$,只要出現了非數字字符就可以)
- 不容許有連續(2個及以上)的橫線:『^(?!.*--)』
下面就是尋找這樣一個文本起始位置,它后面的文本同時滿足這5個條件。實際上,因為錨點并不真正匹配文本,所以多個錨點可以重疊在一起,因此我們完全可以尋找5個錨點,把它們串聯起來:
『(^(?=.{6, 12}$))(^(?=[0-9A-Za-z-]*$))(^((?!-).*(?<!-)$))(^(?=.*[^0-9])(^(?!.*--))』
意思就是:先尋找這樣一個字符串起始位置,它之后的字符串滿足條件1;然后尋找這樣一個字符串其實位置,它之后的字符串滿足條件2;…… 如果能找到5個這樣的字符串起始位置(實際上,因為只有一個字符串起始位置,所以這5個位置是重疊的),就算驗證成功。
其實我們也可以不用那么多的括號,只用一個『^』即可:
『^(?=.{6, 12}$)(?=[0-9A-Za-z-]*$)(?=(?!-).*(?<!-)$)(?=.*[^0-9])(?!.*--)』
總結
雖然“匹配”是正則表達式的常見操作,但細分起來,“匹配”又可分為提取和驗證兩種操作。
提取時需要照顧準確性和效率,因為此時字符串的起始/結束位置是不確定的,應當添加適當的環視結構,避免匹配了不期望的數據。
驗證時對效率的要求并不高,因為驗證的字符串一般都很短,而且驗證的起始/結束位置都是確定的,直接在字符串兩端添加^和$即可。而且驗證有時候要比提取簡單得多,我們可以改換思路,改“查找文本”為“查找位置”,針對驗證時容許/不容許出現的每一個條件,寫出對應的環視功能,作為一個將它們并列在一起。
關于作者
余晟,程序員,曾任抓蝦網高級顧問,現就職于盛大創新院,感興趣的方向包括搜索和分布式算法等。翻譯愛好者,譯有《精通正則表達式》(第三版)和《技術領導之路》,目前正在寫作《正則表達式傻瓜書》(暫定名),希望為國內開發同行貢獻一本實用的正則表達式教程。