正則表達式匹配文本

在正則表達式中,匹配是最最基本的操作。使用正則表達式,換種說法就是“用正則表達式去匹配文本”。但這只是廣義的“匹配”,細說起來,廣義的“匹配”又可以分爲兩類:提取和驗證。所以,本篇文章就來專門講講提取和驗證。

提取

提取可以理解爲“用正則表達式遍歷整個字符串,找出能夠匹配的文本”,它主要用來提取需要的數據,常見的任務有:找出文本中的電子郵件地址,找出HTML代碼中的圖片地址、超鏈接地址……提取數據時,首先要注意的,就是準確性。

準確

準確性分爲兩方面:完整精確 。前者是要提取出需要的所有文本,不能漏過;後者是要保證提取的結果中沒有不需要的文本,不可出錯。

爲保證完整,我們需要考慮足夠多的變體,覆蓋所有情況。一般來說,要提取的數據都只有概念的描述(比如,提取一個電子郵件地址,提取一個身份證號),如果沒有拿到完整規範的特徵描述,可能只能憑經驗總結出幾條特徵,然後逐步完善,也就是不斷考慮新的情況,照顧到各種情況。

拿“提取文本中的浮點數字符串”爲例。最容易想到的情況,就是3.14、3999.2、0.36之類,也就是“數字字符串 + 小數點 + 數字字符串”,所以用表達式『\d+\.\d+』,按照我們上一篇文章說過的“與或非”,三個部分都是必須出現的,所以這個表達式似乎是沒問題了。

\d+\.\d+ 

但是有些時候,0.7是寫作.7的,上面的表達式無法照顧這種情況,所以必須修改表達式:整數部分是可能出現也可能不出現的,所以小數點之前的\d+應該改爲\d*,就成了『\d*\.\d+』。

\d*\.\d+ 

但是且慢,浮點數還包括負數,比如-0.7,但現在這個表達式無法匹配最開始的符號,所以還應該改成『-?\d*\.\d+』。

-?\d*\.\d+

但僅僅保證完整性還不夠,提取的另一方面是精確,就是排除掉那些“能夠由正則表達式匹配,但其實並非期望”的字符串,所以我們還需要仔細觀察目前的正則表達式,適當添加限制條件。

仍然用上面的正則表達式作例子,『-?\d*\.\d+』中,『-?』和『\d*』都是可能出現的元素,所以它們可能都不出現,這時候表達式能匹配.7之類,沒有錯;如果只出現了『\d*』能匹配的文本,可以匹配3.14之類,也沒有錯;但是,如果只出現『-?』呢?-.7,通常來說,負的浮點數是應該寫作-0.7的,而-.7顯然是不合法的。所以,這個表達式應該修改爲『(-?\d+|\d*)\.\d+』。

(-?\d+|\d*)\.\d+ 

事情到這裏就完整了嗎?似乎還不是。我們知道有些地方,日期字符串是“2010.12.22”的形式,如果你要處理的文本中不包含這種日期字符串還好,否則,上面的表達式會錯誤匹配2010.12 .22或者2010.12.22 。爲了避免這種情況,我們需要給表達式加上更多的限制。最直接想法就是,限定表達式兩端不能出現點號.,變成『(?!<.)(-?\d+|\d*)\.\d+(?!.)』。

(?!<.)(-?\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)』。

(?!<\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爲例)

String str = "123 45 6"; 
Pattern p = Pattern.compile("\\d+"); 
Matcher m = p.matcher(str); 
while (m.find()) { 
    System.out.println(m.group()); 
} 

如果不是面向對象式處理,無法記錄匹配的狀態信息,則可以手動指定偏移值。多數語言都有辦法在匹配時指定偏移值,也就是“從字符串的offset位置開始嘗試匹配”。如果要逐一獲得所有匹配,每次將偏移值指定爲上一次匹配的結束位置即可。注意,字符串處理時可能有人習慣將偏移值指定爲“上一次匹配的起始位置+1”,但正則表達式處理時這樣是不對的,比如正則表達式是『\d+』,而字符串是"123 45 6",第一次匹配的結果是123,如果把偏移值設定爲“上一次匹配的起始位置+1”,之後的匹配結果就是23,3……。在PHP、JavaScript、Ruby中,通常採用這種辦法。

代碼(以PHP爲例)

$string="123 45 6"; 
$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爲例)

for match in re.finditer("\\d+", "123 45 6") 
print match.group(0) 

驗證

另一類“匹配”是數據驗證,也就是“檢查字符串能否完全 由正則表達式匹配”,它主要用來測試和保證數據的合法性。比如有些網站要求你設定密碼,密碼只能由數字或小寫字母構成,長度在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條明確的規則:

  1. 密碼的長度在6-12個字符之間
  2. 只能由小寫字母、阿拉伯數字、橫線組成
  3. 開頭和結尾不能是橫線
  4. 不能全部是數字
  5. 不容許有連續(2個及以上)的橫線

下面依次列出對應5條規則的表達式:

  1. 密碼長度在6-12個字符之間:其形式類似『.{6, 12}』
  2. 只能由小寫字母、阿拉伯數字、橫線組成:所有的字符都只能由『[0-9A-Za-z-]』匹配
  3. 開頭和結尾不能是橫線:開頭『^(?!-)』,結尾『(?<!-)$』
  4. 不能全部是數字,也就是說必須出現一個『[^0-9]』或者『\D』
  5. 不容許有連續(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條規則的環視表達式依次是:

  1. 密碼長度在6-12個字符之間:『^(?=.{6, 12}$)』
  2. 只能由小寫字母、阿拉伯數字、橫線組成:『^(?=[0-9A-Za-z-]*$)』
  3. 開頭和結尾不能是橫線:『^(?!-).*(?<!-)$』
  4. 不能全部是數字:『^(?=.*[^0-9])』(這裏不需要出現$,只要出現了非數字字符就可以)
  5. 不容許有連續(2個及以上)的橫線:『^(?!.*--)』

下面就是尋找這樣一個文本起始位置,它後面的文本同時滿足這5個條件。實際上,因爲錨點並不真正匹配文本,所以多個錨點可以重疊在一起,因此我們完全可以尋找5個錨點,把它們串聯起來:

『(^(?=.{6, 12}$))(^(?=[0-9A-Za-z-]*$))(^((?!-).*(?<!-)$))(^(?=.*[^0-9])(^(?!.*--))』

意思就是:先尋找這樣一個字符串起始位置,它之後的字符串滿足條件1;然後尋找這樣一個字符串其實位置,它之後的字符串滿足條件2;…… 如果能找到5個這樣的字符串起始位置(實際上,因爲只有一個字符串起始位置,所以這5個位置是重疊的),就算驗證成功。

其實我們也可以不用那麼多的括號,只用一個『^』即可:

『^(?=.{6, 12}$)(?=[0-9A-Za-z-]*$)(?=(?!-).*(?<!-)$)(?=.*[^0-9])(?!.*--)』 

總結

雖然“匹配”是正則表達式的常見操作,但細分起來,“匹配”又可分爲提取和驗證兩種操作。

提取時需要照顧準確性和效率,因爲此時字符串的起始/結束位置是不確定的,應當添加適當的環視結構,避免匹配了不期望的數據。

驗證時對效率的要求並不高,因爲驗證的字符串一般都很短,而且驗證的起始/結束位置都是確定的,直接在字符串兩端添加^和$即可。而且驗證有時候要比提取簡單得多,我們可以改換思路,改“查找文本”爲“查找位置”,針對驗證時容許/不容許出現的每一個條件,寫出對應的環視功能,作爲一個將它們並列在一起。

關於作者

餘晟,程序員,曾任抓蝦網高級顧問,現就職於盛大創新院,感興趣的方向包括搜索和分佈式算法等。翻譯愛好者,譯有《精通正則表達式》(第三版)和《技術領導之路》,目前正在寫作《正則表達式傻瓜書》(暫定名),希望爲國內開發同行貢獻一本實用的正則表達式教程。


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章