在《深入理解正則表達式高級教程-環視》中已經對環視做了簡單的介紹,但是,可能還有一些讀者比較迷惑,今天特意以專題的形式,深入探討一下正則表達式的環視的概念與用法。
一、環視的概念
環視,在不同的地方又稱之為零寬斷言,簡稱斷言。
環視強調的是它所在的位置,前面或者后面,必須滿足環視表達式中的匹配情況,才能匹配成功。
環視可以認為是虛擬加入到它所在位置的附加判斷條件,並不消耗正則的匹配字符。
(一)環視概念與匹配過程示例
示例一:簡單環視匹配過程
例如,對於源字符串ABC
,正則(?=A)[A-Z]
匹配的是:
1. (?=A)
所在的位置,后面是A
2. 表達式[A-Z]
匹配A-Z
中任意一個字母
根據兩個的先后位置關系,組合在一起,那就是: (?=A)
所在的位置,后面是A
,而且是A-Z
中任意一個字母,因此,上面正則表達式匹配一個大寫字母A
。
從例子可以看出,從左到右,正則分別匹配了環視(?=A)
和[A-Z]
,由於環視不消耗正則的匹配字符,因此,[A-Z]
還能對A
進行匹配,並得到結果。
(二)什么是消耗正則的匹配字符?
在《深入理解正則表達式高級教程》里我們已經講過,正則是按照單個字符來進行匹配的,一般情況下是從左到右,逐個匹配源字符串中的內容。
示例二:一次匹配消耗匹配字符匹配過程
例如,對於源字符串ABCAD
,正則A[A-Z]
匹配的過程是:
1. 正則A
:因為沒有位置限定,因此是從源字符串開始位置開始,也就是正則里的^
,這個^
是虛擬字符,表示匹配字符串開始位置,也就是源字符串ABCAD
里的A
前面的位置,因為正則A
能夠匹配源字符串A
,匹配成功,匹配位置從源字符串^
的位置后移一位,到達A
后面,即此時源字符串ABCAD
的A
這個字符已經被消耗,接下來的正則匹配從A
后面開始。
2. 正則[A-Z]
:當前匹配位置為第一個A
字母后面位置,正則[A-Z]
對源字符串ABCAD
里的B
字母進行匹配,匹配成功,位置后移到B
字母后面的位置。至此,由於正則已經匹配完成,因此,正則A[A-Z]
匹配結果是AB
。
我們知道,有些語言如js支持g
模式修飾符,也就是全局匹配,那么,上面例子中,正則匹配1次成功之后,將會從匹配成功位置(B
字母后面位置)開始,再從頭進行匹配一次正則,直到源字符串全部消耗完為止。
示例三:多次匹配消耗匹配字符匹配過程
因此,全局匹配的過程補充如下:
3. 正則A
:當前匹配位置為B
字母后面位置,正則A
去匹配源字符串中的C
,匹配失敗,匹配位置后移一位,此時C
被消耗了。
4. 正則A
:當前匹配位置為C
字母后面位置,正則A
去匹配源字符串中的第二個A
字母,匹配成功,匹配位置后移一位,此時A
被消耗了。
5. 正則[A-Z]
:當前匹配位置為第二個A
字母后面位置,正則[A-Z]
對源字符串ABCAD
里的D
字母進行匹配,匹配成功,位置后移到D
字母后面的位置,此時D
被消耗了。
6. 由於正則里還有個源字符串結束位置,也就是正則里的$
,這個$
也是虛擬字符,因此,還要繼續進行匹配:
正則A
:當前匹配位置為D
字母后面的位置,正則A
去匹配源字符串的結束位置,匹配失敗,匹配結束。
最終匹配結果是AB
和AD
。
二、環視的類型
環視的類型有兩類:
(一)肯定和否定
1、肯定:(?=exp)
和 (?<=exp)
2、否定:(?!exp)
和 (?<!exp)
(二)順序和逆序
1、順序:(?=exp)
和 (?!exp)
2、逆序:(?<=exp)
和 (?<!exp)
· 兩種類型名稱組合
1、肯定順序:(?=exp)
2、否定順序:(?!exp)
3、肯定逆序:(?<=exp)
4、否定逆序:(?<!exp)
· 四種組合的用法
四種組合,根據正則與環視位置的不同,又可以組合出來8種不同的擺放方式。
一般來說,順序的環視,放在正則后面,認為是常規用法,而放在正則前面,對正則本身的匹配起到了限制,則認為是變種的用法。
而逆序的環視,常規用法是環視放在正則前面,變種用法是放在正則后面。
總結一句話就是:常規用法,環視不對正則本身做限制。
但是,無論常規和變種,都是非常常見的用法。
四種組合正則與環視的擺放位置
1、肯定順序常規: [a-z]+(?=;) 字母序列后面跟着;
2、肯定順序變種: (?=[a-z]+$).+$ 字母序列
3、肯定逆序常規: (?<=:)[0-9]+ :后面的數字
4、肯定逆序變種: \b[0-9]\b(?<=[13579]) 0-9中的奇數
5、否定順序常規: [a-z]+\b(?!;) 不以;結尾的字母序列
6、否定順序變種: (?!.*?[lo0])\b[a-z0-9]+\b 不包含l/o/0的字母數字系列
7、否定逆序常規: (?<!age)=([0-9]+) 參數名不為age的數據
8、否定逆序變種: \b[a-z]+(?<!z)\b 不以z結尾的單詞
下面示例,僅對肯定順序環視進行兩種用法的講解,其他組合都有類似用法,讀者參考上面列舉8種位置用法自行測試。
1、肯定順序:(?=exp)
(1)常規用法
所謂常規用法,主要指正則匹配部分位於肯定順序環視左側,如:test(?=\.php)
,用於匹配后綴是.php
的test文件。
示例四:肯定順序環視常規用法
源字符串:
notexefile1.txt
exefile1.exe
exefile2.exe
exefile3.exe
notexefile2.php
notexefile3.sh
需求:獲取.exe
后綴文件不含后綴的文件名
正則:.+(?=\.exe)
結果:
exefile1
exefile2
exefile3
示例中,因為要獲取.exe
后綴不含后綴的文件名,因此,在不使用分組進行捕獲的時候,我們利用了肯定順序型環視的限定,達到了既限定為.exe
后綴又不被捕獲進匹配結果的效果,充分展示了環視不占位的特性。
(2)變種用法
所謂變種用法,主要指正則匹配部分位於肯定順序環視右側,匹配內容收到環視條件的限定,如:^(?=[a-z]+$).+
,雖然后面用的是.+
(.
除了不能匹配換行,能匹配任意字符),但是,這個表達式只能匹配一個以上的a-z
字母組合,因為它被前面的環視限制了匹配范圍。
示例五:肯定順序環視變種用法
需求:必須包含字母(不區分大小寫)、數字,6-16位密碼
正則:^(?=.*?[a-zA-Z])(?=.*?[0-9])[a-zA-Z0-9]{6,16}$
測試用例:
#量詞條件:
1. 小於6
2. 6-16(關注邊界值)
3. 大於16
#字符條件:
1. 純數字
2. 純英文
3. 數字+英文
4. 英文+數字
5. 英文數字亂序混合
注:每類字符條件都要考慮量詞條件
示例中,使用(?=.*?[a-zA-Z])
限定后面的字符中至少有一個字母,使用(?=.*?[0-9])
限定后面的字符中至少有一個數字,最后通過實際匹配正則[a-zA-Z0-9]{6,16}
限定量詞。此示例,同樣提現了環視不占位的特性,否則的話,第一個環視消耗完字符,會導致后面匹配失敗,而實際並沒有,因為環視不消耗匹配字符。
2、否定順序:(?!exp)
示例六:否定順序環視
源字符串:
notexefile1.txt
exefile1.exe
exefile2.exe
exefile3.exe
notexefile2.php
notexefile3.sh
需求:獲取不是.exe
后綴文件不含后綴的文件名
正則:(.+)(?!\.exe)\.[^.]+$
結果:
notexefile1
notexefile2
首先,拿到這個需求,看過前面肯定順序環視例子的寫法,我們很可能一下子寫出來.+(?!\.exe)
,但是測試之后卻發現,錯了!為什么?一萬個為什么飄過~~~
為什么匹配錯誤,這涉及到正則匹配的原理,匹配過程如下:
為了解釋方便,這里以多行模式進行講解。
正則.+
:因為沒有指定位置,從每行字符串開始位置開始匹配,.+
是貪婪模式,盡可能多匹配,而且是匹配除了換行外的任意字符,因此,以第一行為例,.+
匹配到notexefile1.txt
,匹配位置移動到字符串最后。
正則(?!\.exe)
:匹配字符串結束位置,不是.exe
,成功,匹配結束。
匹配結果得到:notexefile1.txt
其他幾行匹配過程是類似的,我們發現每行它都匹配上了,這不是我們預期的結果。
為了得到預期的結果,我們需要在環視限定的條件下,把后綴部分消耗掉,同時利用否定順序環視限定其不能是.exe
后綴,然后用分組獲取文件名,得到表達式:(.+)(?!\.exe)\.[^.]+$
。這個表達式的匹配過程,跟上面其實是類似的,只不過因為表達式沒有匹配完成,導致了回溯,回溯讓出了后綴部分給\.[^.]+
去匹配了。
在寫這個正則的過程中,我們可以先寫出(.+)\.[^.]+$
這樣的正則,然后在再后綴位置插入環視限定,從而得到目標正則(.+)(?!\.exe)\.[^.]+$
。
由於回溯過程涉及步驟過多,這里就不做展開,后面有機會再寫一個關於正則回溯的文章,現在大家可以打開這個否定順序匹配與回溯演示頁,分別查看3個版本的debug情況。
選擇版本:在正則輸入框上面的下拉菜單里
查看debug:左側TOOLS區域的Regex Debugger
菜單。
注:由於該站jquery引用自谷歌,因此需要翻牆加載才可以打開
當然也可以用Regexbuddy的Debug功能,這個可以參考《正則表達式工具RegexBuddy使用教程》查看Debug用法。
三個版本的正則都是(.+)(?!\.exe)\.[^.]+$
源字符串分別是:
1. 測試示例六,使用示例六源字符串
2. 測試匹配成功情況回溯,源字符串
notexefile1.txt
3. 測試匹配失敗情況回溯,源字符串
exefile1.exe
3、肯定逆序:(?<=exp)
(1)肯定逆序環視和否定逆序環視在一些語言中是不支持的,如JavaScript就不支持,大家在使用過程中需要注意一下。
(2)很多語言不支持非確定長度的逆序環視。所謂非確定長度,是指逆序環視部分內容,不是固定長度的,如(?<=.*;)abc
,這里用的.*
就是不固定的長度。無論是分支情況還是什么,逆序環視部分需要固定長度。
(3)有些語言里,支持特定范圍的非確定長度,這個是指(?<=.{0,100};)abc
這種,本來的.*
使用0-100
這樣的限定最大長度為100的范圍值。
因此,大家使用過程中可以根據自己使用語言的差異,測試使用。
示例七:肯定逆序環視
源字符串:
name=Zjmainstay
age=26
需求:獲取name參數的值
正則:(?<=name=).+
示例很直白,前面必須是name=
,然后獲取其后面的數據,由於環視不占位,因此並沒有出現在匹配結果中。
4、否定逆序:(?<!exp)
示例八:否逆序環視
源字符串:
name=Zjmainstay
age=26
需求:獲取不是name參數的值
正則:^[^=]+=(?<!name=)(.+)
跟否定順序示例一樣,我們不能直接用(?<!name=).+
進行匹配,正則做法是先把參數部分匹配出來,再用否定逆序環視對它進行限定,限定它不能是name=
,因此實現匹配。
講到這里,你們是否能想到前面否定順序示例六中,可以用否定逆序來做?
正則:(.+)\.[^.]+(?<!\.exe)$
因此,幾個環視組合,由於正則所擺放的位置不同,可以產生等價的效果。
三、環視的應用
環視一直是正則表達式使用過程中的難題,主要體現在它的不占位(不消耗匹配字符)但起限定作用、肯定和否定、順序和逆序區分、擺放位置不同如何理解等概念上。經過上面的講解,相信讀者已經對這幾個概念有了深刻的理解,但是,理解概念跟靈活運用是兩碼事。
接下來我們再舉幾個平時常用的例子,幫助大家理解並掌握,達到靈活運用的程度。
示例九:正則分塊組合法-必須包含字母、數字、特殊字符
正則:^(?=.*?[a-z])(?=.*?\d)(?![a-z\d]+$).+$
解析: (?=.*?[a-z])
限制必須有字母 (?=.*?\d)
限制必須有數字 (?![a-z\d]+$)
限制從開頭到結尾不能全為數字和字母 .+
在沒有限定的情況下可以是任意字符 ^
和$
限定字符串的開頭和結尾
組合起來就可以得到上面正則。
示例十:正則逐步完善法-排除特定標簽p/a/img
,匹配html標簽
正則:</?(?!p|a|img)([^> /]+)[^>]*/?>
解析:
常見的標簽格式有:
<p>...</p> //無屬性值
<p class="t"....>...</p> //有屬性值
<img ..../> //有屬性值自閉合
<br/> //無屬性值自閉合
首先,從簡單標簽入手,對於</p>
和<br/>
,寫出正則: </?[^>]*/?>
由於[^>]
通配符的匹配訪問太大,因此,實際上無論有沒有屬性值,都被上面表達式給匹配了,這個沒關系,我們通過進一步細化匹配通配符,縮小匹配范圍。
我們觀察可得,標簽名是這樣得到的:
無屬性值:<p> <([^>]+)
有屬性值:<p class <([^ ]+)
無屬性值自閉合:<br/> <([^/]+)
閉合標簽:</p> </([^>]+)>
得到正則:
</?([^> /]+)
用這部分代替前面通配正則的標簽名部分,得到: </?([^> /]+)[^>]*/?>
最后,我們需要排除p/a/img
標簽,用否定順序法,在標簽名前面加入否定環視: </?(?!p|a|img)([^> /]+)[^>]*/?>
大功告成,這是我們要的結果!
此示例的正則逐步完善法是正則書寫過程中常用方法,倒推回去也是可行的,比如,假如我們拿到一段很長的正則,而它的匹配結果是錯誤的,我們該怎么做?
我們可以用逐步截斷的方法,一步步的減除掉右側的一部分,直到它恢復匹配,我們就知道剛剛被減除掉的部分正則是有問題的,觀察它為什么導致錯誤,修改正確,再逐步恢復后面減除的正則即可。
示例十一:正則減除查錯法-匹配異常原因查找
源字符串:
<ul>
<li class="item">item1</li>
<li class="item">item2</li>
<li class="item bug">item3</li>
<li class="item">item4</li>
<li class="item">item5</li>
</ul>
正則:<li class="item">(.*?)</li>
減除排錯過程:
例子比較簡單,主要演示思路過程。
用上面的正則去匹配源字符串,我們發現,明明預期5個結果,但是卻得到了4個,因此,我們開始進行減除正則排錯。
1. 減除右側</li>
,此時正則<li class="item">(.*?)
匹配4個
2. 減除右側(.*?)
,此時正則<li class="item">
,匹配4個
3. 減除"item">
,此時正則<li class=
,匹配5個
4. 恢復"item">
,減除>
,此時正則<li class="item"
,匹配4個
5. 減除"
,此時正則<li class="item
,匹配5個
至此,觀察發現item后面還有其他可能,補充兼容:
6. 修復得正則<li class="item[^"]*"
7. 逐步把前面減除的"
后面部分補充回來,此時正則<li class="item[^"]*">(.*?)</li>
,匹配5個
問題解決!