書接上回《正則表達式 - 從 0 到 1(前篇)》,這一篇主要是對正則表達式進階語法的介紹。javascript
在上一篇中,介紹了正則表達式經常使用的語法,好比字符類、重複、分組等,可是正則表達式還有一些高級語法,這些語法日常可能比較少使用到,可是當你碰到特定場景的時候,就會忍不住叫一聲——真香!html
正則表達式擁有一些高級功能,可是並非全部正則引擎都支持,就好比上一篇中提到的自定義字符類的減法、交集等。還有一些正則表達式受環境不一樣而擁有特定的特性、語法,好比上一篇中提到的自定義字符類中的 ]
定界符、在使用 /
標記正則時,/
自己也須要轉義(自定義字符類中又不須要了)之類的,還有甚至預約義字符類 \d
、\w
、\s
所指代的字符類範圍都根據不一樣的正則引擎而不同。java
因此,在使用正則表達式以前,仍是要具體熟悉一下所使用的正則引擎是否有特定的要求。對於 \d
、\w
、\s
這些預約義字符類,可使用自定義字符類來強制指定範圍,好比 \d
可使用 [0-9]
來代替,可是,這並非必須的!由於即使是不一樣正則引擎中 \d
指代的範圍不一樣,但不變的事實是,\d
永遠匹配數字。python
舉個例子,\d
在 JavaScript、Java、PCRE 等支持 Unicode 的正則引擎中僅與 ASCII 數字匹配,可是在其餘大部分支持 Unicode 的正則引擎中,\d
還與 Unicode 中的其餘數字系統匹配。正則表達式
好比真·阿拉伯數字(東方阿拉伯數字 Eastern Arabic numerals)「٠١٢٣٤٥٦٧٨٩」:express
在 JavaScript 中是沒法匹配的:編程
// 你能夠把下面的代碼複製到瀏覽器的 Console 中執行(Chrome 快捷鍵:Ctrl+Shift+J / Command+Option+J)
const regex = /\d+/;
const numbers1 = '0123456789';
const result1 = numbers1.match(regex);
console.log(numbers1.length); // 10
console.log(result1); // ["0123456789", ......]
const numbers2 = '٠١٢٣٤٥٦٧٨٩';
const result2 = numbers2.match(regex);
console.log(numbers2.length); // 10
console.log(result2); // null
複製代碼
而在 C#.NET 中,則能夠成功匹配:瀏覽器
// 你能夠在 https://ideone.com/dzh4iI 在線測試下面這個代碼
using System;
using System.Text.RegularExpressions;
namespace RegexTester {
public class Program {
public static void Main(string[] args) {
Regex regex = new Regex(@"\d+");
var numbers1 = "0123456789";
var result1 = regex.Matches(numbers1);
Console.WriteLine("Length: {0}", numbers1.Length); // Length: 10
Console.WriteLine("Matched: {0}", result1[0]); // Matched: 0123456789
var numbers2 = "٠١٢٣٤٥٦٧٨٩";
var result2 = regex.Matches(numbers2);
Console.WriteLine("Length: {0}", numbers2.Length); // Length: 10
Console.WriteLine("Matched: {0}", result2[0]); // Matched: ٠١٢٣٤٥٦٧٨٩
}
}
}
複製代碼
在 Python3 中,也能夠成功匹配:app
# 你能夠在 https://ideone.com/BIYK0O 在線測試下面這個代碼
import re
regex = r'\d+'
numbers1 = '0123456789'
result1 = re.search(regex, numbers1)
print(len(numbers1)) # 10
print(result1[0]) # 0123456789
numbers2 = '٠١٢٣٤٥٦٧٨٩'
result2 = re.search(regex, numbers2)
print(len(numbers2)) # 10
print(result2[0]) # ٠١٢٣٤٥٦٧٨٩
複製代碼
\d
自己的含義就是匹配「數字字符」,所以匹配到 Unicode 中其餘非 ASCII 數字也無可厚非,由於那些字符確實都是數字字符。因此,在實際應用過程當中,要根據實際狀況,決定是繼續使用 \d
仍是轉成更明確的 [0-9]
。畢竟,在多語言環境下,支持 Unicode 的 \d
帶來的用戶體驗可能會更好。可是若是代碼編寫的時候存在考慮不完善的地方(也就是代碼存在 Bug),\d
帶來的 Unicode 支持可能會產生未知的後果。編程語言
嗯,扯了這麼多,就是想說明一個事實,就是正則具體支持什麼語法都是根據引擎實現而決定的,甚至還和不一樣引擎的不一樣版本相關,特別是本文將要提到的這些「高級語法」,以及我我的也不多接觸到的其餘「高級 S Plus Max 語法」。
因此,本文也僅做參考,做爲知識擴充閱讀便可,具體在讀者所用的正則引擎是否支持,也還請讀者自行測試。
下面咱們就開始吧~
在知道了分組與分組的反向引用以後,在有些時候,可能會出現一些問題。
考慮這樣的正則表達式:/^(a?)b\1$/
,若是要匹配的字符串是 aba
,那毫無疑問,能夠匹配成功,可是若是要匹配的字符串是 ba
或是 b
呢,因爲第一個分組中的 a?
是能夠沒有的,此時第一個分組就沒有東西了。因此後面的 \1
也應該是空的,因此,結果是 ba
沒法成功匹配而 b
能夠成功匹配。
再考慮這樣的正則表達式:/^(a)?b\1$/
,與上面相似,若是要匹配的字符串是 aba
,毫無疑問,能夠匹配成功,可是對於 ba
或是 b
呢?因爲第一個分組總體都是可選的,因此此時,第一個分組將不存在。此時後面須要引用 \1
,對於大多數正則引擎來講,ba
和 b
都將會致使失敗。可是 JavaScript 是一個例外,對於 JavaScript 來講,即使第一個分組是可選的,在它不存在的時候,它也表示一個空分組。因此對 JavaScript 來講,ba
匹配失敗,可是 b
能夠成功匹配。
對於相似於 /(test)\7/
、/(test)\12/
這樣,分組數只有 1 個,但卻引用了 7 號分組、12 號分組,這在絕大多數正則引擎中都是一個錯誤。可是,JavaScript 因爲支持八進制轉義符,因此,若是分組不存在,JavaScript 會嘗試將其解釋爲一個八進制字符,因此 \7
、\12
都是合法的,可是 \8
、\9
是不合法的八進制字符,就屬於錯誤。而 Java 中,對不存在的組的引用將不會匹配任何內容。在 .NET 中,雖然支持八進制轉義符,可是必須是兩位數的八進制,因此,\7
屬於錯誤,而 \12
確是合法的。
注:對 JavaScript 和 .NET 來講,只有分組號不存在時,纔會嘗試解釋爲八進制轉義符。
在「分組」中,咱們知道正則中可使用一對小括號 ()
來建立一個分組,而後在「分組引用」中,咱們學會了使用序號來引用一個分組的內容。
可是,在不少狀況下,分組也被用來協助重複、枚舉,而這些分組的存在會干擾咱們對分組的統計計數,致使後面進行分組引用的時候編號很難肯定。
爲了方便計數,咱們能夠將不須要被引用的分組設置爲「非捕獲組」,只要在分組的開頭,括號內,添加 ?:
便可。
示例:
/^(130|131)(\d{4})\2$/
匹配 130、131 號段,後面 8 位數字前四位和後四位相同的手機號碼,好比13012341234
、13156785678
/^(?:130|131)(\d{4})\1$/
同上。
這裏第一個例子裏使用前面說的普通分組,也叫「捕獲組」,要引用第二個分組 (\d{4})
時,分組號是 2。而第二個例子中,將第一個分組設置爲了非捕獲組,那麼分組序號將從第二個開始設置爲 1,因此要引用 (\d{4})
這個分組時,分組號就是 1。
有了非捕獲組,在複雜狀況下仍是很難編號,而且使用編號引用的話也會使正則變得難以閱讀。爲了方便分組,正則引入了「命名組」的概念,也就是給分組起名字,這樣就不須要去數分組的序號了,只要使用分組的名字去匹配便可。
命名組在不一樣正則引擎中語法不一樣,第一個支持命名組的正則引擎是 Python 的 re,使用的語法是 (?P<name>group)
,而要引用這個命名組,則使用 (?P=name)
。後來 .NET 也開始支持命名組,可是微軟使用的語法是 (?<name>group)
或 (?'name'group)
,而要引用命名組則使用 \k<name>
或 \k'name'
,.NET 的命名組中的組名可使用尖括號,也可使用單引號,二者在正則引擎中沒有區別。
在 Python 和 .NET 都有了命名組,而且有了三種命名組的寫法後,Perl 5.10 冒了出來,同時支持 Python 和 .NET 的三種寫法,而且在這個基礎上,還給分組引用又帶來了兩種新的語法:\k{name}
和 \g{name}
。emmmmmm,新增的這兩種語法與 Python 和 .NET 的那三種在引擎中徹底等同,沒有任何區別。(╯‵□′)╯︵┻━┻
Java 也使用了 .NET 的語法,可是隻支持使用尖括號做爲組名的語法,而不支持使用單引號的形式。
JavaScript 從 ES2018 開始支持命名捕獲組,與 Java 同樣,使用 .NET 的語法,也只支持使用尖括號做爲組名的語法而不支持使用單引號的形式。
總之,命名組如今幾乎全部正則引擎都支持,可是具體使用的語法,還請讀者自行嘗試!
示例:
/^<(?P<tag>[a-zA-Z][a-zA-Z0-9]*)(?:\s+[^>]*)?>.*<\/(?P=tag)>$/
使用 Python 語法,能夠簡單匹配 HTML 標籤(複雜狀況暫不考慮)
/^<(?<tag>[a-zA-Z][a-zA-Z0-9]*)(?:\s+[^>]*)?>.*<\/\k<tag>>$/
使用 .NET 尖括號語法,同上
/^<(?'tag'[a-zA-Z][a-zA-Z0-9]*)(?:\s+[^>]*)?>.*<\/\k'tag'>$/
使用 .NET 引號語法,同上
上面的例子中,先是以 <
符號開頭,表示 HTML 標籤開始。而後跟着一個名稱爲 tag
的命名組,組內容爲 [a-zA-Z][-a-zA-Z0-9]*
,即爲單個字母,或字母后跟任意個數字、字母或是 -
,也就是 HTML 標籤名稱的規則。而後跟着一個非捕獲組,用於匹配 HTML 標籤的屬性,以一個或多個空格開頭,跟着任意個不是 >
的字符(只作簡單匹配,複雜狀況暫不考慮),這個非捕獲組後面有一個 ?
,表示 HTML 屬性無關緊要。再後面跟着一個 >
符號,表示 HTML 標籤結尾。而後是 .*
用於匹配 HTML 標籤的內容(只作簡單匹配,複雜狀況咱不考慮),而後是 <\/
,表示 HTML 結束標籤的開始,由於使用 /
來標記正則,因此 /
須要 \
進行轉義。而後是引用前面名稱爲 tag
的分組。最後跟着一個 >
表示 HTML 結束標籤的結尾。
在正則表達式中,有一些僅僅表明「位置」的符號,好比 ^
表示字符串的開頭位置,$
表示字符串結尾的位置。實際上,正則還有一個表示位置的符號 \b
,它表示「單詞邊界」。
它與上一篇中提到的 \d
、\w
和 \s
不同,不屬於字符類,它僅僅表示一個特殊的位置,這個位置一般有這樣的特徵:
^
相同的位置$
相同的位置具體哪些字符屬於單詞字符,也是取決於所使用的正則表達式引擎的,一般來講,能被 \w
匹配的字符都是單詞字符。可是 Java 是個例外,在 Java 中,部分 Unicode 字符能夠做爲 \b
的單詞字符,但卻沒法被 \w
匹配。
示例:
/\bbed\b/
匹配獨立爲單詞的bed
,好比Lying in bed, but can't sleep
、There is a bed in the corner
,可是Sleeping in the bedroom
就沒法匹配到了。
還記得上一篇提到的「重複」嗎?除了 {m}
這樣的固定次數匹配,其餘的「重複」的具體重複次數都是不肯定的,好比 +
能夠匹配至少一次,但上不封頂。
這就麻煩了,考慮這個示例:/<.+>/
,咱們想要使用這個正則表達式來匹配相似於 <div>
這樣的字符串,可是,若是你的字符串是 <div>Test</div>
,你會發現,匹配結果是整個字符串,也的確,整個字符串也的確知足正則的條件,畢竟 .
表示任意字符,因此 d
、i
、v
、>
、T
、e
、s
、t
、<
和 /
都是知足條件的。
在正則裏,不定次數的重複都是「貪婪」的,貪婪的意思就是它會盡量多的匹配字符,因此上面這個例子中,<
匹配完第一個字符後,.+
開始重複任意次,因此它會一直日後找,直到找到一個不知足 .
的字符,可是後面都是匹配的,因此,就匹配到最後一個字符,而後再去嘗試找知足 >
的字符。可是因爲 .+
已經匹配了全部字符,所以 >
沒法匹配到,因此 .+
須要作一個讓步,放出一個字符 >
,使得 >
匹配成功。
爲了使上面的例子符合咱們的要求,只匹配到第一個 >
就結束,咱們能夠將重複轉爲「懶惰」模式,只須要在重複符號後加一個 ?
便可,好比 +
變爲 +?
,*
變爲 *?
,?
變爲 ??
,{m,n}
變爲 {m,n}?
。上面的例子能夠改爲 /<.+?>/
,就能夠匹配到 <div>Test</div>
中的 <div>
了。
在使用貪婪模式的時候,必定要當心!由於正則表達式在遇到貪婪重複的時候,會一直日後遞歸匹配,直到發現第一個不知足條件的結果時,纔會一點一點向前回溯,直到找到知足條件的結果,或者回溯到原點,纔會匹配失敗。這個過程是比較危險的,由於這會致使正則表達式的匹配時間呈指數性爆炸式增加,而且會使 CPU 佔用大量上漲。
2019 年 7 月初,全球知名的 CDN 提供商 Cloudflare 出現了全球範圍的 502 故障,緣由就是因爲使用了一個貪婪匹配的正則表達式。感興趣的讀者能夠閱讀 Cloudflare 的博客(具體講解在博客附錄部分):blog.cloudflare.com/details-of-…
若是咱們在進行正則匹配的時候,要忽略大小寫,咱們一般可使用 [a-zA-Z]
來同時匹配小寫與大寫,可是對於複雜狀況,這樣作可能會比較麻煩。
正則表達式一般都支持使用選項進行行爲控制,可是不一樣的正則引擎支持的選項都不盡相同,具體使用的正則引擎支持哪些選項,須要讀者自行查詢相關文檔。
常見的選項有:
i
:忽略大小寫s
:單行模式,使得 .
與包括換行符在內的全部字符匹配(默認 .
是不包含換行符的)m
:多行模式,使得 ^
和 $
再也不只表示整個字符串的開頭和結尾,而是表示每一行的開頭與結尾。不一樣的正則引擎設置選項的方式也都不一樣,大概總結一下,有如下幾種設置選項的方式:
/Hello/i
,這將對整個正則表達式生效,使其忽略大小寫。new RegExp('Hello', 'i')
,這也將對整個正則表達式生效,使其忽略大小寫。(?flag)
語法設置選項,好比 /(?i)Hello/
。
/Hello (?i)World/
中,Hello
是要求 H
大寫,ello
小寫的,可是 World
則不區分大小寫,因此能夠匹配 Hello WORLD
、Hello World
、Hello world
,可是 HELLO World
則沒法匹配。Hello World(?i)
將沒有任何效果,或者在某些正則引擎中被視爲錯誤。Hello World(?i)
會使得整個正則表達式忽略大小寫。示例:
/^Apple$/i
能夠匹配Apple
、apple
、APPLE
、appLE
、AppLe
等。
有時,咱們須要爲匹配結果增長一些條件限制,但又不想在匹配結果中包含這些限制條件。什麼意思呢?好比咱們要匹配區號爲 0123 的中國座機號碼,而且匹配結果不包含區號。中國的座機號碼一般有兩種,一種是 012-12345678,前面三位是區號,後面 8 位是座機號碼,另外一種是 0123-1234567,前面四位是區號,後面 7 位是座機號碼,而且區號一般都是以 0 開頭。
emmmmmm,是否是想打人,這都什麼複雜的條件……
只要先用 StartsWith 函數檢查字符串是以 0123 開頭,而後使用 SubString 函數截取後面幾位便可……
emmmmmm,要用正則匹配……
正則裏有一個功能叫作「零寬斷言」,用於聲明當前位置要匹配的內容,但僅僅是聲明,不作任何匹配。零寬斷言分爲前瞻和後顧兩種模式,前瞻和後顧又分爲正向和負向兩種方式,排列組合一下一共四種:正向前瞻 (?=regex)
、負向前瞻 (?!regex)
、正向後顧 (?<=regex)
和負向後顧 (?<!regex)
。
前瞻,就是向前看的意思,也就是從當前位置向字符串後面😓看;然後顧,就是向後看的意思,也就是從當前位置向字符串前面😓看。正好相反,是由於對於正則來講,永遠都是從字符串「前面」開始向「後面」進行匹配(對於 LTR 語言來講,就是從左到右),因此,對於正則來講,前瞻就是「向字符串後面看」,後顧就是「向字符串前面看」。
正向和負向,分別表示匹配成功和匹配失敗。
示例:
/(?<=0123-)\d{7}/
能夠匹配0123-
後面的 7 位任意數字,好比0123-1234567
,匹配結果爲1234567
,不包含0123-
。而0234-1234567
、012-1234567
等不是由0123-
開頭的就會匹配失敗。
/(?<!\d{4}-)\d{8}/
能夠匹配前面跟的不是 4 位數字加一個-
的 8 位任意數字,好比012-12345678
匹配結果爲12345678
,01212345678
匹配結果爲01212345
。而0123-12345678
前面跟了 4 位數字加一個-
,因此匹配失敗。
/[a-z]+(?=ed)/
能夠匹配ed
結尾的字母串,好比ended
匹配結果爲end
,opened
匹配結果爲open
,abcededed
匹配結果爲abceded
(貪婪原則)。而相似於end
、be
等不是由ed
結尾的則會匹配失敗。要注意的是,bedroom
能夠匹配成功,匹配結果爲一個字母b
。
/\d{4}(?!\.12)/
能夠匹配不是由.12
結尾的 4 位數字,好比1234.56
匹配結果爲1234
,1234.1
匹配結果爲1234
,12345.12
匹配結果爲1234
。而1234.12
則會匹配失敗。
/[a-z]+(?!ed)/
能夠匹配所有由字母組成的字符串。
/(?<!0123)\d+/
能夠匹配所有由數字組成的字符串。
(・∀・(・∀・(・∀・*)??? 匹配所有由字母組成的字符串?匹配所有由數字組成的字符串?但是上面不是說負向零寬斷言,應該匹配「不是由 ed
結尾的字母串」、「不是由 0123
開頭的數字串」嗎?
再仔細想一想,opened
這種字母串,雖然是由 ed
結尾,可是若是把 opened
看做一個總體,這一個總體後面可就沒東西了,因此這個總體並非由 ed
結尾的,因此匹配成功,匹配結果就是 opened
;而數字串同理,01231234567
這樣的數字串,雖然是由 0123
開頭的,可是把他看做一個總體,總體前面沒有數字了,因此這個總體不是由 0123
開頭的,因此匹配成功,匹配結果就是 01231234567
。注意!這與貪婪原則或懶惰原則沒有關係!
因此,在使用負向的零寬斷言時必定要注意匹配結果是否有意義!
部分正則引擎不支持後顧式的零寬斷言 (?<=regex)
和 (?<!regex)
,好比 JavaScript(Chrome 62 開始支持,Firefox、Safari 至今徹底不支持,Node.JS 與 Chrome 使用相同的 V8 引擎,因此自 Chrome 支持之後 Node.JS 也開始支持)。
零寬斷言的概念比較複雜,它和 ^
、$
、\b
相似,就表示一個位置。
再舉個例子,好比我要匹配一個 11 位的數字串,它中間要包含 1234
。這是兩個條件,要同時知足。好比 13012345678
、12341301234
、13056781234
等。
使用現有的正則能力,是否能夠完成呢?
若是僅僅檢查長度:/^\d{11}$/
,就沒有辦法檢查是否包含 1234
了。
若是簡單的 /^\d*1234\d*$/
,這樣雖然能匹配到包含 1234
的數字串,可是沒有辦法保證總體的長度。
若是寫成 /^\d{3}1234\d{4}$/
,能夠成功匹配相似於 13012345678
這樣的數字串,可是 12341301234
這種就無能爲力了。
可怕的想法是:/^(1234\d{7}|\d1234\d{6}|\d{2}1234\d{5}|\d{3}1234\d{4}|\d{4}1234\d{3}|\d{5}1234\d{2}|\d{6}1234\d{1}|\d{7}1234)$/
,能夠完美匹配,可是……emmmmmm,是否是有笨……
實際上,若是能靈活使用零寬斷言,這個問題就能夠很好的解決了:
示例:
/^(?=\d{11}$)\d*?1234\d*/
能夠匹配 11 位數字,而且包含1234
的數字串。
僅此而已,很是簡單。這裏要對「僅僅表示一個位置」有一個深入的理解。
沒有人說過 $
必定要放在整個正則表達式的結尾,也沒有人說過正向前瞻零寬斷言必定要放在正則其餘內容的後面。
這裏正則在匹配的時候,首先碰到一個正向前瞻零寬斷言,因此會直接「向字符串後面看」,檢查從當前位置(也就是起始位置)開始,後面是否跟着 11 位數字,而且 11 位數字以後是字符串的結尾。此時正則匹配的位置仍是在字符串的開頭,這只是一個「預檢測」。檢查經過,正則開始從當前位置正式開始匹配 \d*?1234\d*
,第一個 \d*?
採用懶惰模式,後面一個實際上採用貪婪模式或是懶惰模式都無所謂,由於前面 \d*?1234
必定是成功匹配了 0 到 7 位數字 + 1234
,而以前檢查了字符串必定是由 11 位數字組成,因此,後面的 \d*
必定是匹配剩餘的全部數字,因此用貪婪模式便可。
但注意,上面的示例中,最後面的 \d*
是不能省略的,雖然即使是省略了,也能夠匹配成功,可是匹配結果將不會包含 1234
後面的內容。由於零寬斷言只作檢查,並不會將檢查的內容放入匹配結果中,因此,結果只包含 \d*?1234\d*
所匹配到的內容。
if A then B else C
是常見編程語言中的基本邏輯之一(雖然不一樣語言語法不盡相同),它表示判斷條件 A 是否成立,若成立則進入 B,若不成立則進入 C。
在 Python、.NET、Perl、PCRE 這些正則引擎中也有相似的結構,語法是 (?ifthen|else)
。注意 if 和 then 之間沒有空格。else 能夠省略,變爲 (?ifthen)
。
其中 if 可使用零寬斷言或是分組引用來指定。當條件成立時,匹配 then,條件不成立時,匹配 else。
示例:
/^\d{4}(0[1-9]|1[012])(?(?<=0[469]|11)(0[1-9]|[12]\d|30)|(?(?<=02)(0[1-9]|[12]\d)|(0[1-9]|[12]\d|3[01])))$/
能夠匹配YYYYMMDD
格式的日期,其中年份沒有要求,可是日期必須合法(容許 2 月 29 日),好比20120229
、20130430
、20150531
等,可是20190230
、20130631
、20150740
、20181305
就沒法匹配。
這裏首先 \d{4}
判斷字符串以 4 位數字開頭,表示年份。而後 (0[1-9]|1[012])
匹配合法的月份(01 ~ 12)。而後後面的總體是一個條件語句,條件是正向後顧零寬斷言 (?<=0[469]|11)
,由於月份已經被匹配了,因此此時的位置處於月份以後,因此須要使用「後顧」來判斷前面的月份,也就是判斷是有 30 天的「小月」。若條件成立,則匹配 (0[1-9]|[12]\d|30)
;若條件不成立,則匹配後面的內容,然後面又是一個條件語句,條件仍是正向後顧零寬斷言 (?<=02)
,因爲至此位置仍是處於月份以後,因此也是使用「後顧」來判斷,若是月份爲 02
,則條件成立,匹配 (0[1-9]|[12]\d)
;不然條件不成立,匹配 (0[1-9]|[12]\d|3[01])
。
雖然正則匹配日期的寫法不止一種,大多數狀況下都是使用枚舉的方式直接匹配,這個例子只是舉一個使用條件語句的例子而已。
條件語句使用分組引用來指定條件相似於這樣:
示例:
/^(a)?b(?(1)c|d)$/
能夠匹配abc
和bd
,其餘組合都不能匹配
/^(?<x>a)?b(?('x')c|d)$/
同上,使用命名捕獲組
因爲第一個分組 (a)?
是可選的,所以後面判斷第一個分組是否存在,若存在則條件成立,匹配 c
不然匹配 d
。
有時咱們須要匹配一個遞歸嵌套的字符串,好比 XML 標籤、前綴表達式等。他們的特色是層層嵌套,每一層都有區別,但又有特定的規則。
在軟件開發的時候,咱們一般會使用遞歸函數來作一些嵌套、重複的事情。
正則中也有相似的東西:Perl、PCRE、Ruby 等正則引擎支持遞歸重複,而 .NET 支持平衡組。
遞歸重複在不一樣的正則引擎中語法也不太同樣,在 Perl 中,使用 (?R)
或 (?0)
;在 Ruby 中,使用 \d<0>
;PCRE(以及 PHP、Delphi、R 這些基於 PCRE 的正則引擎)同時支持 Perl 和 Ruby 的這三種語法。
正則在匹配的時候,若是碰到遞歸重複標記,將會從當前位置開始,將整個正則表達式從新匹配一遍,一層一層遞歸,直到最內層匹配結束以後,再一層一層往上回溯。若其中一層匹配失敗,將會致使整個遞歸匹配失敗。
示例:
/([a-z])(?R)??\1/
能夠匹配字符數爲偶數個的迴文字,好比aa
、abba
、abccba
、abcddcba
等。而字符數爲奇數個,或者不是迴文,則匹配失敗,好比aba
、abcba
、abab
都會匹配失敗。
/\([+\-*\/](?:\s(?:\d+|(?R))){2}\)/
能夠匹配前綴表達式,要求操做符只有+
、-
、*
和/
,操做數有且僅有兩個。好比(+ 1 2)
、(* (/ 6 2) (+ 5 (- 7 6)))
、(/ 123 (/ (* 99 99) (+ 0 1)))
等。若是括號不匹配、操做符不匹配、操做數不匹配,都會致使匹配失敗!
注意,遞歸重複必須擁有跳出條件,好比上例中使用的枚舉,或是 ?
標記爲可選。若遞歸重複沒有跳出條件將致使遞歸死循環錯誤。好比,/(?R)/
將直接報錯,由於這將觸發無限遞歸。因爲遞歸重複是針對整個正則表達式進行重複,所以若是正則表達式以 ^
開頭將會永遠匹配失敗,由於當發生遞歸時,「當前」位置永遠不多是字符串開頭。
還有,注意遞歸重複的性能問題,對於上面的第一個示例,其中的遞歸標記使用了兩個 ?
表示懶惰,這在某些狀況下能夠加快速度。測試匹配 abcdefggfedcba
這個字符串,使用懶惰模式 /([a-z])(?R)??\1/
完成匹配須要 50 步,而使用貪婪模式 /([a-z])(?R)?\1/
則須要 81 步;而測試匹配 abcdefggfedcbb
(最後一個 a
改爲了 b
),懶惰模式須要 97 步,而貪婪模式則須要 165 步。
平衡組是微軟在 .NET 中支持的一種「遞歸重複」的解決方案。
與遞歸重複不一樣的是,平衡組並非採用重複整個正則表達式的方式來實現的,而是採用命名捕獲組或是非捕獲組來實現的。這比遞歸重複更加靈活。
使用命名捕獲組的語法是:(?<name-balance>group)
或 (?'name-balance'group)
,其中 name
是捕獲組的名字,而 balance
是平衡組的名字;使用非捕獲組的語法是省略捕獲組的名字:(?<-balance>group)
或 (?'-balance'group)
。
在絕大多數正則引擎中,命名捕獲組若是出現屢次,那麼匹配結果中命名捕獲組的值將會是最後一次出現的值,好比 /^(?<name>\w)+$/
匹配 ab
,則捕獲組 name
的結果只能獲得 b
,而 ^(?<name>\w)(?<name>\w)$
這樣重複的組名則被視爲是錯誤。
但在 .NET 中,/^(?<name>\w)+$/
和 ^(?<name>\w)(?<name>\w)$
均可以對 ab
匹配成功,而且雖然捕獲組 name
的值會被 b
覆蓋,可是全部的歷史匹配結果都會存儲在捕獲組的 Captures
屬性中。
// 你能夠在 https://ideone.com/SLDjlH 在線測試下面這個代碼
using System;
using System.Text.RegularExpressions;
namespace RegexTester {
public class Program {
public static void Main(string[] args) {
Regex regex = new Regex(@"^(?<name>\w)+$");
var str = "abcde";
var result = regex.Matches(str);
for (var i = 0; i < result.Count; ++i) {
var item = result[i];
Console.WriteLine("Group Count: {0}", item.Groups.Count);
foreach (Group group in item.Groups) {
Console.WriteLine(@"Group ""{0}"" = ""{1}""", group.Name, group.Value);
foreach (Capture groupItem in group.Captures) {
Console.WriteLine(@"Group ""{0}"" Captured ""{1}""", group.Name, groupItem.Value);
}
}
}
}
}
}
// 輸出結果:
/* Group Count: 2 Group "0" = "abcde" Group "0" Captured "abcde" Group "name" = "e" Group "name" Captured "a" Group "name" Captured "b" Group "name" Captured "c" Group "name" Captured "d" Group "name" Captured "e" */
複製代碼
正由於如此,.NET 擁有了追蹤捕獲組歷史的能力,也就所以創造了平衡組。
正則引擎在匹配的時候,若是遇到捕獲組,將會把捕獲組放入 Captures
棧中,而遇到平衡捕獲組時,將會在指定的捕獲組的 Captures
棧中 pop 出最後一個結果。若是對應 Captures
棧中沒有結果了,則匹配將會失敗。
示例:
/^(?<char>[a-z])+[a-z]?(?<-char>\k<char>)+(?(char)(?!))$/
能夠匹配任意迴文字,好比aa
、aba
、abba
、abcba
、abccba
等。
這個例子中,先是一個 (?<char>[a-z])+
命名捕獲組 char
匹配 [a-z]
,並重復至少一次,而且每次重複都將匹配到的字母壓入 Captures
棧中。而後是 [a-z]?
能夠匹配可選的一個任意字母,由於迴文字最中間的字母不必定須要重複。以後是 (?<-char>\k<char>)+
,其中 \k<char>
反向引用捕獲組 char
的值,若匹配成功,(?<-char>\k<char>)
平衡組將會 pop 出 Captures
棧中最後一個結果,此時捕獲組 char
的值變爲倒數第二個匹配的值;若是不存在 char
這個分組,或是 Captures
已經爲空,則直接匹配失敗,這個過程重複至少一次。最後是一個條件語句 (?(char)(?!))
,省略了 else,它判斷分組 char
是否存在,若存在則匹配 (?!)
,這是一個永遠失敗的匹配語法(前瞻匹配空字符串 /(?=)/
永遠成立,負向前瞻匹配空字符串 /(?!)/
永遠失敗)。由於迴文字是對稱的,因此字母數量必定是相等的,所以若是知足條件,此時 char
分組應當所有被平衡組抵消而不存在了。若分組 char
仍然存在,則表示迴文字母的數量先後不一致,使用 (?!)
強行匹配失敗。
示例:
/^(?:[^<>]*?(?:(?'bracket'<)[^<>]*?)+?(?:(?'-bracket'>)[^<>]*?)+?)+(?(bracket)(?!))$/
匹配徹底配對的<>
包圍的字符串。好比bbb<aaa<ab<abc>abc<aaa<aaa>>a>aaa>aaa
。
注意,在匹配失敗的時候,性能損失是肉眼可見的。
這個例子中,最外層是一個 (?:...)+
非捕獲組的重複。
後面是一個條件語句 (?(bracket)(?!))
,由於咱們要求 <>
括號配對,那麼二者數量必定是相等的,所以若是知足條件,此時 bracket
分組應當所有被平衡組抵消而不存在了。若分組 bracket
仍然存在,則表示 >
的數量小於 <
的數量,所以括號不匹配,使用 (?!)
強行匹配失敗。
在第一個非捕獲組中,先是 [^<>]*?
匹配任意個非括號字符;而後是一個 (?:(?'bracket'<)[^<>]*?)+?
要求至少出現一次的非捕獲組,其中 (?'bracket'<)
表示匹配一個 <
並放入 Captures
棧,而後 [^<>]*?
匹配任意個非括號字符;而後又是一個 (?:(?'-bracket'>)[^<>]*?)+?
要求至少出現一次的非捕獲組,其中 (?'-bracket'>)
平衡組嘗試匹配一個 >
,若匹配成功,再檢查 Captures
棧是否非空,若是 Captures
棧爲空則直接匹配失敗,由於此時沒有對應的 <
來與之配對了,若 Captures
棧非空,則 pop 出一個後繼續匹配 [^<>]*?
任意個非括號字符。
實際上就是這樣,正則還有不少東西沒有提到,感興趣的讀者能夠去 www.regular-expressions.info/tutorial.ht… 看到更多更詳細的介紹。
是否是看的愈來愈迷糊?這仍是上一篇中的那個簡單高效的文本查找匹配工具嗎?如今怎麼有點懷疑人生了?
emmmmmm……
的確是這樣的,正則本應該簡單高效,再軟件開發過程當中,複雜的邏輯理應交給編程語言去實現。而像這種條件語句、遞歸循環之類的就沒有必要用正則去寫了。
可是,存在即合理,既然提供了這樣的語法功能,那麼就必定有應用場景。這些複雜語法功能,看過了,瞭解一下,語法不必定記住,至少知道有這麼個東西,再碰到特定場景的時候,可以想起來,老是會喊一聲「真香」的。
記得要點贊、分享、評論三連,更多精彩內容請關注ihap 技術黑洞!