正則表達式 - 從 1 到 0(棄坑篇)

書接上回《正則表達式 - 從 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 語法」。

因此,本文也僅做參考,做爲知識擴充閱讀便可,具體在讀者所用的正則引擎是否支持,也還請讀者自行測試。

下面咱們就開始吧~

目錄(序號接上一篇)

  • 10、空分組、失敗分組與不存在分組的反向引用
  • 11、非捕獲組與命名組
  • 12、單詞邊界
  • 十3、貪婪與懶惰
  • 十4、正則表達式選項
  • 十5、零寬斷言
  • 十6、條件語句
  • 十7、遞歸重複與平衡組
  • 十8、棄坑

10、空分組、失敗分組與不存在分組的反向引用

在知道了分組與分組的反向引用以後,在有些時候,可能會出現一些問題。

考慮這樣的正則表達式:/^(a?)b\1$/,若是要匹配的字符串是 aba,那毫無疑問,能夠匹配成功,可是若是要匹配的字符串是 ba 或是 b 呢,因爲第一個分組中的 a? 是能夠沒有的,此時第一個分組就沒有東西了。因此後面的 \1 也應該是空的,因此,結果是 ba 沒法成功匹配而 b 能夠成功匹配。

再考慮這樣的正則表達式:/^(a)?b\1$/,與上面相似,若是要匹配的字符串是 aba,毫無疑問,能夠匹配成功,可是對於 ba 或是 b 呢?因爲第一個分組總體都是可選的,因此此時,第一個分組將不存在。此時後面須要引用 \1,對於大多數正則引擎來講,bab 都將會致使失敗。可是 JavaScript 是一個例外,對於 JavaScript 來講,即使第一個分組是可選的,在它不存在的時候,它也表示一個空分組。因此對 JavaScript 來講,ba 匹配失敗,可是 b 能夠成功匹配。

對於相似於 /(test)\7//(test)\12/ 這樣,分組數只有 1 個,但卻引用了 7 號分組、12 號分組,這在絕大多數正則引擎中都是一個錯誤。可是,JavaScript 因爲支持八進制轉義符,因此,若是分組不存在,JavaScript 會嘗試將其解釋爲一個八進制字符,因此 \7\12 都是合法的,可是 \8\9 是不合法的八進制字符,就屬於錯誤。而 Java 中,對不存在的組的引用將不會匹配任何內容。在 .NET 中,雖然支持八進制轉義符,可是必須是兩位數的八進制,因此,\7 屬於錯誤,而 \12 確是合法的。

注:對 JavaScript 和 .NET 來講,只有分組號不存在時,纔會嘗試解釋爲八進制轉義符。

11、非捕獲組與命名組

在「分組」中,咱們知道正則中可使用一對小括號 () 來建立一個分組,而後在「分組引用」中,咱們學會了使用序號來引用一個分組的內容。

可是,在不少狀況下,分組也被用來協助重複、枚舉,而這些分組的存在會干擾咱們對分組的統計計數,致使後面進行分組引用的時候編號很難肯定。

爲了方便計數,咱們能夠將不須要被引用的分組設置爲「非捕獲組」,只要在分組的開頭,括號內,添加 ?: 便可。

示例:

/^(130|131)(\d{4})\2$/ 匹配 130、131 號段,後面 8 位數字前四位和後四位相同的手機號碼,好比 1301234123413156785678

/^(?: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 結束標籤的結尾。

12、單詞邊界

在正則表達式中,有一些僅僅表明「位置」的符號,好比 ^ 表示字符串的開頭位置,$ 表示字符串結尾的位置。實際上,正則還有一個表示位置的符號 \b,它表示「單詞邊界」。

它與上一篇中提到的 \d\w\s 不同,不屬於字符類,它僅僅表示一個特殊的位置,這個位置一般有這樣的特徵:

  1. 若是整個字符串第一個字符是單詞字符,則表示與 ^ 相同的位置
  2. 若是整個字符串最後一個字符是單詞字符,則表示與 $ 相同的位置
  3. 若是字符串中間的某兩個連續的字符,其中一個是單詞字符,而另外一個不是,則表示這兩個字符中間的位置

具體哪些字符屬於單詞字符,也是取決於所使用的正則表達式引擎的,一般來講,能被 \w 匹配的字符都是單詞字符。可是 Java 是個例外,在 Java 中,部分 Unicode 字符能夠做爲 \b 的單詞字符,但卻沒法被 \w 匹配。

示例:

/\bbed\b/ 匹配獨立爲單詞的 bed,好比 Lying in bed, but can't sleepThere is a bed in the corner,可是 Sleeping in the bedroom 就沒法匹配到了。

十3、貪婪與懶惰

還記得上一篇提到的「重複」嗎?除了 {m} 這樣的固定次數匹配,其餘的「重複」的具體重複次數都是不肯定的,好比 + 能夠匹配至少一次,但上不封頂。

這就麻煩了,考慮這個示例:/<.+>/,咱們想要使用這個正則表達式來匹配相似於 <div> 這樣的字符串,可是,若是你的字符串是 <div>Test</div>,你會發現,匹配結果是整個字符串,也的確,整個字符串也的確知足正則的條件,畢竟 . 表示任意字符,因此 div>Test</ 都是知足條件的。

在正則裏,不定次數的重複都是「貪婪」的,貪婪的意思就是它會盡量多的匹配字符,因此上面這個例子中,< 匹配完第一個字符後,.+ 開始重複任意次,因此它會一直日後找,直到找到一個不知足 . 的字符,可是後面都是匹配的,因此,就匹配到最後一個字符,而後再去嘗試找知足 > 的字符。可是因爲 .+ 已經匹配了全部字符,所以 > 沒法匹配到,因此 .+ 須要作一個讓步,放出一個字符 >,使得 > 匹配成功。

爲了使上面的例子符合咱們的要求,只匹配到第一個 > 就結束,咱們能夠將重複轉爲「懶惰」模式,只須要在重複符號後加一個 ? 便可,好比 + 變爲 +?* 變爲 *?? 變爲 ??{m,n} 變爲 {m,n}?。上面的例子能夠改爲 /<.+?>/,就能夠匹配到 <div>Test</div> 中的 <div> 了。

在使用貪婪模式的時候,必定要當心!由於正則表達式在遇到貪婪重複的時候,會一直日後遞歸匹配,直到發現第一個不知足條件的結果時,纔會一點一點向前回溯,直到找到知足條件的結果,或者回溯到原點,纔會匹配失敗。這個過程是比較危險的,由於這會致使正則表達式的匹配時間呈指數性爆炸式增加,而且會使 CPU 佔用大量上漲。

2019 年 7 月初,全球知名的 CDN 提供商 Cloudflare 出現了全球範圍的 502 故障,緣由就是因爲使用了一個貪婪匹配的正則表達式。感興趣的讀者能夠閱讀 Cloudflare 的博客(具體講解在博客附錄部分):blog.cloudflare.com/details-of-…

十4、正則表達式選項

若是咱們在進行正則匹配的時候,要忽略大小寫,咱們一般可使用 [a-zA-Z] 來同時匹配小寫與大寫,可是對於複雜狀況,這樣作可能會比較麻煩。

正則表達式一般都支持使用選項進行行爲控制,可是不一樣的正則引擎支持的選項都不盡相同,具體使用的正則引擎支持哪些選項,須要讀者自行查詢相關文檔。

常見的選項有:

  1. i:忽略大小寫
  2. s:單行模式,使得 . 與包括換行符在內的全部字符匹配(默認 . 是不包含換行符的)
  3. m:多行模式,使得 ^$ 再也不只表示整個字符串的開頭和結尾,而是表示每一行的開頭與結尾。

不一樣的正則引擎設置選項的方式也都不一樣,大概總結一下,有如下幾種設置選項的方式:

  1. 對於 JavaScript 這類擁有正則語法的語言,能夠直接將選項追加在正則標記的後面,好比 /Hello/i,這將對整個正則表達式生效,使其忽略大小寫。
  2. 大部分編程語言都提供了正則的構造函數,此函數會接受多個參數,其中包含正則選項參數,將選項傳遞給這個參數便可,好比在 JavaScript 中支持:new RegExp('Hello', 'i'),這也將對整個正則表達式生效,使其忽略大小寫。
  3. 不少正則引擎支持直接在正則表達式中使用 (?flag) 語法設置選項,好比 /(?i)Hello/
    • 在這類容許將正則選項放在正則表達式內部的語言,能夠把選項放在正則表達式內的任意位置,而且這樣一來,此選項僅對從當前位置開始右邊的內容生效,好比 /Hello (?i)World/ 中,Hello 是要求 H 大寫,ello 小寫的,可是 World 則不區分大小寫,因此能夠匹配 Hello WORLDHello WorldHello world,可是 HELLO World 則沒法匹配。
    • 在這類容許將正則選項放在正則表達式內部的語言,若是將選項放在正則表達式的結尾,好比 Hello World(?i) 將沒有任何效果,或者在某些正則引擎中被視爲錯誤。
    • Python 是個例外,對於 Python 來講,將選項放在正則內部的任何位置都將對整個正則表達式生效,因此 Hello World(?i) 會使得整個正則表達式忽略大小寫。

示例:

/^Apple$/i 能夠匹配 AppleappleAPPLEappLEAppLe 等。

十5、零寬斷言

有時,咱們須要爲匹配結果增長一些條件限制,但又不想在匹配結果中包含這些限制條件。什麼意思呢?好比咱們要匹配區號爲 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-1234567012-1234567 等不是由 0123- 開頭的就會匹配失敗。

/(?<!\d{4}-)\d{8}/ 能夠匹配前面跟的不是 4 位數字加一個 - 的 8 位任意數字,好比 012-12345678 匹配結果爲 1234567801212345678 匹配結果爲 01212345。而 0123-12345678 前面跟了 4 位數字加一個 -,因此匹配失敗。

/[a-z]+(?=ed)/ 能夠匹配 ed 結尾的字母串,好比 ended 匹配結果爲 endopened 匹配結果爲 openabcededed 匹配結果爲 abceded(貪婪原則)。而相似於 endbe 等不是由 ed 結尾的則會匹配失敗。要注意的是,bedroom 能夠匹配成功,匹配結果爲一個字母 b

/\d{4}(?!\.12)/ 能夠匹配不是由 .12 結尾的 4 位數字,好比 1234.56 匹配結果爲 12341234.1 匹配結果爲 123412345.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。這是兩個條件,要同時知足。好比 130123456781234130123413056781234 等。

使用現有的正則能力,是否能夠完成呢?

若是僅僅檢查長度:/^\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* 所匹配到的內容。

十6、條件語句

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 日),好比 201202292013043020150531 等,可是 20190230201306312015074020181305 就沒法匹配。

這裏首先 \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)$/ 能夠匹配 abcbd,其餘組合都不能匹配

/^(?<x>a)?b(?('x')c|d)$/ 同上,使用命名捕獲組

因爲第一個分組 (a)? 是可選的,所以後面判斷第一個分組是否存在,若存在則條件成立,匹配 c 不然匹配 d

十7、遞歸重複與平衡組

有時咱們須要匹配一個遞歸嵌套的字符串,好比 XML 標籤、前綴表達式等。他們的特色是層層嵌套,每一層都有區別,但又有特定的規則。

在軟件開發的時候,咱們一般會使用遞歸函數來作一些嵌套、重複的事情。

正則中也有相似的東西:Perl、PCRE、Ruby 等正則引擎支持遞歸重複,而 .NET 支持平衡組。

遞歸重複

遞歸重複在不一樣的正則引擎中語法也不太同樣,在 Perl 中,使用 (?R)(?0);在 Ruby 中,使用 \d<0>;PCRE(以及 PHP、Delphi、R 這些基於 PCRE 的正則引擎)同時支持 Perl 和 Ruby 的這三種語法。

正則在匹配的時候,若是碰到遞歸重複標記,將會從當前位置開始,將整個正則表達式從新匹配一遍,一層一層遞歸,直到最內層匹配結束以後,再一層一層往上回溯。若其中一層匹配失敗,將會致使整個遞歸匹配失敗。

示例:

/([a-z])(?R)??\1/ 能夠匹配字符數爲偶數個的迴文字,好比 aaabbaabccbaabcddcba 等。而字符數爲奇數個,或者不是迴文,則匹配失敗,好比 abaabcbaabab 都會匹配失敗。

/\([+\-*\/](?:\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)(?!))$/ 能夠匹配任意迴文字,好比 aaabaabbaabcbaabccba 等。

這個例子中,先是一個 (?<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 出一個後繼續匹配 [^<>]*? 任意個非括號字符。

十8、棄坑

實際上就是這樣,正則還有不少東西沒有提到,感興趣的讀者能夠去 www.regular-expressions.info/tutorial.ht… 看到更多更詳細的介紹。

是否是看的愈來愈迷糊?這仍是上一篇中的那個簡單高效的文本查找匹配工具嗎?如今怎麼有點懷疑人生了?

emmmmmm……

的確是這樣的,正則本應該簡單高效,再軟件開發過程當中,複雜的邏輯理應交給編程語言去實現。而像這種條件語句、遞歸循環之類的就沒有必要用正則去寫了。

可是,存在即合理,既然提供了這樣的語法功能,那麼就必定有應用場景。這些複雜語法功能,看過了,瞭解一下,語法不必定記住,至少知道有這麼個東西,再碰到特定場景的時候,可以想起來,老是會喊一聲「真香」的。



記得要點贊、分享、評論三連,更多精彩內容請關注ihap 技術黑洞!

相關文章
相關標籤/搜索