1. 程式人生 > >深入JS正則先行和後行斷言

深入JS正則先行和後行斷言

如果 空格 master 文本 密碼 trac 開頭 是我 gre

這裏是 Mastering Lookahead and Lookbehind 文章的簡單翻譯,這篇文章是在自己搜索問題的時候stackoverflow上回答問題的人推薦的,看完覺得寫得很不錯。這裏的簡單翻譯是指略去了一些js不具備的內容,再者原文實在是太長了,所以也去掉了一些沒有實質內容的話,同時也加入了很多自己的理解。如果需要深入理解js的斷言機制,還是推薦先去看完MDN的基礎再去看這篇文章(http://www.rexegg.com/regex-lookarounds.html)效果會比較好。



一開始是對零寬斷言的簡單概念介紹,略去。

先行斷言例子:簡單密碼驗證

密碼需要滿足四個條件:

  1. 6到10個單字字符 \w
  2. 至少包含一個小寫字母 [a-z]
  3. 至少包含三個大寫字母 [A-Z]
  4. 至少包含一個數字 \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。有幾種寫法:

  1. 字符減法,[\w-[Q]](js不支持)
  2. [_0-9a-zA-PR-Z]
  3. [^\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正則先行和後行斷言