深入JS正則先行和後行斷言
這裏是 Mastering Lookahead and Lookbehind 文章的簡單翻譯,這篇文章是在自己搜索問題的時候stackoverflow上回答問題的人推薦的,看完覺得寫得很不錯。這裏的簡單翻譯是指略去了一些js不具備的內容,再者原文實在是太長了,所以也去掉了一些沒有實質內容的話,同時也加入了很多自己的理解。如果需要深入理解js的斷言機制,還是推薦先去看完MDN的基礎再去看這篇文章(http://www.rexegg.com/regex-lookarounds.html)效果會比較好。
一開始是對零寬斷言的簡單概念介紹,略去。
先行斷言例子:簡單密碼驗證
密碼需要滿足四個條件:
- 6到10個單字字符 \w
- 至少包含一個小寫字母 [a-z]
- 至少包含三個大寫字母 [A-Z]
- 至少包含一個數字 \d
最初的設想就是在字符串的開頭先行檢測四次,每次檢測每個條件。
條件一
這裏文章用 \A 匹配字符串開頭,用 \z 匹配字符串結尾,和 js 不一樣,改了一下
第一個條件很簡單:^\w{6,10}$
。加入先行斷言:(?=^\w{6,10}$)
,先行斷言:在字符串開頭的位置後面,是6到10個字符,以及字符串的結尾。
(at the current position in the string, what follows is the beginning of the string, six to ten word characters, and the very end of the string. )
我們想在字符串的開頭斷言,因此需要用^做一個錨點定位,不需要重復聲明開頭,所以把^從斷言中拿出來:
^(?=\w{6,10}$)
留意到,雖然我們已經用先行斷言檢測了整個字符串,但是我們的位置還沒有變,正則驗證錨點依然停留在字符串的開頭位置,只是做了先行判斷。意味著我們還可以繼續檢測整個字符串。
條件二
檢測小寫字母最容易想到的寫法是 .*[a-z]
,但是這種寫法 .* 一開始就會匹配到字符串的結尾,導致回溯,容易想到的寫法是 .*?[a-z]
這會導致更多的回溯。推薦的寫法是 [^a-z]*[a-z]
(當需要用到包含某些字符時,可以參考這種通用的寫法),將條件加入先行斷言:(?=[^a-z]*[a-z])
^(?=\w{6,10}$)(?=[^a-z]*[a-z])
斷言裏面依然沒有匹配任何字符,兩個斷言的位置是可以互換的。
條件三
類似條件二: (?=(?:[^A-Z]*[A-Z]){3})
正則變成了:
^(?=\w{6,10}$)(?=[^a-z]*[a-z])(?=(?:[^A-Z]*[A-Z]){3})
條件四
類似的:(?=\D*\d)
正則變成了:
^(?=\w{6,10}$)(?=[^a-z]*[a-z])(?=(?:[^A-Z]*[A-Z]){3})(?=\D*\d)
此時,我們在字符串開頭斷言,並先行檢測了四次判讀了四種條件,依然沒有匹配任何字符,但是驗證了密碼。
匹配有效字符串
檢查完畢後,正則檢測的位置依然停留在字符串開頭,可以用一個簡單的.*
去匹配整個字符串,因為不管.*
匹配到了什麽,都是經過驗證的。因此:
^(?=\w{6,10}$)(?=[^a-z]*[a-z])(?=(?:[^A-Z]*[A-Z]){3})(?=\D*\d).*
微調:移除一個條件
檢查這個正則裏的先行斷言,可以留意到\w{6,10}$
這個表達式檢查了字符串的所有字符,因此可以用他匹配整個字符串而不是用.*
,因此可以減少一個先行判斷簡化正則:
^(?=[^a-z]*[a-z])(?=(?:[^A-Z]*[A-Z]){3})(?=\D*\d)\w{6,10}$
總結這個結果,如果檢查n
個條件,正則至多需要n-1
個先行判斷。甚至能夠把幾個先行判斷合並。
實際上,除了\w{6,10}$
剛好匹配了整個字符串外,其他的幾個先行判斷也可以通過改寫匹配整個字符串,比如(?=\D*\d)可以加一個簡單的.*$
匹配到字符串結尾:
^(?=\w{6,10}$)(?=[^a-z]*[a-z])(?=(?:[^A-Z]*[A-Z]){3})\D*\d.*$
此外,為什麽要在.*
後面加$
,難道不能匹配到字符串結尾麽?因為點符號不匹配換行符(除非在DOTALL mode
下,即點匹配所有),因此.*
只能匹配到第一行的末尾,如果有換行則無法匹配到,$
保證了我們不僅到達一行的結尾,也到達了字符串的結尾。
在這個正則表達式裏,開頭的(?=\w{6,10}$)
已經匹配到了結尾,所以後面的$
不是很必要。
先行斷言的位置幾乎沒有影響
在這個例子裏,因為三個先行斷言都沒有改變位置,所以可以互換。雖然結果沒有影響,但是會影響性能,應該把容易驗證失敗的先行斷言放在前面。
實際上,我們把^
放在前面就是考慮了這個情況,因為^
也沒有匹配任何字符移動正則匹配錨點,他也可以和其他先行斷言互換,但是這會帶來問題。
首先,在DOTALL mode
下,後行負向斷言(?<!.)
可以匹配開頭,即前面沒有任何字符,非DOTALL mode
下,有(?<![\D\d])
匹配開頭。
現在假設把^
放在第四個位置,在三個先行斷言後,這時如果第三個斷言失效了,那麽正則引擎會到第二個位置繼續從第一個先行斷言匹配,就這樣不停地改變位置匹配直到全部位置都失敗。雖然只要匹配到^
就不會從其他位置繼續判斷,但是正則引擎因為提前失敗而無法到達^
。
放第一位時,除了開頭位置外,其他位置在第一次匹配^
就失敗了,因此效率高些。
零寬斷言沒有改變位置
這裏是一些初學者常犯的錯誤。
比如用A(?=5)
匹配AB25
,不理解地方在於先行斷言裏的5
是緊跟A
後的位置,如果要匹配後面的位置,需要用(?=[^5]*5)
。
用A(?=5)(?=[A-Z])
匹配A5B
,依然是位置不變問題,應該是用A(?=5[A-Z])
零寬斷言的用法
驗證
即上面密碼驗證的例子,即一個字符串滿足多個條件。每個條件都是檢測整個字符串。
限制字符範圍
比如匹配非Q
字符外的單字字符\w
。有幾種寫法:
- 字符減法,[\w-[Q]](js不支持)
- [_0-9a-zA-PR-Z]
[^\WQ]
先行斷言寫法:(?!Q)\w
在先行斷言當前位置後面不是Q後,\w
匹配了一個字符。這個寫法不僅容易理解,也容易附加拓展,比如不包含Q和K,那麽就是:(?![QK])\w`
後行斷言:
\w(?<!Q)
Tempering the scope of a token 標誌範圍調整
限制標誌(token)的匹配範圍。
舉個例子,如果想要匹配後面沒有跟著{END}的任何字符,可以用:
(?:(?!{END}).)*
每一個.
標誌都被(?!{END})
調整,斷言點標誌不能是{END}
的開頭,這個技巧叫tempered greedy token
另外一種方案有點過於復雜,略去。
Delimiter 分隔符
在第一個#START#
出現後匹配後面的所有字符寫法:
(?<=#START#).*
或者匹配字符串的所有字符,除了#END#?
.*?(?=#END#)
兩個斷言可以合並:
(?<=#START#).*?(?=#END#)
Inserting Text at a Position 在位置插入文本
給你一個文件,裏面都是駝峰命名的電影標題,比如HaroldAndKumarGoToWhiteCastle
,為了方便閱讀,需要在大小寫之間插入空格,下面的正則匹配這些位置:
(?<=[a-z])(?=[A-Z])
在編輯器的正則匹配查找中,可以用這個去匹配這些位置,並用空格代替。(這裏能想到/[a-z][A-Z]/g
同樣能夠查找,但是找到的不是位置,所以替換起來就不是那麽方便了。
Splitting a String at a Position 在某位置分割字符串
類似上面的例子,就可以分割大小寫之間的位置,在很多語言中,用split函數加上正則可以返回一個單詞數組。
Finding Overlapping Matches 查找重疊匹配
有時候需要在同一個單詞裏做多次匹配,舉個例子,想在ABCD
中匹配ABCD,BCD,CD和D,可以用:
(?=(\w+))
這個還蠻好理解的,會匹配四個位置,"","A",,"","B","","C","","D",""。不過至於說怎麽提取這四個部分,還沒找到合適的方法。
Zero-Width Matches 0寬度匹配
零寬斷言,錨點,邊界在包含標誌的正則表達式中,允許正則引擎返回匹配的字符串。舉個例子(?<=start_)\d+
,正則引擎會返回數字,但是不包括前綴start_
。
下面是一些應用:
Validation 驗證
即類似密碼驗證例子
Inserting 插入
類似插入空格例子
Splitting 分割
類似插入空格例子
Overlapping Matches 重疊匹配
同一個單詞裏做多次匹配例子
Positioning the Lookaround 零寬斷言定位
零寬斷言有兩個選擇去定位,在文本前和文本後,一般來講,其中一個性能更高。
Lookahead 先行斷言
\d+(?= dollars)
和(?=\d+ dollars)\d+
都匹配100 dallars
中的100
,但是前者性能更佳,因為他只匹配\d+
一次。(這裏寫一下自己對第二個式子的理解,第二個式子其實是先斷言當前位置的後面是\d+ dollars
,然後匹配斷言中的字符串中的\d+
)。
Negative Lookahead 先行負向斷言
\d+(?! dollars)
和(?!\d+ dollars)\d+
都匹配100 pesos
中的100
,但是前者性能更佳,同上。
後面還有兩個後行斷言的例子,js不支持就不列舉了。
這些例子的不同在於匹配的前後。這裏的說明不是要就糾結於位置,只是能夠知道並感覺到這樣寫正則的效率,通過練習,會慢慢熟悉這些不同並寫出性能更高的正則。
Lookarounds that Look on Both Sides: Back to the Future
這個部分涉及到的是零寬斷言的嵌套,這裏只說明一下裏面舉的例子,因為js不支持後行斷言,這裏講的東西作用就不大了。
匹配下劃線之間的數字:_12_
,有很多方法,文中提出的新方法是:
(?<=_(?=\d{2}_))\d+
即,當前位置前面斷言匹配了下劃線_
,同時下劃線的後面斷言匹配了\d{2}_,即整個後行斷言匹配的是_\d{2}_
,而當前的位置在_
和\d{2}
之間,後面用\d+
匹配數字。
Compound Lookahead and Compound Lookbehind 復合先行和復合後行
在標誌後至多有一個字符
匹配後面至多有一個下劃線的數字:
\d+(?=_(?!_))
還有一種不太優雅的寫法是:\d+(?=(?!__)_)
標誌前至多有一個字符
匹配前面至多有一個下劃線的數字:
(?<=(?<!_)_)\d+
還有一種不太優雅的寫法是:(?<=_(?<!__))\d+
Multiple Compounding 多重復合
即多個嵌套,這個有點復雜,就是超過一次嵌套,多個條件一起判斷。這裏就不列舉了,可以看看這個例子:
(?<=(?<!(?<!X)_)_)\d+
表示數字前綴不能是多個下劃線,除了X__
這種情況。
The Engine Doesn‘t Backtrack into Lookarounds……because they‘re atomic
_rabbit _dog _mouse DIC:cat:dog:mouse
在這個字符串中,DIC後面是允許的動物名,我們要匹配前面_tokens
中在允許動物名內的。
_(\w+)\b(?=.*:\1\b)
獲得_dog
和_mouse
。
翻轉一下:
_(?=.*:(\w+)\b)\1\b
這樣只匹配到了_mouse
這個地方很神奇,稍微講一下。第一個正則還蠻好理解的每次正向斷言都拿前面的\1
捕獲去匹配後面,按從左往右多次匹配結果到兩個結果。第二個正則就特殊,捕獲是放在正向斷言裏的,正向斷言由於貪婪匹配會直接到了_mouse
的下劃線後的位置,然後正則引擎跳出正向斷言去匹配\1
,匹配到mouse
成功。匹配結束。這裏的重點是,正則引擎並不能在正向判斷裏面回溯,只要跳出了正向斷言,就不會再進去。因此這裏的正向斷言只會匹配到mouse
。我一開始想到加個非貪婪,那麽就只會匹配到cat了。
Fixed-Width, Constrained-Width and Infinite-Width Lookbehind 負向斷言,略去
Lookarounds (Usually) Want to be Anchored
匹配一個包含一個單詞的字符串,裏面有一位數字:
^(?=\D*\d)\w+$
這裏需要考慮的問題是^
錨點是否有必要。
這裏的重點在於^
能夠減少錯誤的次數,如果沒有^
,正則引擎會在每個位置都去匹配,只有在所有位置都錯誤後才會返回錯誤,但是加了^
,只要開頭匹配錯誤引擎就會停止。雖然在匹配成功的情況下,兩種情況返回是一樣的,但是在性能上差別卻很大。
One Exception: Overlapping Matches
不過有時候我們希望正則引擎匹配多個位置,比如上面的例子:(?=(\w+))
。在ABCD
中匹配了四次,獲得了四個我們想要的結果。
後記
後記提到了上面講到的[^a-z]*[a-z]
優化為[^a-z]*+[a-z]
,不過一看就知道js不支持,這個的優化點在於,如果發現匹配不成功,有些不夠智能的引擎會回溯前面的非小寫字符,去匹配後面的小寫字母這樣顯而易見的無效回溯。
這篇文章的大致解釋就到這裏,後面需要在了解一下關於正則引擎的問題了。
翻譯文章來源:
http://www.rexegg.com/regex-lookarounds.html
本文來源:JuFoFu
本文地址:http://www.cnblogs.com/JuFoFu/p/7719916.html
水平有限,錯誤歡迎指正,轉載請註明出處。
深入JS正則先行和後行斷言