官术网_书友最值得收藏!

第3章 括號

3.1 分組

用字符組和量詞可以匹配引號字符串,也可以匹配HTML tag,如果需要用正則表達式匹配身份證號碼,依靠字符組和量詞能不能做到呢?

身份證號碼是一個長度為15或18個字符的字符串,如果是15位,則全部由數字組成,首位不能為0;如果是18位,則前17位全部是數字,末位可能是數字,也可能是x一般來說,最后的x可以是小寫也可以是大寫,但也有些部門規定身份證號碼最后的x必須是大寫X,這里為講解方便,只考慮了小寫x的情況;如果要兼容大寫X或保證只能出現大寫X,只需要修改最后的字符組[0-9x]即可。。規則非常明確,可以嘗試編寫正則表達式了。

整個表達式是[1-9]\d{13,16}[0-9x],它的匹配如例3-1所示。

例3-1身份證號碼的匹配

      idCardRegex = r"^[1-9]\d{13,16}[0-9x]$"
      re.search(idCardRegex, "110101198001017032") != None  #  => True
      re.search(idCardRegex, "1101018001017016") != None    #  => True
      re.search(idCardRegex, "11010119800101701x") != None  #  => True

看來,果然能夠匹配各種形式的身份證號碼,應該沒問題。不過這還不夠,這個正則表達式應該保證身份證號碼的字符串能夠匹配,其他字符串不能夠匹配,例3-2展示了非身份證號碼的匹配情況。

例3-2身份證號碼的錯誤匹配

      re.search(idCardRegex, "1101011980010176") != None    #  => True
      re.search(idCardRegex, "110101800101701x") != None    #  => True

這兩個字符串分明不是身份證號碼(第一個有16位長,第二個雖然有15位長,但末尾是x),卻都匹配了。這是為什么呢?仔細觀察所用的正則表達式,會發現兩點原因:第一,\d{13,16}表示除去首尾兩位,中間的部分長度可能在13~16之間,而不是“長度要么為13,要么為16”;第二,最后的[0-9x]只應該對應18位身份證號碼的情況,但是在這個表達式中,它也可以對應到15位身份證號碼,而15位身份證號碼的末位是不能為x的!

雖然字符串的長度是可變的,但是除去第一位和最后一位,中間部分的長度必須明確指定,只能是13或者16,而不能使用量詞{13,16};另外,末尾一位到底是[0-9](也就是\d)還是[0-9x],取決于長度——如果長度是15位,則是\d;如果長度是18位,則是[0-9x]。區分兩種情況分別考慮,要更加清楚一些。

看來,只要以15 位號碼的匹配為基礎,末尾加上可能出現的\d{2}[0-9x]即可。這里的\d{2}[0-9x]必須作為一個整體,或許不出現(15位號碼),或許出現(18位號碼)。量詞?可以表示“不出現,或者出現1次”,正好用在這里。

但是,在正則表達式\d{2}[0-9x]?中,量詞?只能限定[0-9x]的出現,而\d{2}?[0-9x]?則更奇怪——即使只出現一個[0-9x],也可以匹配。到底怎樣才能把\d{2}[0-9x]作為一個整體呢?

答案是:使用括號(…),把正則表達式改寫為[1-9]\d{14}(\d{2}[0-9x])?。上一章提到過,量詞限定之前元素的出現,這個元素可能是一個字符,也可能是一個字符組,還可能是一個表達式——如果把一個表達式用括號包圍起來,這個元素就是括號里的表達式,括號內的表達式通常被稱為“子表達式”。所以,(\d{2}[0-9x])?就表示子表達式\d{2}[0-9x]作為一個整體,或許不出現,或許最多出現一次。從例3-3可以看到,這個表達式確實可以準確匹配身份證號碼。

例3-3身份證號碼的準確匹配

      idCardRegex = r"^[1-9]\d{14}(\d{2}[0-9x])?$"
      #應該匹配的
      re.search(idCardRegex, "110101198001017016") != None  #  => True
      re.search(idCardRegex, "1101018001017016") != None    #  => True
      re.search(idCardRegex, "11010119800101701x") != None  #  => True
      #不應該匹配的
      re.search(idCardRegex, "1101011980010176") != None    #  => False
      re.search(idCardRegex, "110101800101701x") != None    #  => False

注:為了方便講解,我們在正則表達式的兩端添加了^$,它們分別定位到字符串的起始位置和結束位置,這樣確保了表達式不會只匹配字符串的某個子串;如果要用表達式來提取數據,應當去掉^$。下面的例子都遵循這條規則。

括號的這種功能,叫做分組(grouping)。如果用量詞限定出現次數的元素不是字符或者字符組,而是幾個字符甚至表達式,就應該用括號將它們“分為一組”。比如,希望字符串ab重復出現一次以上,就應該寫作(ab)+,此時(ab)成為一個整體,由量詞+來限定;如果不用括號而直接寫ab+,受+限定的就只有b。例3-4顯示了有括號與無括號的表達式的匹配異同。

例3-4用括號改變量詞的作用元素

      re.search(r"^ab+$", "ab") != None       #  => True
      re.search(r"^ab+$", "abb") != None      #  => True
      re.search(r"^ab+$", "abab") != None      #  => True
      re.search(r"^(ab)+$", "ab") != None      #  => True
      re.search(r"^(ab)+$", "abb") != None     #  => False
      re.search(r"^(ab)+$", "abab") != None    #  => True

有了分組,就可以準確表示“長度只能是mn”。比如在上面匹配身份證號碼的例子中,要匹配一個長度為13或者16的數字字符串。常犯的錯誤是使用表達式\d{13,16},看起來沒問題,但長度為14或15的數字字符串同樣會匹配。真正準確的做法是:首先匹配長度為13的數字字符串,然后匹配可能出現的長度為3的數字字符串,正則表達式就成了\d{13}(\d{3})?

分組是非常有用的功能,因為使用正則表達式時經常會遇到并沒有直接相連,但確實存在聯系的部分,分組可以把這些概念上相關的部分“歸攏”到一起,以免割裂,下面來看幾個例子。

上一章使用表達式<[^/][^>]*>匹配HTML中的open tag,比如<table>,但是這個表達式會匹配self-closing tag,比如<br />。如果把表達式改為<[^/][^>]*[^/]>,確實可以避免匹配self-closing tag,但是因為兩個排除型字符組要匹配兩個字符,這個表達式又會放過<u>之類的open tag,僅僅依靠字符組和量詞無法配合解決問題,必須用到括號的分組功能。

<[^/][^>]*[^/]>錯過的只有一種情況,就是tag name為單個字母的情況。如果tag name不是單個字母,則第一個字母之后,必然會出現這樣一個字符串:其中不包含>,結尾的字符并不是/。最后,才是tag結尾的>。像圖3-1所示那樣,將這幾個元素拆開,能看得更清楚點。

圖3-1 open tag的準確匹配

所以,需要用一個括號將可選出現的部分分組,再用量詞?限定,就可以得到兼顧這兩種情況,準確匹配open tag的正則表達式了,程序代碼如例3-5所示。

例3-5準確匹配open tag

      openTagRegex = r"^<[^/]([^>]*[^/])?>$"
      re.search(openTagRegex, "<u>") != None       #  => True
      re.search(openTagRegex, "<table>") != None   #  => True
      re.search(openTagRegex, "<u/>") != None      #  => False
      re.search(openTagRegex, "</table>") != None  #  => False

再看個更復雜的例子。在Web服務中,經常并不希望暴露真正的程序細節,所以用某種模式的URL來掩蓋。比如這個URL:/foo/bar_qux.php,看起來是訪問一個PHP頁面,其實完全不是這樣。真正的結構如圖3-2所示,foo是模塊的名稱,bar是控制器的名字,qux則是方法名,三個名字都只能出現小寫字母。

圖3-2 URL的結構

希望能處理的情況有三種,其他情況都不予考慮。

為編寫通用的正則表達式來匹配,許多人是這么總結的。

所以正則表達式就是:/[a-z]+/?[a-z]*_?[a-z]*(\.php)?

仔細看看這個表達式,無論是/foo,還是/foo/bar.php,抑或是/foo/bar_qux.php,都可以匹配,看起來確實沒有問題。

可是,這個表達式中只有/[a-z]+是必須出現的,其他部分都是“不一定出現”——也就是說,其中任意一個或幾個部分出現,這個表達式都可以匹配。所以,/foo/_也是可以匹配的,/foo.php也是可以匹配的,如例3-6所示。

例3-6 URL匹配的表達式這里有個下畫線

      urlPatternRegex = r"^/[a-z]+/?[a-z]*_?[a-z]*(\.php)?$"
      re.search(urlPatternRegex, "/foo") != None               #  => True
      re.search(urlPatternRegex, "/foo/bar.php") != None        #  => True
      re.search(urlPatternRegex, "/foo/bar_qux.php") != None     #  => True
      re.search(urlPatternRegex, "/foo/_") != None              #  => True
      re.search(urlPatternRegex, "/foo.php") != None            #  => True

之所以會亂套,根源在于有些元素雖然是“不一定出現”的。可是,“不一定出現”的元素之間卻是有關聯的:“不一定出現”的元素雖然沒有直接相連,卻是“要么同時出現,要么同時不出現”的關系。這時候就要梳理清楚邏輯關系,用括號的分組功能把各種分支情況歸攏到一起。

/foo是必須出現的;之后存在兩種可能:/bar.php或者/bar_qux.php。前一種情況中,開頭的/、控制器名bar、結尾的.php是必須出現的;在后一種情況中,開頭的/、控制器名bar、下畫線_、模塊名qux、結尾的.php是必須出現的。

仔細觀察這兩個表達式,會發現它們可以合并:把第二個表達式中多出的部分,繼續用分組?,再加上最開頭“必須出現”的/foo括號配合量詞?表示,塞到第一個表達式中,得到的表達式配合量詞,最后得到完整的表達式。

從例3-7可以看到,這個表達式確實杜絕了錯誤的匹配。

例3-7杜絕了錯誤匹配的表達式

      urlPatternRegex = r"^/[a-z]+(/[a-z]+(_[a-z]+)?\.php)?$"
      re.search(urlPatternRegex, "/foo") != None           #  => True
      re.search(urlPatternRegex, "/foo/bar.php") != None    #  => True
      re.search(urlPatternRegex, "/foo/bar_qux.php") != None     #  => True
      re.search(urlPatternRegex, "/foo/_") != None              #  => False
      re.search(urlPatternRegex, "/foo.php") != None            #  => False

關于括號的分組功能,最后來看E-mail地址的匹配: E-mail地址以@分隔為兩段,之前的是用戶名(username),之后的是主機名(hostname),用戶名一般只容許出現數字和字母(現在有些郵件服務商也容許用戶名中出現點號等字符了,這種情況復雜些,此處不做考慮),而主機名則是類似mail.google.commail.163.com之類的字符串。

用戶名的匹配非常簡單,其中能出現的字符主要有大寫字母[A-Z]、小寫字母[a-z]、阿拉伯數字字符[0-9],下畫線_、點號.,所以總的字符組就是[A-Za-z0-9_.],又可以簡化為[\w.];另一方面,用戶名的最大長度是64 個字符,所以匹配用戶名的正則表達式就是[\w.]{0,64}

主機名匹配的情況則要麻煩一些,簡單的情況比如somehost.com;復雜的情況則還包括子域名,比如 mail.somehost.net,而且子域名可能不只一級,比如 mail.sub.somehost.net。查閱規范可知,主機名被點號分隔為若干段,叫做域名字段(label),每個域名字段中能出現的字符是字母字符、數字字符和橫線字符,長度必須在1~63 之間。下面看幾個例子,嘗試從中找到主機名的規律。

看來規律是這樣的:最后的域名字段是頂級域名雖然常見的頂級域名是cn、com、info之類的,最長4個字母,最短2個字母,但情況并非都是如此,在有些內部系統中,主機名中并不包含點號,可以視為頂級域名,所以這里認為頂級域名也是一個普通域名字段。,之前的部分可以看作某種模式的重復:該模式由域名字段和點號組成,域名字段在前,點號在后。比如somehost.com就可以這么看:頂級域名是 com,之前是 somehost.sub.somehost.net就可以這么看:頂級域名是 net,之前是sub.somehost.。

匹配域名字段的表達式是[-a-zA-Z0-9]{1,63},匹配點號的表達式是\.,使用括號的分組功能,把這兩個表達式分為一組,用量詞*限定表示“不出現,或出現多次”,就得到匹配主機名的表達式([-a-zA-Z0-9]{1,63}\.)*[-a-zA-Z0-9]{1,63}(因為頂級域名也是一個域名字段,所以即便主機名是localhost,也可以由最后那個匹配域名字段的表達式匹配)。

將匹配用戶名的表達式、@符號、匹配主機名的表達式組合起來,就得到了完整的匹配E-mail地址的表達式:[-\w.]{0,64}@([-a-zA-Z0-9]{1,63}\.)*[-a-zA-Z0-9]{1,63},這個表達式的匹配情況如例3-8所示。

例3-8完整匹配E-mail地址的正則表達式

      emailRegex = r"^[-\w.]{0,64}@([-a-zA-Z0-9]{1,63}\.)*[-a-zA-Z0-9]{1,63}$"
      #應該匹配的
      re.search(emailRegex, "abc@somehost") != None             #  => True
      re.search(emailRegex, "abc@somehost.com") != None         #  => True
      re.search(emailRegex, "abc@some-host.com") != None        #  => True
      re.search(emailRegex, "123@somehost.info") != None        #  => True
      re.search(emailRegex, "abc123@somehost.info") != None      #  => True
      re.search(emailRegex, "abc123@sub.somehost.com") != None   #  => True
      re.search(emailRegex, "abc123@m-s.sub.somehost.com") != None   #  => True
      #不應該匹配的
      re.search(emailRegex, "abc@.somehost.com") != None        #  => False
      re.search(emailRegex, "a#bc@some-host.commnication") != None   #  => False

3.2 多選結構

之前用表達式[1-9]\d{14}(\d{2}[0-9x])?匹配身份證號,思路是把18位號碼多出的3位“合并”到匹配15位號碼的表達式中。能不能直接分情況處理呢?15 位身份證號就是[1-9]開頭,之后是14 位數字;18 位身份證號就是[1-9]開頭,之后是16 位數字,最后是[0-9x]?。只要兩個表達式中的一個能夠匹配,就是合法的身份證號,這樣的思路更加清晰。

答案是可以的,而且仍然使用括號解決問題,只是要用到括號的另一個功能:多選結構(alternative)。

多選結構的形式是(…|…),在括號內以豎線|分隔開多個子表達式,這些子表達式也叫多選分支(option);在一個多選結構內,多選分支的數目沒有限制。在匹配時,整個多選結構被視為單個元素,只要其中某個子表達式能夠匹配,整個多選結構的匹配就成功;如果所有子表達式都不能匹配,則整個多選結構匹配失敗。

回到身份證號碼匹配的例子,既然可以區分15位和18位兩種情況,就可以將每種情況對應的表達式作為一個分支,使用多選結構([1-9]\d{14}|[1-9]\d{14}\d{2}[0-9x])。這個表達式的匹配如例3-9所示,它同樣可以準確驗證身份證號碼。

例3-9用多選結構匹配身份證號碼

      idCardRegex = r"^([1-9]\d{14}|[1-9]\d{14}\d{2}[0-9x])$"
      #應該匹配的
      re.search(idCardRegex, "110101198001017016") != None  #  => True
      re.search(idCardRegex, "1101018001017016") != None    #  => True
      re.search(idCardRegex, "11010119800101701x") != None  #  => True
      #不應該匹配的
      re.search(idCardRegex, "1101011980010176") != None    #  => False
      re.search(idCardRegex, "110101800101701x") != None    #  => False

多選結構在實際中經常用到,匹配IP地址就是如此:IP地址(暫不考慮IPv6)分為四段(四個字節),每段都是八位二進制數,換算成常見的十進制,取值在0~255之間,中間以點號.分隔。點號.的匹配非常容易,用\.就可以,所以暫且忽略它,只考慮匹配這個數值的問題,而且因為4段IP地址的取值范圍是相同的,只考慮其中一段的匹配即可。

要匹配十進制形式的IP地址,最常見的正則表達式就是[0-9]{1,3},也就是1~3位十進制數字。粗看起來,這個表達式沒什么錯,細看卻有很大問題。因為256999這樣的數值,顯然不在0~255之間,卻可以由[0-9]{1,3}匹配。

細致一點的表達式似乎是[0-2][0-5][0-5],這樣就限制了數值只能是在255以內……不過,仔細想想,因為限定了第二位(十位)和第三位(個位)都只能出現0~5之間的字符,表達式沒法匹配168之類的數值。

其實,問題可以這樣解決:先用表達式匹配這個字符串,再將它轉換為整數類型的變x,判斷x是否在0和255之間:0<=x && x<=255。沒錯,這確實是一個解決問題的思路,只是麻煩一點,最好能用正則表達式“一次性”搞定這個問題。仔細想想就會發現,正則表達式雖然直接表示“匹配一段數值在0~255之間的文本”,但可以分幾種情況描述符合這樣規則的文本。

雖然不如 0<=x && x<=255 的判斷簡便,但如果文本符合其中任何一條規則(或者說,只要其中任何一個正則表達式能匹配),就可以判斷它“表示數字的數值在0~255之間”。用多選結構把這幾條規則對應的表達式合并起來,就得到了表達式 ([0-9]|[0-9]{2}|1[0-9][0-9]|2 [0-4][0-9]|25[0-5]),它的匹配如例3-10所示。

例3-10準確匹配0~255之間的字符串

      partRegex = r"^([0-9]|[0-9]{2}|1[0-9][0-9]|2[0-4][0-9]|25[0-5])$"
      #應該匹配的
      re.search(partRegex, "0") != None   #  => True
      re.search(partRegex, "98") != None  #  => True
      re.search(partRegex, "168") != None  #  => True
      #不應該匹配的
      re.search(partRegex, "256") != None  #  => False

如果要更完善一點,能識別030005這樣的數值,可以修改對應的子表達式,為一位數和兩位數的情況增加之前可能出現 0 的匹配,得到表達式((00)?[0-9]|0?[0-9]{2}|1[0-9] [0-9]|2[0-4][0-9]|25[0-5])

上面講解的,其實是用正則表達式匹配數值在某個范圍內的字符串的通用模式,它很重要,因為許多時候會遇到類似的任務,比如匹配月(1~12)、日(不考慮只有30 天的情況,粗略記為1~31)、小時(0~24)、分鐘(00~60)的正則表達式,用正則表達式解決這類問題,會用到同樣的模式。

這個模式還可以用于匹配手機號碼:大陸的手機號碼是11位的,前面3位是號段,到目前為止有130~139 號段、150~153、155~156、180、182、185~189 號段,用多選分支(13[0-9]|15[0-356]|18[025-9])可以很準確地匹配號段;之后的8 位一般沒有限制,只要是數字即可,用\d{8}匹配。另外,手機號碼最開頭可能有 0 或者+86,它可以用(0|\+86)匹配,因為整個部分是可能出現的,所以需要加上量詞,也就是(0|\+86)?最后得到的正則表達式就是(0|\+86)?(13[0-9]|15[0-356]|18[025-9])\d{8}

多選結構還可以解決更復雜的問題,比如上一章的tag匹配問題,當時使用的表達式是<[^>]+>,一般來說,這個表達式是沒有問題的,但也有可能tag內部還是會出現>符號,比如<input name=txt value=">">。這類問題使用字符組解決不了,使用多選結構則可以解決。

仔細分析tag中可能出現>它只可能作為屬性(attribute)出現在單引號字符串和雙引號字符串中,根據html規范,引號字符串中不能出現嵌套轉義的引號,所以單引號字符串可以用'[^']*'來匹配,雙引號字符串可以用"[^"]*"來匹配,相應的,其他內容則可以用[^'">]來匹配,所以更完善的表達式是<('[^']*'|"[^"]*"|[^'">])+>。它的匹配情況見例3-11。

例3-11準確的HTML tag匹配

      tagRegex = r"^<('[^']*'|\"[^\"]*\"|[^'\">])+>$"
      re.search(tagRegex, "<input name=txt value=\">\">") != None    #  => True
      re.search(tagRegex, "<input name=txt value='>'>") != None      #  => True
      re.search(tagRegex, "<a>") != None                           #  => True

請注意其中的量詞,因為單引號字符串和雙引號字符串都可以是空字符串,比如 alt=''alt="",所以匹配其中文本的內容使用*;而[^'">]則沒有使用量詞,因為它存在于多選結構內部,多選結構外部有+量詞限制,保證了它不只匹配一個字符。如果在多選結構內部使用[^'">]*,雖然看來似乎沒錯,卻可能導致非常奇怪的結果,不過現在不用關心,詳細的講解在第135頁。

關于多選結構,最后還要補充三點。

第一,多選結構的一般表示法是(option1|option2)(其中option1option2是兩個作為多選分支的正則表達式),多選結構中一般會同時使用括號()和豎線|;但是如果沒有括號(),只出現豎線|,仍然是多選結構。從例3-12可以看到,ab|cd既可以匹配ab,也可以匹配cd

例3-12沒有括號的多選結構

      re.search(r"ab|cd", "ab") != None   #  => True
      re.search(r"ab|cd", "cd") != None   #  => True

在多選結構中,豎線|用來分隔多選結構,而括號()用來規定整個多選結構的范圍,如果沒有出現括號,則將整個表達式視為一個多選結構,所以ab|cd等價于(ab|cd)。如果在某些文檔中看到沒有括號的多選結構,不用奇怪。

不過,我還是推薦明確寫出兩端的括號,這樣更形象,也能避免一些錯誤。如果你仔細看,就會發現在上面的表達式中,并沒有使用^$定位字符串的起始位置和結束位置,按道理說,加上之后應該匹配更加準確,結果卻并非如此。

因為豎線|的優先級很低(關于優先級,?106),所以^ab|cd$其實是(^ab|cd$),而不是^(ab|cd)$,它的真正意思是“字符串開頭的 ab 或者字符串結尾的 cd”,而不是“只包含 abcd的字符串”,代碼見例3-13。

例3-13沒有括號的多選結構

      re.search(r"^ab|cd$", "abc") != None     #  => True
      re.search(r"^ab|cd$", "bcd") != None     #  => True
      re.search(r"^(ab|cd)$", "abc") != None   #  => False
      re.search(r"^(ab|cd)$", "bcd") != None   #  => False

第二,多選分支并不等于字符組。多選分支看起來類似字符組,如[abc]能匹配的字符串和(a|b|c)一樣,[0-9]能匹配的字符串和(0|1|2|3|4|5|6|7|8|9)一樣。從理論上說,可以完全用多選結構來替換字符組,但這種做法并不推薦,理由在于:首先,[abc](a|b|c)要簡潔許多,在多選結構中的每個分支都必須明確寫出,不能使用-范圍表示法,(0|1|2|3|4|5|6|7|8|9)[0-9]麻煩很多;其次,大多數情況下,[abc](a|b|c)的效率要高很多。所以,能用字符組解決的問題,最好不用等價的多選結構。

反過來,多選結構不一定能對應到字符組。因為字符組的每個“分支”的長度相同,而且只能是單個字符;而多選結構的每個“分支”的長度沒有限制,甚至可以是復雜的表達式,比如(abc|b+c*ab),字符組完全無能為力。

多選分支和字符組的另一點重要區別(同時也是最常犯的錯誤)是:排除型字符組可以表示“無法由某幾個字符匹配的字符”,多選結構卻沒有對應的結構表示“無法由某幾個表達式匹配的字符串”。從例3-14可以看到,[^abc]表示“匹配除abc之外的任意字符”,(^a|b|c)卻不能表示“匹配除abc之外的任意字符串”。

例3-14多選結構不能表示“無法由某幾個表達式匹配的字符串”

      re.search(r"(^a|b|c)", "ab") != None     #  => True
      re.search(r"(^a|b|c)", "cd") != None     #  => True

在實際開發中確實可能遇到這種需求,不過沒有現場的解法。如果你現在就希望匹配“無法由某幾個表達式匹配的字符串”,請參考第140頁。

第三,多選分支的排列是有講究的。比如這個表達式(jeff|jeffrey),用它匹配jeffrey,結果到底是jeff還是jeffrey呢?這個問題并沒有標準的答案,本書介紹的Java、.NET、Python、Ruby、JavaScript、PHP中,多選結構都會優先選擇最左側的分支。這一點從例3-15看得很清楚:如果使用字符串是 jeffrey,正則表達式是(jeff|jefferey)還是(Jeffrey|jeff),結果是不一樣的(此處僅以Python為例,本書中介紹的其他語言中的結果與此相同)。

例3-15多選結構的匹配順序

      print re.search(r"(jeffrey|jeff)", " jeffrey").group(0)
      jeffrey
      print re.search(r"(jeff|jeffrey)", " jeffrey").group(0)
      jeff

在實際開發中可能會遇到這樣的情況:統計一段文本中,“湖南”和“湖南省”分別出現的次數。如果直接查找“湖南”,可能會將“湖南省”中的“湖南”也找出來,如果使用多選結構(湖南省|湖南),就可以一次性找出所有“湖南”和“湖南省”,再按照字符串的長度分別計數,就可以得到兩者出現的次數了。

不過,(湖南省|湖南)只是一個針對特殊應用的例子。在平時使用中,如果出現多選結構,應當盡量避免多選分支中存在重復匹配,因為這樣會大大增加回溯的計算量。也就是說,應當避免這樣的情況:針對多選結構(option1|regex2),某段文本既可以由 option1匹配,也可以由option2匹配。如果出現了這樣的多選結構,效率可能會受到極大影響(第160頁總結了可能影響效率的幾種寫法),尤其在受量詞限定的多選結構中更是如此:一般人都不會遇到(a|[ab])這類多選結構,但([0-9]|\w)之類則一不留神就會遇到。

3.3 引用分組

括號不僅僅能把有聯系的元素歸攏起來并分組,還有其他的作用——使用括號之后,正則表達式會保存每個分組真正匹配的文本,等到匹配完成后,通過 group(num)之類的方法“引用”分組在匹配時捕獲的內容(這個方法之前已經出現過)。其中,num表示對應括號的編號,括號分組的編號規則是從左向右計數,從1開始。因為“捕獲”了文本,所以這種功能叫做捕獲分組(capturing group)。對應的,這種括號叫做捕獲型括號。

舉個例子,我們經常遇到諸如 2010-12-222011-01-03這類表示日期的字符串,希望從中提取出年、月、日之類的信息,就可借助捕獲分組來實現。正則表達式中,每個捕獲分組都有一個編號,具體情況如圖3-3所示。

圖3-3 分組及編號

一般來說,正則表達式匹配完成之后,都會得到一個表示“匹配結果”的對象,對它調用獲取分組的方法,傳入分組編號 num,就可以得到對應分組匹配的文本。第1 章介紹過,如果匹配成功,re.search()返回一個MatchObject對象。如果只需要知道“是否能匹配”,判斷它是否為None即可;但如果獲取了MatchObject對象,也可以通過對應的方法,顯示匹配結果的詳細信息。使用MatchObject.group(num),就可以引用正則表達式中編號為 num 的分組匹配的文本。從例3-16可以看到,通過引用編號為1、2、3的捕獲分組,分別獲得了年、月、日的信息。

例3-16引用捕獲分組

      print re.search(r"(\d{4})-(\d{2})-(\d{2})", "2010-12-22").group(1)
      2010
      print re.search(r"(\d{4})-(\d{2})-(\d{2})", "2010-12-22").group(2)
      12
      print re.search(r"(\d{4})-(\d{2})-(\d{2})", "2010-12-22").group(3)
      22

前面說過,num的編號從1開始。不過,也有編號為0的分組,它是默認存在的,對應整個表達式匹配的文本。在許多語言中,如果調用group()方法,不給出參數num,默認就等于調用group(0),比如Python就是如此,代碼見例3-17。

例3-17默認存在編號為0的分組

      print re.search(r"(\d{4})-(\d{2})-(\d{2})", "2010-12-22").group(0)
      2010-12-22
      print re.search(r"(\d{4})-(\d{2})-(\d{2})", "2010-12-22").group()
      2010-12-22

有些正則表達式里可能包含嵌套的括號,比如在上面的例子中,除了能單獨提取出年、月、日之外,再給整個表達式加上一重括號,就出現了嵌套括號,這時候括號的編號是怎樣的呢?答案很簡單:無論括號如何嵌套,分組的編號都是根據開括號出現順序來計數的;開括號是從左向右數起第多少個開括號,整個括號分組的編號就是多少。圖3-4舉例說明了這種編號規則,具體的代碼見例3-18。

圖3-4 分組編號只取決于開括號出現的順序

例3-18嵌套的括號

      nestedGroupingRegex = r"(((\d{4})-(\d{2}))-(\d{2}))"
      print re.search(nestedGroupingRegex, "2010-12-22").group(0)
      2010-12-22
      print re.search(nestedGroupingRegex, "2010-12-22").group(1)
      2010-12-22
      print re.search(nestedGroupingRegex, "2010-12-22").group(2)
      2010-12
      print re.search(nestedGroupingRegex, "2010-12-22").group(3)
      2010
      print re.search(nestedGroupingRegex, "2010-12-22").group(4)
      12
      print re.search(nestedGroupingRegex, "2010-12-22").group(5)
      22

上一章用正則表達式<a\s[\s\S]+?</a>提取HTML中的所有的超鏈接tag,配合括號的分組功能,可以更進一步,依靠引用分組把超鏈接的地址和文本分別提取出來。通常的超鏈接tag類似這樣:<a href="url">text</a>。其中url是超鏈接地址,text是文本,為了準確獲取這兩部分內容,可以把表達式改為<a\s+href="([^"]+)">([^<]+)</a>

其中給匹配url和text的表達式分別加上括號,就是([^"]+)和([^<]+)(注意其中<a之后是\s+,因為這里需要的是空白字符,而不限定是空格字符,而且可能不止一個字符)。

當然這只是最簡單的情況,在等號=兩端可能還有空白字符,比如<a href = "url">text</a>,所以正則表達式中的=兩端也應該添加\s*,于是得到<a\s+href\s*=\s*"([^"]+)">([^<]+)</a>

不過,屬性既可以用雙引號字符串表示,也可以用單引號字符串表示,比如<a href='url'>text</a>;甚至可以不用引號,比如<a href=url>text</a>。為了處理這兩種情況,需要繼續改造表達式:首尾出現的單引號或者雙引號字符用["']?即可匹配;真正的URL,既不能包含單引號,也不能包含雙引號,還不能是空白字符,所以可以用[^"'\s]+匹配,而且這部分是需要提取出來的,別忘了它外面的括號。于是得到了最后的表達式<a\s+href\s*=\s*["']?([^"'\s]+)["']?>([^<]+)</a>

現在表達式已經編寫完畢,第一個括號內的表達式用來匹配url,第二個括號內的表達式用來匹配text,所以如果要提取urltext,應該使用編號為1和2的分組。下面仍然以yahoo.com的首頁為例來看看結果。需要說明的是,如果使用re.findall(),而且正則表達式中出現了捕獲型括號,那么返回數組的每個元素都是數組,其中各個元素對應各個分組的文本,所以直接用下標2訪問得到第二個分組對應的文本,不必顯式調用group(2),代碼見例3-19。

例3-19用分組提取出超鏈接的詳細信息

      # yahoo.com的源代碼已經保存在htmlSource
      hrefTagRegex = r"<a\s+href\s*=\s*[\"']?([^\"'\s]+)[\"']?>([^<]+)</a>"
      for hyperlink in re.findall(hrefTagRegex, htmlSource):
       print hyperlink[2], hyperlink[1]
                Web      http://search.yahoo.com/
                Images   http://images.search.yahoo.com/images
                Video        http://video.search.yahoo.com/video
                ……更多結果未列出

類似的,還可以提取出網頁標題(<head>)或網頁中的圖片鏈接(<img>)的表達式雖然HTML 4.0規范中沒有限制tag名的大小寫,但XHTML規范要求都使用小寫,在平時處理中,tag名也大多使用小寫,如果實在要處理大寫的情況,可以使用字符組,比如img寫作[iI][mM][gG],更省事的辦法是指定不區分大小寫的模式,?81。。應當注意的是,匹配<img>時,在<imgsrc之間可能還有其他內容,比如width=750之類,所以不能僅僅用\s+匹配,而應當添加[^>]*?。在 src=…之后也是同樣如此。表3-1 總結了匹配網頁標題和圖片鏈接的表達式。

表3-1 提取網頁標題和圖片鏈接的正則表達式

應當記住的是,引用分組時,引用的是分組對應括號內的表達式捕獲的文本。在這個問題上,正則表達式新手常犯錯誤。例3-20 仍然是用正則表達式匹配日期字符串,兩個表達式能匹配的字符串是完全相同的,引用分組的編號也是相同的,結果卻不同。

例3-20新手容易弄錯分組的結構

      re.search(r"(\d{4})-(\d{2})-(\d{2})", "2010-12-22").group(1)
      2010
      re.search(r"(\d){4}-(\d{2})-(\d{2})", "2010-12-22").group(1)
      0

在第一個表達式中,編號為1的括號是(\d{4}),其中的\d{4}是“匹配四個數字字符”的子表達式。在第二個表達式中,編號為1的括號是(\d),其中的\d是“匹配一個數字字符”的子表達式,因為之后有量詞{4},所以整個括號作為單個元素,要重復出現4次,而且編號都是1;于是每重復出現一次,就要更新一次匹配結果。所以在匹配過程中,編號為1 的分組匹配的文本的值,依次是 2010,最后的結果是 0。在實際使用時,常常有人忽略了這一細節,得到匪夷所思的匹配結果。

引用分組捕獲的文本,不僅僅用于數據提取,也可以用于替換,有時候這么做非常方便。仍然舉上面的日期的例子,比如希望將 YYYY-MM-DD格式的日期變為 MM/DD/YYYY,就可以使用正則表達式替換。

在Python語言中進行正則表達式替換的方法是 re.sub(pattern, replacement, string),其中pattern是用來匹配被替換文本的表達式,replacement是要替換成的文本,string是要進行替換操作的字符串,比如re.sub(r"[a-z]", " ", string)就是將string中的每一個小寫字母替換為一個空格。程序運行結果如例3-21。

例3-21正則表達式替換

      print re.sub(r"[a-z]", " ", "1a2b3c")
      1 2  3

replacement中也可以引用分組,形式是\num,其中的num是對應分組的編號。不過,replacement并不是一個正則表達式,而是一個普通字符串。根據字符串中的轉義規定,\t表示制表符,\n表示換行符,\1\2卻不是字符串中的合法轉義序列,所以也必須指定replacement為原生字符串(?93)。例3-22說明了如何通過在replacement中使用了引用分組,轉換日期字符串的格式。

例3-22在替換中使用分組

      print re.sub(r"(\d{4})-(\d{2})-(\d{2})", r"\2/\3/\1", "2010-12-22")
      12/22/2010
      print re.sub(r"(\d{4})-(\d{2})-(\d{2})", r"\1\2\3", "2010-12-22")
      2010年12月22日

值得注意的是,如果想在replacement中引用整個表達匹配的文本,不能使用\0,即便用原生字符串也不行。因為在字符串中,\0開頭的轉義序列通常表示用八進制形式表示的字符,\0本身表示ASCII字符編碼為0的字符。如果一定要引用整個表達式匹配的文本,則可以稍加變通,給整個表達式加上一對括號,之后用\1來引用,如例3-23。

例3-23在替換中,使用\1替代\0

      #ASCII編碼為0的字符無法顯示
      print re.sub("(\\d{4})-(\\d{2})-(\\d{2})", "\\0", "2010-12-22")
      print re.sub("(\\d{4})-(\\d{2})-(\\d{2})", r"\0", "2010-12-22")
      print re.sub("((\\d{4})-(\\d{2})-(\\d{2}))", "[\\1]", "2010-12-22")
      [2010-12-22]
      print re.sub("((\\d{4})-(\\d{2})-(\\d{2}))", r"[\1]", "2010-12-22")
      2010-12-22

3.3.1 反向引用

英文的不少單詞中都有重疊出現的字母,比如shoot或beep,如果希望檢查某個單詞是否包含重疊出現的字母,該怎么辦呢?

匹配字母的表達式是[a-z](這里暫時不考慮大寫的情況),所以最先想到的往往是用兩個字符組[a-z][a-z]來匹配,但這樣做并不對,因為重疊出現的字母是不確定的。假設字符串是ata 可以由第一個[a-z]匹配,t 可以由第二個[a-z]匹配,但是因為前一個[a-z]和后一個[a-z]之間并沒有聯系,所以[a-z][a-z]其實只能匹配兩個小寫字母,不關心它們是否相同。

這個問題有點復雜。“重疊出現”的字母,取決于第一個[a-z]在運行時的匹配結果,而不能預先設定。也就是說必須“知道”之前匹配的確切內容:如果前面的[a-z]匹配的是e,后面就只能匹配e;如果前面的[a-z]匹配的是o,后面就只能匹配o

前面我們看到了引用分組,能引用某個分組內的子表達式匹配的文本,但引用都是在匹配完成后進行的,能不能在正則表達式中引用呢?

答案是可以的,這種功能被稱作反向引用(back-reference),它允許在正則表達式內部引用之前的捕獲分組匹配的文本(也就是左側),其形式也是\num,其中 num 表示所引用分組的編號,編號規則與之前介紹的相同。

根據反向引用,查找連續重疊字母的表達式就是([a-z])\1,其中的[a-z]匹配第一個字母,再用括號將匹配分組,然后用\1來反向引用,這個表達式的匹配情況見例3-24。

例3-24用反向引用匹配重復字母

      re.search(r"^([a-z])\1$", "aa") != None      #  => True
      re.search(r"^([a-z])\1$", "dd") != None      #  => True
      re.search(r"^([a-z])\1$", "ac") != None      #  => False

在日常開發中,我們可能經常需要反向引用來建立前后聯系。最常見的例子就是解析HTML代碼時匹配tag。之前我們說過,tag包括open tag和close tag,open tag和close tag經常是成對出現的,比如<bold>text</bold><h1>title</h1>

有了反向引用功能,就可以先匹配open tag,再匹配其他內容,直到最近的close tag為止:在匹配open tag時,用一個括號分組匹配tag name的表達式([^>]+);在匹配close tag時,用\1引用之前匹配的tag name,就完成了配對(要注意的是,這里需要用到忽略優先量詞*?,否則可能會出現錯誤匹配,理由在上一章匹配JavaScript代碼時講過)。最后得到的表達式就是<([^>]+)>[\s\S]*?</\1>,這個表達式的匹配如例3-25所示。

例3-25用反向引用匹配成對的tag

      pairedTagRegex = r"<([^>]+)>[\s\S]*?</\1>"
      #應該匹配的
      re.search(rpairedTagRegex, "<bold>text</bold>") != None    #  => True
      re.search(rpairedTagRegex, "<h1>title</h1>") != None      #  => True
      #不應該匹配的
      re.search(rpairedTagRegex, "<h1>text</bold>") != None      #  => False

也有些tag更復雜一點,比如<span class="class1">text</span>,在tag名之后有一個空白字符,然后是其他屬性,此時原有的表達式就無法匹配了。為應對這類情況,應當修改表達式讓分組1 準確匹配tag name,它可以是數字、小寫字母、大寫字母,所以將它修改為<([a-zA-Z0-9]+)\s[^>]+>[\s\S]*?<\1>,但滿足了\s[^>]+的匹配,就無法應對之前的那些open tag。為了兼容兩種情況,必須用括號分組和量詞?來限定,改為也就是(\s[^>]+)?,最后的表達式就是<([a-zA-Z0-9]+)(\s[^>]+)?>[\s\S]*?</\1>。具體程序如例3-26。

例3-26用反向引用匹配更復雜的成對tag

      pairedTagRegex = r"<([a-zA-Z0-9]+)(\s[^>]+)?>[\s\S]*?</\1>"
      re.search(pairedTagRegex, "<bold>text</bold>") != None     #  => True
      re.search(pairedTagRegex, "<h1>title</h1>") != None       #  => True
      re.search(pairedTagRegex, "<span class=\"class1\">text</span>") != None #=> True
      re.search(pairedTagRegex, "<h1>text</bold>") != None      #  => False

反向引用還可以用在其他很多地方,比如在處理中文文本時,查找“浩浩蕩蕩”、“清清白白”之類AABB,或者“如火如荼”、“越快越好”之類AXAY的四字詞語。

關于反向引用,還有一點需要強調:反向引用重復的是對應捕獲分組匹配的文本,而不是之前的表達式;也就是說,反向引用的是由之前表達式決定的具體文本,而不是符合某種規則的未知文本。這一點,新手常犯錯誤。

仍然以匹配IP地址為例,前面說過,IP地址分4段(4個字節),匹配其中每一段的表達式是(0{0,2}[0-9]|0?[0-9]{2}|1[0-9][0-9]|2[0-4][0-9]|25[0-5]),之間用點號.分隔,所以匹配完整IP地址的表達式應該是用量詞重復這個子表達式,而不是用反向引用重復這個表達式匹配的文本。例3-27 對比了這兩個表達式,其中第二個表達式中使用了反向引用,故而要求后面3 段與第1 個字段完全一樣,所以它只能匹配 8.8.8.8 之類地址,而不能匹配192.168.0.1之類地址。

例3-27匹配IP地址的正則表達式

      #匹配其中一段的表達式
      #segment = r"(0{0,2}[0-9]|0?[0-9]{2}|1[0-9][0-9]|2[0-4][0-9]|25[0-5])"
      #正確的表達式
      ipAddressRegex = r"(" + segment + r"\.){3}" + segment
      #錯誤的表達式
      ipAddressRegex = segment + r"\.\1\.\1\.\1"

3.3.2 各種引用的記法

根據前面的介紹,對分組的引用可能出現在三種場合:在匹配完成后,用 group(num)之類的方法提取數據;在進行正則表達式替換時,用\num引用;在正則表達式內部,用\num引用。

不過,這只是Python語言的規定,事情并不總是如此:group(num)之類的方法,在各種語言中都是差不多的;但是在有些語言中,替換時引用的記法和正則表達式內部引用的記法是不同的。表3-2總結了各種常用語言中的兩類記法在很多文檔中都表示為\n,但\n容易誤解為換行符,所以本書中統一用\num表示。

表3-2 各種語言中引用分組的記法

看起來\num$num差別不大:\1或者$1表示第1個捕獲分組,\2或者$2表示第2個捕獲分組……不過一般來說,$num要好于\num。原因在于,$0可以準確表示“第0個分組(也就是整個表達式匹配的文本)”,而\0則不行,因為在不少語言的字符串中,\num本身是一個有意義的轉義序列,它表示值為num的ASCII字符,所以\0會被解釋為“ASCII編碼為0的字符”。但是反向引用不存在這個問題,因為不能在正則表達式還沒匹配結束時,就用\0引用整個表達式匹配的文本。

但無論是\num還是$num,都有可能遇到二義性的問題:如果出現了\10(或者$10,這里以\num為例),它到底表示第10個捕獲分組\10,還是第1個捕獲分組\1之后跟著一個字符0?Python的結果見例3-28。

例3-28可能具有二義性的反向引用

      print re.sub(r"(\d)", r"\10", "123")
      Traceback (most recent call last):
      sre_constants.error: invalid group reference

原來\10會被解釋成“第10個捕獲分組匹配的文本”,而不是“第1個捕獲分組匹配的文本之后加上字符 0”。如果我們就是希望做到后面這步,Python提供了\g<num>表示法,將\10 寫成\g<1>0,這樣同時也避免了替換時無法使用\0的問題,代碼如例3-29。

例3-29使用g<n>消除二義性

      print re.sub(r"(\d)", r"\g<1>0", "123")
      102030

PHP中也有專門的記法解決這類問題,在替換時可以使用\${num}的寫法,準確標注所引用分組的編號,也就是說,\${1}0表示“第1個捕獲分組之后加上0”,${10}表示“第10個捕獲分組”。而$10,在第10 個捕獲分組存在的情況下,表示該捕獲分組;否則,被視為空字符串。PHP的代碼見例3-30。

例3-30 PHP中的引用

      //正則表達式只包含9個捕獲分組,將捕獲的文本替換為空字符串
      echo preg_replace("/^(\\d)(\\d)(\\d)(\\d)(\\d)(\\d)(\\d)(\\d)(\\d)/", "$10", "0123456789");
      9
      //正則表達式包含10個捕獲分組,將捕獲的文本替換為10號分組匹配的9
      echo preg_replace("/^(\\d)(\\d)(\\d)(\\d)(\\d)(\\d)(\\d)(\\d)(\\d)(\\d)/", "$10",
  "0123456789");
      9
      Echo   preg_replace("/^(\\d)(\\d)(\\d)(\\d)(\\d)(\\d)(\\d)(\\d)(\\d)/",   "${1}0",
  "0123456789");
      00

注:正則表達式兩端的/是分隔符,PHP規定正則表達式兩端必須使用分隔符。

Python和PHP的規定明確,所以避免了\num的二義性;其他一些語言卻不是如此,根據它們的文檔,引用捕獲分組只有\num(或者$num)一種記法,這時候\10(其實\11\21 等都是如此)的二義性問題就無可避免了(實際上,本書中介紹的語言,除了Python和PHP之外都是如此)。

比如Java對\num中的num是這樣規定的:如果是一位數,則引用對應的捕獲分組;如果是兩位數且存在對應捕獲分組時,引用對應的捕獲分組,如果不存在對應的捕獲分組,則引用一位數編號的捕獲分組。

也就是說,如果確實存在編號為10的捕獲分組,則\10引用此捕獲分組匹配的文本;否則,\10表示“第1個捕獲分組匹配的文本”和“字符0”。程序的運行結果見例3-31。

例3-31 Java中的引用

      //存在10分組
      System.out.println("0123456789".replaceAll("^(\\d)(\\d)(\\d)(\\d)(\\d)(\\d)(\\d)(
  \\d)(\\d)$", "$10"));
      9
      //不存在10分組
      System.out.println("012345678".replaceAll("^(\\d)(\\d)(\\d)(\\d)(\\d)(\\d)(\\d)(\
  \d)$", "$10"));
      00

除Java之外,Ruby和JavaScript也采用這種規定,它看起來有點古怪,而且有個問題無法解決:如果存在編號為10的捕獲分組,無法用\10表示“編號為1的捕獲分組和字符0”,因為此時\10表示的必然是編號為10的捕獲分組。

在開發中,尤其是進行文本替換時有時確實會遇到這個問題,在現有的規則下是無解的。好在,一般我們并不會用到太多的捕獲分組(包含捕獲分組數超過10 個的表達式很少見,也很難理解和維護)。而且,已經有越來越多的語言提供了命名分組,它可以徹底解決這個問題。

3.3.3 命名分組

捕獲分組通常用數字編號來標識,但這樣有幾個問題:數字編號不夠直觀,雖然規則是“從左向右按照開括號出現的順序計數”,但括號多了難免混淆;引用時也不夠方便,上面已經講過\10引起混淆的情況。

為解決這類問題,一些語言和工具提供了命名分組(named grouping),可以將它看做另一種捕獲分組,但是標識是容易記憶和辨別的名字,而不是數字編號。

命名分組的記法也并不復雜。在Python中用(?P<name>…)來分組的,其中的name是賦予這個分組的名字,regex則是分組內的正則表達式。這樣,匹配年月日的正則表達式中,可以給年、月、日的分組分別命名,再用group(name)來獲得對應分組匹配的文本。圖3-5說明了命名分組的結構,具體的代碼見例3-32。

圖3-5 命名分組

例3-32命名分組捕獲

      namedRegex = r"(?P<year>\d{4})-(?P<month>\d{2})-(?P<day>\d{2})"
      result = re.search(namedRegex, "2010-12-22")
      print result.group("year")
      2010
      print result.group("month")
      12
      print result.group("day")
      22

因為數字編號分組的歷史更長,為保證向后兼容性,即便使用了命名分組,每個命名分組同時也具有數字編號,其編號規則沒有變化。從例3-33可以看到,在全部使用命名分組的情況下,仍然可以使用數字編號來引用分組。

例3-33命名分組捕獲時仍然保留了數字編號

      namedRegex = r"(?P<year>\d{4})-(?P<month>\d{2})-(?P<day>\d{2})"
      result = re.search(namedRegex, "2010-12-22")
      print result.group(1)
      2010
      print result.group(2)
      12
      print result.group(3)
      22

在Python中,如果使用了命名分組,在表達式中反向引用時,必須使用(?P=name)的記法;而要進行正則表達式替換,則需要寫作\g<name>,其中的name是分組的名字。代碼見例3-34。

例3-34 命名分組的引用方法

      re.search(r"^(?P<char>[a-z])(?P=char)$", "aa") != None # => True
      re.sub("(?P<digit>\d)", r"\g<digit>0", "123");
      102030

值得注意的是,命名分組不是目前通行的功能,不同語言的記法也不同,表3-3總結了目前常見的用法。

表3-3 不同語言中命名分組的記法

注1:Java 5和Java 6都不支持命名分組,根據目前看到的JRE的文檔,Java 7開始支持命名分組可參考http://cr.openjdk.java.net/~sherman/6350801/webrev.00/regex/Pattern.html#groupname。,其記法與.NET相同。

注2:Ruby 1.9以上版本才支持使用命名分組。

3.4 非捕獲分組

目前為止,總共介紹了括號的三種用途:分組,將相關的元素歸攏到一起,構成單個元素;多選結構,規定可能出現的多個子表達式;引用分組,將子表達式匹配的文本存儲起來,供之后引用。

1在PHP 5.2.2以后可以使用\k<name>或者\k'name',在PHP 5.2.4之后可以使用\k{name}和\g{name}。

這三種用途并不是彼此獨立的,而是互相重疊的:單純的分組可以視為“只包含一個多選分支的多選結構”;整個多選結構也會被視為單個元素,可以由單個量詞限定。最重要的是,無論是否需要引用分組,只要出現了括號,正則表達式在匹配時就會把括號內的子表達式存儲起來,提供引用。如果并不需要引用,保存這些信息無疑會影響正則表達式的性能;如果表達式比較復雜,要處理的文本又很多,更可能嚴重影響性能。

為解決這種問題,正則表達式提供了非捕獲分組(non-capturing group),非捕獲分組類似普通的捕獲分組,只是在開括號后緊跟一個問號和冒號(?:…),這樣的括號叫做非捕獲型括號,它只能限定量詞的作用范圍,不捕獲任何文本。在引用分組時,分組的編號同樣會按開括號出現的順序從左到右遞增,只是必須以捕獲分組為準,非捕獲分組會略過,如例3-35所示。

例3-35 非捕獲分組的使用

      print re.search(r"(\d{4})-(\d{2})-(\d{2})", "2010-12-22").group(2)
      12
      print re.search(r"(?:\d{4})-(\d{2})-(\d{2})", "2010-12-22").group(1)
      12

非捕獲分組不需要保存匹配的文本,整個表達式的效率也因此提高,但是看起來不如捕獲分組美觀,所以很多人不習慣這種記法。不過,如果只需要使用括號的分組或者多選結構的功能,而沒有用到引用分組,則應當盡量使用非捕獲型括號。

如果不習慣這種記法,比較好的辦法是,在寫正則表達式時統一使用捕獲分組,確保正確之后,再把不需要引用的分組修改為非捕獲分組——當然,引用分組的編號可能也要調整(上例中,只需要取月份信息,把第一個分組改為非捕獲分組之后,取月份信息對應分組的編號從2變為1)。

在本書中,為了使代碼簡潔和易于,除非特殊標注,否則不管匹配完成之后是否會引用文本,都使用捕獲分組。

3.5 補充

3.5.1 轉義

之前講到,如果元字符是單個出現的,直接添加反斜線字符轉義即可轉義,所以*+?的轉義形式分別是\*\+\?。如果元字符是成對出現的,則有可能只對第一個字符轉義,比如{6}[a-z]的轉義分別是\{6}\[a-z]

括號的轉義與它們都不同,與括號有關的所有三個元字符()|都必須轉義。因為括號非常重要,所以無論是開括號還是閉括號,只要出現,正則表達式就會嘗試尋找整個括號,如果只轉義了開括號而沒有轉義閉括號,一般會報告“括號不匹配”的錯誤。另一方面,多選結構中的|也必須轉義(多選結構可以不用括號只出現|),所以,也不要忘記對|的轉義;否則就可能出現例3-36的問題。

例3-36括號的轉義

      re.search(r"^\(a\)$", "(a)") != None     #  => True
      re.search(r"^\(a\)$", "(a)") != None     #  => True
      re.search(r"^\(a)$", "(a)") != None      #  => True
      Traceback (most recent call last):
      error: unbalanced parenthesis
      #未轉義|
      re.search(r"^\(a|b\)$", "(a|b)") != None     #  => False
      #同時轉義了|
      re.search(r"^\(a\|b\)$", "(a|b)") != None    #  => True

3.5.2 URL Rewrite

提到括號的分組和引用功能,就不能不提到URL Rewrite。URL Rewrite是常見Web服務器中都具備(也必須)的功能,它用來進行網址的轉發,下面是一個轉發的例子。

      外部訪問URL
      http://www.example.com/blog/2006/12
      內部實現
      http://www.example.com/blog/posts.php?year=2006&month=12

這樣的好處是隔離了外部接口和內部實現,方便修改;也有利于提供更有意義、更直觀的URL。

一般來說,URL Rewrite都是使用轉發規則實現的,每條轉發規則對應一類URL,以正則表達式解析并提取出所需要的信息,重組之后再轉發。比如上面的轉發,就需要先提取年、月、日的信息進行重組。很自然地,我們會想到使用括號和引用分組的功能來實現。下面就以剛才提到的日期轉發為例,看上面的轉發規則在當前主流的Web服務器中如何配置。

Microsoft IIS

在Web.config配置文件中,找到<rewrite>節點,在<rules>下新增下面的代碼。

      <rule name="Rewrite Rule">
      <match url="^blog/([0-9]{4})/([0-9]{2)/?$" />
      <action type="Rewrite" url="blog/posts.php?year={R:1}&amp;month={R:2}" />
      </rule>

其中<match>節點中的url是外部訪問的URL。對轉發的URL而言,能接收的都是path部分,如果URL是 http://www.example.com/blog/2006/12,則path就是 blog/2006/12。正則表達式以^blog開頭,分別用[0-9]{4}[0-9]{2}匹配其中的年、月信息,因為之后的轉發需要用到這些信息,所以必須使用捕獲分組以便引用。另外,因為URL最后可能出現反斜線/,也可能不出現,意義沒有區別,所以使用了量詞/?

Action節點中的url則是轉發之后(也就是內部使用)的URL,轉發到blog/posts.php,且將年、月信息作為請求參數,附在后面。在IIS中,通過{R:num}的記法引用分組,其中num為對應分組的編號;另外,因為這是一個XML文件,所有的&必須轉義為&amp;,URL中的&也不例外。

關于IIS中URL Rewrite的具體信息,可以參考下面的詳細文檔。

http://learn.iis.net/page.aspx/496/iis-url-rewriting-and-aspnet-routing/

Apache

在httpd.conf配置文件中,找到虛擬主機對應的配置字段,首先確認啟用了URL Rewrite功能,也就是保證出現了下面這行:

      RewriteEngine on

然后編寫規則,上面的轉義對應的規則如下:

      RewriteRule ^blog/([0-9]{4})/([0-9]{2})/?$ blog/posts.php?year=$1&month=$2 [L]

RewriteRule開頭的行指定了轉發規則,RewriteRule之后是外部URL和轉發的URL,最后是可選出現的標志位(flags,[L]表示“如果URL匹配成功,按本條規則轉發之后,不再考慮其他轉發規則”),這幾個字段之間用任意空白字符分隔。在Apache中,分組的引用使用$num的形式,其中num為分組對應的編號。

關于Apache中URL Rewrite的具體信息,可以參考下面的詳細文檔。

Apache 2.x版:http://httpd.apache.org/docs/2.0/misc/rewriteguide.html

Apache 1.3版:http://httpd.apache.org/docs/1.3/mod/mod_rewrite.html

Nginx

在Nginx.conf配置文件中找到對應虛擬主機的配置字段,在其中添加下面的規則。

      rewrite ^blog/([0-9]{4})/([0-9]{2})/?$ blog/posts.php?year=$1&month=$2 last;

rewrite開頭的行指定了轉發規則,rewrite之后是外部URL和轉發的URL,最后是可選出現的標志位(flags,last的含義與Apache轉發規則中的[L]相同),這幾個字段之間也是用任意空白字符分隔(要注意,行的末尾必須有分號;)。在Nginx中,使用$num的記法引用分組,其中num為分組對應的編號。

相對來說,Nginx的轉發功能最為強大,因為Apache和IIS的轉發一般都只限于單條語句,但是Nginx的轉發可以使用復雜的判斷邏輯,比如下面的轉發首先判斷瀏覽器的user-agent,如果是IE則轉發,否則不轉發。

      if ($http_user_agent  MSIE) {
      rewrite ^blog/([0-9]{4})/([0-9]{2})/?$ blog/posts.php?year=$1&month=$2 last;
      }

關于Nginx中URL Rewrite的具體信息,可以參考下面的詳細文檔。

http://wiki.nginx.org/HttpRewriteModule

3.5.3 一個例子

這部分內容來自一位朋友的問題,這個問題相當有迷惑性和代表性,所以不妨列在這里,希望能解開更多讀者的類似疑惑。

問題是這樣的:運行re.findall('(\w+\.?)+', 'aaa.bbb.ccc'),期望得到序列aaa.、bbb.、ccc,實際運行的結果卻只有ccc,這是為什么呢?

其實答案很簡單——因為表達式(\w+\.?)+中存在量詞+,所以整個正則表達式的匹配過程中,括號內的\w+\.?會多次匹配:第1次匹配aaa.,第2次匹配bbb.,第3次(也就是最后)匹配ccc,最終這個捕獲分組匹配的文本就是ccc。調用re.findall()時,因為存在括號(也就是捕獲分組),默認返回捕獲分組匹配的文本,也就是ccc

解答了這個問題之后,他繼續問:如果字符串是aaa.bbb,或者aaa.bbb.ccc.ddd,如何能用一個表達式,逐個拆分出aaa.、bbb.之類的子串呢?(請注意,子串的個數是變化的,并且不能預先知道。)

要解答這個問題,需要記住:捕獲分組的個數是不能動態變化的——單個正則表達式里有多少個捕獲分組,一次匹配成功之后,結果中就必然存在多少個對應的元素(捕獲分組匹配的文本),如果不能預先規定匹配結果中元素的個數,就不能使用捕獲分組。如果要匹配數目不定的多段文本,必須通過重復多次匹配完成。具體到這個例子,在 re.findall('\w+\.?', 'aaa.bbb.ccc')中,整個正則表達式會匹配成功3次,得到3個子串;如果把字符串改為aaa.bbb.ccc.ddd,則整個正則表達式會匹配成功4次,得到4個子串。

主站蜘蛛池模板: 长乐市| 永修县| 兴文县| 宁陵县| 兴山县| 大埔区| 重庆市| 贵港市| 萍乡市| 绥中县| 弥渡县| 蓝山县| 葫芦岛市| 万源市| 博野县| 合江县| 策勒县| 永川市| 襄汾县| 盐池县| 昌平区| 亳州市| 东源县| 延长县| 汉沽区| 体育| 当涂县| 扶风县| 尚义县| 赤城县| 乐东| 固阳县| 嘉黎县| 凭祥市| 广元市| 隆化县| 富顺县| 新余市| 思茅市| 凤凰县| 房产|