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

第3章 括號(hào)

3.1 分組

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

身份證號(hào)碼是一個(gè)長(zhǎng)度為15或18個(gè)字符的字符串,如果是15位,則全部由數(shù)字組成,首位不能為0;如果是18位,前17位全部是數(shù)字,首位同樣不能是0,末位可能是數(shù)字,也可能是字母x。[1]規(guī)則非常明確,可以著手編寫(xiě)正則表達(dá)式了。

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

例3-1 身份證號(hào)碼的匹配

看來(lái),果然能夠匹配各種形式的身份證號(hào)碼,應(yīng)該沒(méi)問(wèn)題。不過(guò)這還不夠,這個(gè)正則表達(dá)式應(yīng)該保證身份證號(hào)碼的字符串能夠匹配,其他字符串不能夠匹配,例3-2展示了身份證號(hào)碼錯(cuò)誤匹配的情況。

例3-2 身份證號(hào)碼的錯(cuò)誤匹配

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

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

看來(lái),只要以15位號(hào)碼的匹配為基礎(chǔ),末尾加上可能出現(xiàn)的\d{2}[0-9x]即可。最后的\d{2}[0-9x]必須作為一個(gè)整體,或許不出現(xiàn)(15位號(hào)碼),或許出現(xiàn)(18位號(hào)碼)。量詞?可以表示“不出現(xiàn),或者出現(xiàn)1次”,正好合適。

但是,正則表達(dá)式\d{2}[0-9x]?是不行的,因?yàn)榱吭~?只能限定[0-9x]的出現(xiàn),而正則表達(dá)式\d{2}?[0-9x]?同樣不行—即使只出現(xiàn)一個(gè)[0-9x],也可以匹配。到底怎樣才能把\d{2}[0-9x]作為一個(gè)整體呢?

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

例3-3 身份證號(hào)碼的準(zhǔn)確匹配

注:為了方便講解,我們?cè)谡齽t表達(dá)式的兩端添加了^$,它們分別定位到字符串的起始位置和結(jié)束位置,這樣確保了表達(dá)式不會(huì)只匹配字符串的某個(gè)子串;如果要用表達(dá)式來(lái)提取數(shù)據(jù),應(yīng)當(dāng)去掉^$。下面的例子都遵循這條規(guī)則。

括號(hào)的這種功能叫作分組(grouping)。如果用量詞限定出現(xiàn)次數(shù)的元素不是字符或者字符組,而是連續(xù)的幾個(gè)字符甚至子表達(dá)式,就應(yīng)該用括號(hào)將它們“編為一組”。比如,希望字符串a(chǎn)b重復(fù)出現(xiàn)一次以上,就應(yīng)該寫(xiě)作(ab)+,此時(shí)(ab)成為一個(gè)整體,由量詞+來(lái)限定;如果不用括號(hào)而直接寫(xiě)ab+,受+限定的就只有b。例3-4顯示了有括號(hào)與無(wú)括號(hào)的表達(dá)式的匹配異同。

例3-4 用括號(hào)改變量詞的作用元素有了分組,就可以準(zhǔn)確表示“長(zhǎng)度只能是mn”。比如在上面匹配身份證號(hào)碼的例子中,要匹配一個(gè)長(zhǎng)度為13或者16的數(shù)字字符串。常犯的錯(cuò)誤是使用表達(dá)式\d{13,16},看起來(lái)沒(méi)問(wèn)題,但長(zhǎng)度為14或15的數(shù)字字符串同樣會(huì)匹配。真正準(zhǔn)確的做法是:首先匹配長(zhǎng)度為13的數(shù)字字符串,然后匹配可能出現(xiàn)的長(zhǎng)度為3的數(shù)字字符串,正則表達(dá)式就成了\d{13}(\d{3})?

分組是非常有用的功能,因?yàn)槭褂谜齽t表達(dá)式時(shí)經(jīng)常會(huì)遇到并沒(méi)有直接相連,但確實(shí)存在聯(lián)系的部分,分組可以把這些概念上相關(guān)的部分“歸攏”到一起,以免割裂,下面來(lái)看幾個(gè)例子。

第2章使用表達(dá)式<[^/][^>]*>匹配HTML中的open tag,比如<table>,但是這個(gè)表達(dá)式也會(huì)匹配self-closing tag,比如<br />。如果把表達(dá)式改為<[^/][^>]*[^/]>,確實(shí)可以避免匹配self-closing tag,但是因?yàn)閮蓚€(gè)排除型字符組要匹配兩個(gè)字符,這個(gè)表達(dá)式又會(huì)漏過(guò)<u>之類(lèi)的open tag,僅僅依靠字符組和量詞無(wú)法配合解決問(wèn)題,必須動(dòng)用括號(hào)的分組功能。

<[^/][^>]*[^/]>錯(cuò)過(guò)的只有一種情況,就是tag name為單個(gè)字母的情況。如果tag name不是單個(gè)字母,則第一個(gè)字母之后,必然會(huì)出現(xiàn)這樣一個(gè)字符串:其中不包含>,結(jié)尾的字符并不是/。最后,才是tag結(jié)尾的>。像圖3-1所示那樣,將這幾個(gè)元素拆開(kāi),能看得更清楚點(diǎn)。

圖3-1 open tag的準(zhǔn)確匹配

所以,用一個(gè)括號(hào)將可選出現(xiàn)的部分分組,再用量詞?限定,就可以得到兼顧這兩種情況、準(zhǔn)確匹配open tag的正則表達(dá)式了,程序代碼如例3-5所示。

例3-5 準(zhǔn)確匹配open tag

再來(lái)看一個(gè)更復(fù)雜的例子。在Web服務(wù)中,經(jīng)常并不希望暴露真正的程序細(xì)節(jié),所以用某種模式的URL來(lái)掩蓋。比如這個(gè)URL:/foo/bar_qux.php,看起來(lái)是訪(fǎng)問(wèn)一個(gè)PHP頁(yè)面,其實(shí)背后有復(fù)雜的路由規(guī)則。真正的結(jié)構(gòu)如圖3-2所示,foo是模塊的名稱(chēng),bar是控制器的名字,qux則是方法名,三個(gè)名字中都只能出現(xiàn)小寫(xiě)字母。

圖3-2 URL的結(jié)構(gòu)

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

為編寫(xiě)通用的正則表達(dá)式來(lái)匹配,有些人是這么總結(jié)的。

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

仔細(xì)看看這個(gè)表達(dá)式,無(wú)論是/foo,還是/foo/bar.php,抑或是/foo/bar_qux.php,都可以匹配,看起來(lái)確實(shí)沒(méi)有問(wèn)題。

可是,這個(gè)表達(dá)式中只有/[a-z]+是必須出現(xiàn)的,其他部分都是“不一定出現(xiàn)”的,也就是說(shuō),其中任意一個(gè)或幾個(gè)部分出現(xiàn),這個(gè)表達(dá)式都可以匹配。那么,/foo/_也是可以匹配的,/foo.php也是可以匹配的,這樣就亂套了,如例3-6所示。

例3-6 URL匹配的表達(dá)式

之所以會(huì)出錯(cuò),根源在于有些元素是“不一定出現(xiàn)”的,但它們之間卻是有關(guān)聯(lián)的:“不一定出現(xiàn)”的幾個(gè)元素雖然沒(méi)有前后緊密相連,卻是“同生共死”的關(guān)系。這時(shí)候就要梳理清楚邏輯關(guān)系,用括號(hào)的分組功能把各種分支情況歸攏到一起。

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

仔細(xì)觀察這兩個(gè)表達(dá)式,會(huì)發(fā)現(xiàn)它們可以合并:把第二個(gè)表達(dá)式中多出的部分,繼續(xù)用分組括號(hào)配合量詞?表示,塞到第一個(gè)表達(dá)式中,用得到的表達(dá)式配合量詞?再加上最開(kāi)頭“必須出現(xiàn)”的/foo,最后得到完整的表達(dá)式。

從例3-7可以看到,這個(gè)表達(dá)式確實(shí)杜絕了錯(cuò)誤的匹配。

例3-7 杜絕了錯(cuò)誤匹配的表達(dá)式

關(guān)于括號(hào)的分組功能,最后來(lái)看E-mail地址的匹配:E-mail地址以@分隔為兩段,a之前的是用戶(hù)名(username),之后的是主機(jī)名(hostname),用戶(hù)名一般只允許出現(xiàn)數(shù)字和字母(現(xiàn)在有些郵件服務(wù)商也允許用戶(hù)名中出現(xiàn)點(diǎn)號(hào)等字符了,這種情況復(fù)雜一些,此處不做考慮),而主機(jī)名則是類(lèi)似mail.google.com、mail.163.com之類(lèi)的字符串。

用戶(hù)名的匹配非常簡(jiǎn)單,其中能出現(xiàn)的字符主要有大寫(xiě)字母[A-Z]、小寫(xiě)字母[a-z]、阿拉伯?dāng)?shù)字字符[0-9],下畫(huà)線(xiàn)_、點(diǎn)號(hào).,所以總的字符組就是[A-Za-z0-9_.],又可以簡(jiǎn)化為[\w.];另一方面,用戶(hù)名的最大長(zhǎng)度是64個(gè)字符,所以匹配用戶(hù)名的正則表達(dá)式就是[\w.]{1,64}。

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

看來(lái)規(guī)律是這樣的:最后的域名字段是頂級(jí)域名[2],之前的部分可以看作某種模式的重復(fù):該模式由域名字段和點(diǎn)號(hào)組成,域名字段在前,點(diǎn)號(hào)在后。比如somehost.com就可以這么看:頂級(jí)域名是com,之前是somehost.;sub.somehost.net就可以這么看:頂級(jí)域名是net,之前是sub.和somehost.。

匹配域名字段的表達(dá)式是[-a-zA-Z0-9]{1,63},匹配點(diǎn)號(hào)的表達(dá)式是\.,使用括號(hào)的分組功能,把這兩個(gè)表達(dá)式分為一組,用量詞*限定表示“不出現(xiàn),或出現(xiàn)多次”,就得到匹配主機(jī)名的表達(dá)式([-a-zA-Z0-9]{1,63}\.)*[-a-zA-Z0-9]{1,63}(因?yàn)轫敿?jí)域名也是一個(gè)域名字段,所以即便主機(jī)名是localhost,也可以由最后那個(gè)匹配域名字段的表達(dá)式匹配)。

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

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

3.2 多選結(jié)構(gòu)

之前用表達(dá)式[1-9]\d{14}(\d{2}[0-9x])?匹配身份證號(hào),思路是把18位號(hào)碼多出的3位“合并”到匹配15位號(hào)碼的表達(dá)式中。這樣寫(xiě)沒(méi)有錯(cuò),只是得花點(diǎn)心思才能理解其邏輯。

能不能直接分情況處理呢?15位身份證號(hào)就是[1-9]開(kāi)頭,之后是14位數(shù)字;18位身份證號(hào)就是[1-9]開(kāi)頭,之后是16位數(shù)字,最后是[0-9x]?。只要兩個(gè)表達(dá)式中的一個(gè)能夠匹配,就是合法的身份證號(hào),這樣的思路更加清晰。

答案是可以的,而且仍然使用括號(hào)解決問(wèn)題,只是要用到括號(hào)的另一個(gè)功能:多選結(jié)構(gòu)(alternative)。

多選結(jié)構(gòu)的形式是(…|…),在括號(hào)內(nèi)以豎線(xiàn)|分隔開(kāi)多個(gè)子表達(dá)式,這些子表達(dá)式也叫多選分支(option);在一個(gè)多選結(jié)構(gòu)內(nèi),多選分支的數(shù)目沒(méi)有限制。在匹配時(shí),整個(gè)多選結(jié)構(gòu)被視為單個(gè)元素,只要其中某個(gè)子表達(dá)式能夠匹配,整個(gè)多選結(jié)構(gòu)的匹配就能成功;如果所有子表達(dá)式都不能匹配,則整個(gè)多選結(jié)構(gòu)匹配失敗。

回到身份證號(hào)碼匹配的例子,既然可以區(qū)分15位和18位兩種情況,就可以將每種情況對(duì)應(yīng)的表達(dá)式作為一個(gè)分支,“合并”為多選結(jié)構(gòu)([1-9]\d{14}|[1-9]\d{14}\d{2}[0-9x])。這個(gè)表達(dá)式的匹配如例3-9所示,它同樣可以準(zhǔn)確驗(yàn)證身份證號(hào)碼。

例3-9 用多選結(jié)構(gòu)匹配身份證號(hào)碼

多選結(jié)構(gòu)在實(shí)際中經(jīng)常用到,匹配IP地址就是如此:IP地址(暫不考慮IPv6)分為4段(4字節(jié)),每段都是8位二進(jìn)制數(shù),換算成常見(jiàn)的十進(jìn)制,取值在0~255之間,中間以點(diǎn)號(hào).分隔。點(diǎn)號(hào).的匹配非常容易,用\.就可以,所以暫且忽略它,只考慮匹配這個(gè)數(shù)值的問(wèn)題,而且因?yàn)?段IP地址的取值范圍是相同的,只考慮其中一段的匹配即可。

要匹配十進(jìn)制形式的IP地址,最常見(jiàn)的正則表達(dá)式就是[0-9]{1,3},也就是1~3位十進(jìn)制數(shù)字。粗看起來(lái),這個(gè)表達(dá)式?jīng)]什么錯(cuò),細(xì)看卻有很大問(wèn)題。因?yàn)?56、999這樣的數(shù)值,顯然不在0~255之間,卻可以由[0-9]{1,3}匹配。

細(xì)致一點(diǎn)的表達(dá)式似乎是[0-2][0-5][0-5],這樣就限制了數(shù)值只能在255以?xún)?nèi)……不過(guò),仔細(xì)想想,因?yàn)橄薅说诙唬ㄊ唬┖偷谌唬▊€(gè)位)都只能出現(xiàn)0~5之間的字符,表達(dá)式?jīng)]法匹配168之類(lèi)的數(shù)值。

其實(shí),問(wèn)題可以這樣解決:先用表達(dá)式匹配這個(gè)字符串,再將它轉(zhuǎn)換為整數(shù)類(lèi)型的變量x,判斷x是否在0到255之間:0<=x && x<=255。沒(méi)錯(cuò),這確實(shí)是一個(gè)解決問(wèn)題的思路,只是有點(diǎn)麻煩,最好能用正則表達(dá)式“一次性”搞定這個(gè)問(wèn)題。仔細(xì)想想就能發(fā)現(xiàn),正則表達(dá)式雖然不能直接表示“匹配一段數(shù)值在0~255之間的文本”,但可以分幾種情況描述這樣的文本。

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

例3-10 準(zhǔn)確匹配0~255之間的字符串

如果要更完善一點(diǎn),能識(shí)別030、005這樣的數(shù)值,可以修改對(duì)應(yīng)的子表達(dá)式,為一位數(shù)和兩位數(shù)的情況增加之前可能出現(xiàn)0的匹配,得到表達(dá)式((00)?[0-9]|0?[0-9]{2}|1[0-9][0-9]|2[0-4][0-9]|25[0-5])

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

這個(gè)模式還可以用于匹配手機(jī)號(hào)碼:手機(jī)號(hào)碼通常是11位,前面3位是號(hào)段,目前有130~139號(hào) 段 、150~153、155~156、180、182、185~189號(hào) 段 , 用 多 選 分 支(13[0-9]|15[0-356]|18[025-9])可以很準(zhǔn)確地匹配號(hào)段;之后的8位一般沒(méi)有限制,只要是數(shù)字即可,用\d{8}匹配。另外,手機(jī)號(hào)碼開(kāi)頭可能有0或者+86,它可以用(0|\+86)匹配,因?yàn)檎麄€(gè)部分是可能出現(xiàn)的,所以需要加上量詞,也就是(0|\+86)?,最后得到的正則表達(dá)式就是(0|\+86)?(13[0-9]|15[0-356]|18[025-9])\d{8}。

多選結(jié)構(gòu)還可以解決更復(fù)雜的問(wèn)題,比如上一章的tag匹配問(wèn)題,當(dāng)時(shí)使用的表達(dá)式是<[^>]+>。一般來(lái)說(shuō),這個(gè)表達(dá)式是沒(méi)有問(wèn)題的,但也有可能tag內(nèi)部還是會(huì)出現(xiàn)>符號(hào),比如<input name=txt value=">">。這類(lèi)問(wèn)題使用字符組解決不了,可以使用多選結(jié)構(gòu)解決。

仔細(xì)分析tag中可能出現(xiàn)>,它只可能作為屬性(attribute)出現(xiàn)在單引號(hào)字符串和雙引號(hào)字符串中,根據(jù)HTML規(guī)范,引號(hào)字符串中不能出現(xiàn)嵌套轉(zhuǎn)義的引號(hào),所以單引號(hào)字符串可以用'[^']*'來(lái)匹配,雙引號(hào)字符串可以用"[^"]*"來(lái)匹配,相應(yīng)的,其他內(nèi)容可以用[^'">]來(lái)匹配,所以更完善的表達(dá)式是<('[^']*'|"[^"]*"|[^'">])+>。它的匹配情況見(jiàn)例3-11。

例3-11 準(zhǔn)確的HTML tag匹配

請(qǐng)注意其中的量詞,因?yàn)閱我?hào)字符串和雙引號(hào)字符串都可以是空字符串,比如alt=''或alt="",所以匹配其中文本的內(nèi)容使用*;而[^'">]則沒(méi)有使用量詞,因?yàn)樗嬖谟诙噙x結(jié)構(gòu)內(nèi)部,多選結(jié)構(gòu)外部有+量詞限制,保證了它不只是匹配一個(gè)字符。如果在多選結(jié)構(gòu)內(nèi)部使用[^'">]*,雖然看來(lái)似乎沒(méi)錯(cuò),卻可能導(dǎo)致非常奇怪的結(jié)果,不過(guò)現(xiàn)在不用關(guān)心,在第143頁(yè)會(huì)給出詳細(xì)講解。

關(guān)于多選結(jié)構(gòu),最后還要補(bǔ)充三點(diǎn)。

第一,多選結(jié)構(gòu)的一般表示法是(option1|option2)(其中option1option2是兩個(gè)作為多選分支的正則表達(dá)式),在多選結(jié)構(gòu)中一般會(huì)同時(shí)使用括號(hào)()和豎線(xiàn)|;但是如果沒(méi)有括號(hào)(),只出現(xiàn)豎線(xiàn)|,仍然是多選結(jié)構(gòu)。從例3-12可以看到,ab|cd既可以匹配ab,也可以匹配cd。

例3-12 沒(méi)有括號(hào)的多選結(jié)構(gòu)

在多選結(jié)構(gòu)中,豎線(xiàn)|用來(lái)分隔多選結(jié)構(gòu),而括號(hào)()用來(lái)規(guī)定整個(gè)多選結(jié)構(gòu)的范圍,如果沒(méi)有出現(xiàn)括號(hào),則將整個(gè)表達(dá)式視為一個(gè)多選結(jié)構(gòu),所以ab|cd等價(jià)于(ab|cd)。如果在某些地方看到?jīng)]有括號(hào)的多選結(jié)構(gòu),你不用奇怪。

不過(guò),我還是推薦明確寫(xiě)出兩端的括號(hào),這樣更形象,也能避免一些錯(cuò)誤。如果你仔細(xì)看,就會(huì)發(fā)現(xiàn)在上面的表達(dá)式中,并沒(méi)有使用^$定位字符串的起始位置和結(jié)束位置,按道理說(shuō),加上之后應(yīng)該匹配更加準(zhǔn)確,結(jié)果卻并非如此。

因?yàn)樨Q線(xiàn)|的優(yōu)先級(jí)很低(關(guān)于優(yōu)先級(jí),?108),所以^ab|cd$其實(shí)是(^ab|cd$),而不是^(ab|cd)$,它的真正意思是“字符串開(kāi)頭的ab或者字符串結(jié)尾的cd”,而不是“只包含ab或cd的字符串”,代碼見(jiàn)例3-13。

例3-13 沒(méi)有括號(hào)的多選結(jié)構(gòu)

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

反過(guò)來(lái),多選結(jié)構(gòu)不一定能對(duì)應(yīng)到字符組。因?yàn)樽址M的每個(gè)“分支”的長(zhǎng)度相同,而且只能是單個(gè)字符;而多選結(jié)構(gòu)的每個(gè)“分支”的長(zhǎng)度沒(méi)有限制,甚至可以是復(fù)雜的表達(dá)式,比如(abc|b+c*ab),字符組完全無(wú)能為力。

多選分支和字符組的另一點(diǎn)重要區(qū)別(同時(shí)也是常犯的錯(cuò)誤)是:排除型字符組可以表示“無(wú)法由某幾個(gè)字符匹配的字符”,多選結(jié)構(gòu)卻沒(méi)有對(duì)應(yīng)的結(jié)構(gòu)表示“無(wú)法由某幾個(gè)表達(dá)式匹配的字符串”。從例3-14可以看到,[^abc]表示“匹配除a、b、c之外的任意字符”,(^a|b|c)卻不能表示“匹配除a、b、c之外的任意字符串”。

例3-14 多選結(jié)構(gòu)不能表示“無(wú)法由某幾個(gè)表達(dá)式匹配的字符串”

在實(shí)際開(kāi)發(fā)中確實(shí)可能遇到這種需求,不過(guò)它沒(méi)有現(xiàn)成的解法。如果你現(xiàn)在就希望匹配“無(wú)法由某幾個(gè)表達(dá)式匹配的字符串”,請(qǐng)翻到第148頁(yè)。

第三,多選分支的排列是有講究的。比如這個(gè)表達(dá)式(jeff|jeffrey),用它匹配jeffrey,結(jié)果到底是jeff還是jeffrey呢?這個(gè)問(wèn)題并沒(méi)有標(biāo)準(zhǔn)的答案,本書(shū)介紹的Java、.NET、Python、Ruby、JavaScript、PHP中,多選結(jié)構(gòu)都會(huì)優(yōu)先選擇最左側(cè)的分支。這一點(diǎn)從例3-15看得很清楚:如果使用的字符串是jeffrey,正則表達(dá)式是(jeff|jefferey)還是(Jeffrey|jeff),結(jié)果是不一樣的(此處僅以Python為例,本書(shū)中介紹的其他語(yǔ)言中的結(jié)果與此相同)。

例3-15 多選結(jié)構(gòu)的匹配順序

在實(shí)際開(kāi)發(fā)中可能會(huì)遇到這樣的情況:統(tǒng)計(jì)一段文本中,“湖南”和“湖南省”分別出現(xiàn)的次數(shù)。如果直接查找“湖南”,可能會(huì)將“湖南省”中的“湖南”也找出來(lái),如果使用多選結(jié)構(gòu)(湖南省|湖南),就可以一次性找出所有“湖南”和“湖南省”,再按照字符串的長(zhǎng)度分別計(jì)數(shù),就可以得到兩者出現(xiàn)的次數(shù)了。

不過(guò),(湖南省|湖南)只是一個(gè)針對(duì)特殊應(yīng)用的例子。在平時(shí)使用中,如果出現(xiàn)多選結(jié)構(gòu),應(yīng)當(dāng)盡量避免多選分支中存在重復(fù)匹配,因?yàn)檫@樣會(huì)大大增加回溯的計(jì)算量。也就是說(shuō),應(yīng)當(dāng)避免這樣的情況:針對(duì)多選結(jié)構(gòu)(option1|option2),某段文本既可以由option1匹配,也可以由option2匹配。如果出現(xiàn)了這樣的多選結(jié)構(gòu),效率可能會(huì)受到極大影響(第168頁(yè)總結(jié)了可能影響效率的幾種寫(xiě)法),尤其在受量詞限定的多選結(jié)構(gòu)中更是如此:一般人很難遇到(a|[ab])這類(lèi)多選結(jié)構(gòu),但([0-9]|\w)之類(lèi)則一不留神就會(huì)遇到。

3.3 引用分組

括號(hào)不僅能把有聯(lián)系的元素歸攏起來(lái)并分組,還有其他的作用—使用括號(hào)之后,正則表達(dá)式會(huì)保存每個(gè)分組真正匹配的文本,等到匹配完成后,通過(guò)group(num)之類(lèi)的方法“引用”分組在匹配時(shí)捕獲的內(nèi)容(這個(gè)方法之前已經(jīng)出現(xiàn)過(guò))。其中,num表示對(duì)應(yīng)括號(hào)的編號(hào),括號(hào)分組的編號(hào)規(guī)則是從左向右計(jì)數(shù),從1開(kāi)始。因?yàn)椤安东@”了文本,所以這種功能叫作捕獲分組(capturing group)。對(duì)應(yīng)的,這種括號(hào)叫作捕獲型括號(hào)

舉一個(gè)例子,我們經(jīng)常遇到諸如2010-12-22、2011-01-03這類(lèi)表示日期的字符串,希望從中提取出年、月、日之類(lèi)的信息,就可借助捕獲分組來(lái)實(shí)現(xiàn)。在正則表達(dá)式中,每個(gè)捕獲分組都有一個(gè)編號(hào),具體情況如圖3-3所示。

圖3-3 分組及編號(hào)

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

例3-16 引用捕獲分組

前面說(shuō)過(guò),num的編號(hào)從1開(kāi)始。不過(guò),也有編號(hào)為0的分組,它是默認(rèn)存在的,對(duì)應(yīng)整個(gè)表達(dá)式匹配的文本。在許多語(yǔ)言中,如果調(diào)用group()方法,不給出參數(shù)num,默認(rèn)就等于調(diào)用group(0),比如Python就是如此,代碼見(jiàn)例3-17。

例3-17 默認(rèn)存在編號(hào)為0的分組

有些正則表達(dá)式里可能包含嵌套的括號(hào),比如在上面的例子中,除了能單獨(dú)提取出年、月、日之外,再給整個(gè)表達(dá)式加上一重括號(hào),就出現(xiàn)了嵌套括號(hào),這時(shí)候括號(hào)的編號(hào)是怎樣的呢?答案很簡(jiǎn)單:無(wú)論括號(hào)如何嵌套,分組的編號(hào)都是根據(jù)開(kāi)括號(hào)出現(xiàn)順序來(lái)計(jì)數(shù)的;開(kāi)括號(hào)是從左向右數(shù)起第多少個(gè)開(kāi)括號(hào),整個(gè)括號(hào)分組的編號(hào)就是多少。圖3-4舉例說(shuō)明了這種編號(hào)規(guī)則,具體的代碼見(jiàn)例3-18。

圖3-4 分組編號(hào)只取決于開(kāi)括號(hào)出現(xiàn)的順序

例3-18 嵌套的括號(hào)

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

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

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

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

現(xiàn)在表達(dá)式已經(jīng)編寫(xiě)完畢,第一個(gè)括號(hào)內(nèi)的表達(dá)式用來(lái)匹配url,第二個(gè)括號(hào)內(nèi)的表達(dá)式用來(lái)匹配text,所以如果要提取url和text,應(yīng)該使用編號(hào)為1和2的分組。下面仍然以yahoo.com的首頁(yè)為例來(lái)看看結(jié)果。需要說(shuō)明的是,如果使用re.findall(),而且正則表達(dá)式中出現(xiàn)了捕獲型括號(hào),那么返回?cái)?shù)組的每個(gè)元素都是數(shù)組,其中各個(gè)元素對(duì)應(yīng)各個(gè)分組的文本,所以直接用下標(biāo)2訪(fǎng)問(wèn)得到第二個(gè)分組對(duì)應(yīng)的文本,不必顯式調(diào)用group(2),代碼見(jiàn)例3-19。

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

類(lèi)似的,還可以提取出網(wǎng)頁(yè)頭部信息(<head>)或網(wǎng)頁(yè)中的圖片鏈接(<img>)的表達(dá)式。[3]應(yīng)當(dāng)注意的是,匹配<img>時(shí),在<img和src之間可能還有其他內(nèi)容,比如width=750之類(lèi),所以不能僅僅用\s+匹配,而應(yīng)當(dāng)添加[^>]*?。在src=…之后也同樣如此。表3-1總結(jié)了匹配網(wǎng)頁(yè)頭部信息和圖片鏈接的表達(dá)式。

表3-1 提取網(wǎng)頁(yè)頭部信息和圖片鏈接的正則表達(dá)式

應(yīng)當(dāng)記住的是,引用分組時(shí),引用的是分組對(duì)應(yīng)括號(hào)內(nèi)的表達(dá)式捕獲的文本。在這個(gè)問(wèn)題上,正則表達(dá)式新手常犯錯(cuò)誤。例3-20仍然是用正則表達(dá)式匹配日期字符串,兩個(gè)表達(dá)式能匹配的字符串是完全相同的,引用分組的編號(hào)也是相同的,結(jié)果卻不同。

例3-20 新手容易弄錯(cuò)分組的結(jié)構(gòu)

在第一個(gè)表達(dá)式中,編號(hào)為1的分組對(duì)應(yīng)的括號(hào)是(\d{4}),其中的\d{4}是“匹配4個(gè)數(shù)字字符”的子表達(dá)式。在第二個(gè)表達(dá)式中,編號(hào)為1的分組對(duì)應(yīng)的括號(hào)是(\d),其中的\d是“匹配1個(gè)數(shù)字字符”的子表達(dá)式,因?yàn)橹笥辛吭~{4},所以整個(gè)括號(hào)作為單個(gè)元素,要重復(fù)出現(xiàn)4次,而且編號(hào)都是1。于是每重復(fù)出現(xiàn)一次,就要更新一次匹配結(jié)果。所以在匹配過(guò)程中,編號(hào)為1的分組匹配的文本的值,依次是2、0、1、0,最后的結(jié)果是0。在實(shí)際使用時(shí),常常有人忽略了這一細(xì)節(jié),得到匪夷所思的匹配結(jié)果。

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

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

例3-21 正則表達(dá)式替換

replacement中也可以引用分組,形式是\num,其中的num是對(duì)應(yīng)分組的編號(hào)。不過(guò),replacement并不是一個(gè)正則表達(dá)式,而是一個(gè)普通字符串。根據(jù)字符串中的轉(zhuǎn)義規(guī)定,\t表制表符,\n表示換行符,\1、\2卻不是字符串中的合法轉(zhuǎn)義序列,所以也必須指定replacement為原生字符串(?98)。例3-22說(shuō)明了如何通過(guò)在replacement中使用引用分組轉(zhuǎn)換日期字符串的格式。

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

值得注意的是,如果想在replacement中引用整個(gè)表達(dá)式匹配的文本,不能使用\0,即便用原生字符串也不行。因?yàn)樵谧址校琝0開(kāi)頭的轉(zhuǎn)義序列通常表示用八進(jìn)制形式表示的字符,\0本身表示ASCII字符編碼為0的字符。如果一定要引用整個(gè)表達(dá)式匹配的文本,則可以稍加變通,給整個(gè)表達(dá)式加上一對(duì)括號(hào),之后用\1來(lái)引用,如例3-23所示。

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

3.3.1 反向引用

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

匹配字母的表達(dá)式是[a-z](這里暫時(shí)不考慮大寫(xiě)的情況),所以最先想到的往往是用兩個(gè)字符組[a-z][a-z]來(lái)匹配,但這樣做并不對(duì),因?yàn)橹丿B出現(xiàn)的字母千差萬(wàn)別。假設(shè)字符串是at,a可以由第一個(gè)[a-z]匹配,t可以由第二個(gè)[a-z]匹配,但是因?yàn)榍耙粋€(gè)[a-z]和后一個(gè)[a-z]之間并沒(méi)有聯(lián)系,所以[a-z][a-z]其實(shí)只能匹配兩個(gè)小寫(xiě)字母,不關(guān)心它們是否相同。

這個(gè)問(wèn)題有點(diǎn)復(fù)雜?!爸丿B出現(xiàn)”的字母,取決于第一個(gè)[a-z]在運(yùn)行時(shí)的匹配結(jié)果,而不能預(yù)先設(shè)定。也就是說(shuō)后面的部分必須“知道”前面部分匹配的內(nèi)容:如果前面的[a-z]匹配的是e,后面就只能匹配e;如果前面的[a-z]匹配的是o,后面就只能匹配o。

前面我們看到了引用分組,了解到能引用某個(gè)分組內(nèi)的子表達(dá)式匹配的文本,但引用都是在匹配完成后進(jìn)行的,能不能在正則表達(dá)式中引用呢?

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

根據(jù)反向引用,查找連續(xù)重疊字母的表達(dá)式就是([a-z])\1,其中的[a-z]匹配第一個(gè)字母,再用括號(hào)將匹配分組,然后用\1來(lái)反向引用,這個(gè)表達(dá)式的匹配情況見(jiàn)例3-24。

例3-24 用反向引用匹配重復(fù)字母

在日常開(kāi)發(fā)中,我們可能經(jīng)常需要反向引用來(lái)建立前后聯(lián)系。最常見(jiàn)的例子就是解析HTML代碼時(shí)匹配tag。之前我們說(shuō)過(guò),tag包括open tag和close tag,open tag和close tag經(jīng)常是成對(duì)出現(xiàn)的,比如<bold>text</bold>或<h1>title</h1>。

有了反向引用功能,就可以先匹配open tag,再匹配其他內(nèi)容,直到最近的close tag為止:在匹配open tag時(shí),用一個(gè)括號(hào)分組匹配tag name的表達(dá)式([^>]+);在匹配close tag時(shí),用\1引用之前匹配的tag name,就完成了配對(duì)(要注意的是,這里需要用到忽略?xún)?yōu)先量詞*?,否則可能會(huì)出現(xiàn)錯(cuò)誤匹配,理由在第2章匹配JavaScript代碼時(shí)講過(guò))。最后得到的表達(dá)式就是<([^>]+)>[\s\S]*?</\1>,這個(gè)表達(dá)式的匹配如例3-25所示。

例3-25 用反向引用匹配成對(duì)的tag

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

例3-26 用反向引用匹配更復(fù)雜的成對(duì)tag

反向引用還可以用在其他很多地方,比如在處理中文文本時(shí),用它很容易找出“浩浩蕩蕩”“清清白白”之類(lèi)AABB,或者“如火如荼”、“越快越好”之類(lèi)AXAY類(lèi)型的四字詞語(yǔ)。

關(guān)于反向引用,還有一點(diǎn)需要強(qiáng)調(diào):反向引用重復(fù)的是對(duì)應(yīng)捕獲分組匹配的文本,而不是之前的表達(dá)式;也就是說(shuō),反向引用是一種“引用”,對(duì)應(yīng)的是由之前表達(dá)式?jīng)Q定的具體文本,它本身并不規(guī)定文本的特征。這一點(diǎn),新手常犯錯(cuò)誤。

仍然以匹配IP地址為例,前面說(shuō)過(guò),IP地址分4段(4字節(jié)),匹配其中每一段的表達(dá)式是(0{0,2}[0-9]|0?[0-9]{2}|1[0-9][0-9]|2[0-4][0-9]|25[0-5]),之間用點(diǎn)號(hào).分隔,所以匹配完整IP地址的表達(dá)式應(yīng)該用量詞重復(fù)這個(gè)子表達(dá)式,而不是用反向引用重復(fù)這個(gè)表達(dá)式匹配的文本。例3-27對(duì)比了這兩個(gè)表達(dá)式,其中第二個(gè)表達(dá)式中使用了反向引用,故而要求后面3段與第1個(gè)字段完全一樣,所以它只能匹配8.8.8.8之類(lèi)的地址,而不能匹配192.168.0.1之類(lèi)地址。

例3-27 匹配IP地址的正則表達(dá)式

3.3.2 各種引用的記法

根據(jù)前面的介紹,對(duì)分組的引用可能出現(xiàn)在三種場(chǎng)合:在匹配完成后,用group(num)之類(lèi)的方法提取數(shù)據(jù);在進(jìn)行正則表達(dá)式替換時(shí),用\num引用;在正則表達(dá)式內(nèi)部,用\num引用。

不過(guò),這只是Python語(yǔ)言的規(guī)定,事情并不總是如此:group(num)之類(lèi)的方法,在各種語(yǔ)言中都是差不多的;但是在有些語(yǔ)言中,替換時(shí)引用的記法和正則表達(dá)式內(nèi)部引用的記法是不同的。表3-2總結(jié)了各種常用語(yǔ)言中的兩類(lèi)記法。[4]

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

(續(xù)表)

看起來(lái)\num和$num差別不大:\1或者$1表示第1個(gè)捕獲分組,\2或者$2表示第2個(gè)捕獲分組……不過(guò)一般來(lái)說(shuō),$num要好于\num。原因在于,$0可以準(zhǔn)確表示“第0個(gè)分組(也就是整個(gè)表達(dá)式匹配的文本)”,而\0則不行,因?yàn)樵诓簧僬Z(yǔ)言的字符串中,\num本身是一個(gè)有意義的轉(zhuǎn)義序列,它表示值為num的ASCII字符,所以\0會(huì)被解釋為“ASCII編碼為0的字符”。但是反向引用不存在這個(gè)問(wèn)題,因?yàn)椴荒茉谡齽t表達(dá)式還沒(méi)匹配結(jié)束時(shí),就用\0引用整個(gè)表達(dá)式匹配的文本。

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

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

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

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

PHP中也有專(zhuān)門(mén)的記法解決這類(lèi)問(wèn)題,在替換時(shí)可以使用\${num}的寫(xiě)法,準(zhǔn)確標(biāo)注所引用分組的編號(hào),也就是說(shuō),\${1}0表示“第1個(gè)捕獲分組之后加上0”,${10}表示“第10個(gè)捕獲分組”。而$10,在第10個(gè)捕獲分組存在的情況下,表示該捕獲分組;否則,被視為空字符串。PHP的代碼見(jiàn)例3-30。

例3-30 PHP中的引用

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

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

比如Java對(duì)\num中的num是這樣規(guī)定的:如果是一位數(shù),則引用對(duì)應(yīng)的捕獲分組;如果是兩位數(shù)且存在對(duì)應(yīng)捕獲分組時(shí),引用對(duì)應(yīng)的捕獲分組,如果不存在對(duì)應(yīng)的捕獲分組,則引用一位數(shù)編號(hào)的捕獲分組。

也就是說(shuō),如果確實(shí)存在編號(hào)為10的捕獲分組,則\10引用此捕獲分組匹配的文本;否則,\10表示“第1個(gè)捕獲分組匹配的文本”和“字符0”。程序的運(yùn)行結(jié)果見(jiàn)例3-31。

例3-31 Java中的引用

除Java之外,Ruby和JavaScript也采用這種規(guī)定,它看起來(lái)有點(diǎn)古怪,而且有一個(gè)問(wèn)題無(wú)法解決:如果存在編號(hào)為10的捕獲分組,無(wú)法用\10表示“編號(hào)為1的捕獲分組和字符0”,因?yàn)榇藭r(shí)\10表示的必然是編號(hào)為10的捕獲分組。

在開(kāi)發(fā)中,尤其是進(jìn)行文本替換時(shí)有時(shí)確實(shí)會(huì)遇到這個(gè)問(wèn)題,它在現(xiàn)有的規(guī)則下是無(wú)解的。好在,一般我們并不會(huì)用到太多的捕獲分組(包含捕獲分組數(shù)超過(guò)10個(gè)的表達(dá)式很少見(jiàn),也很難理解和維護(hù))。而且,已經(jīng)有越來(lái)越多的語(yǔ)言提供了命名分組,它可以徹底解決這個(gè)問(wèn)題。

3.3.3 命名分組

捕獲分組通常用數(shù)字編號(hào)來(lái)標(biāo)識(shí),但這樣有幾個(gè)問(wèn)題:數(shù)字編號(hào)不夠直觀,雖然規(guī)則是“從左向右按照開(kāi)括號(hào)出現(xiàn)的順序計(jì)數(shù)”,但括號(hào)多了難免混淆;引用時(shí)也不夠方便,上面已經(jīng)講過(guò)\10引起混淆的情況。

為解決這類(lèi)問(wèn)題,一些語(yǔ)言和工具提供了命名分組(named grouping),可以將它看作另一種捕獲分組,但是標(biāo)識(shí)是容易記憶和辨別的名字,而不是數(shù)字編號(hào)。

命名分組的記法也并不復(fù)雜。在Python中用(?P<name>regex)來(lái)分組的,其中的name是賦予這個(gè)分組的名字,regex則是分組內(nèi)的正則表達(dá)式。這樣,匹配年月日的正則表達(dá)式中,可以給年、月、日的分組分別命名,再用group(name)來(lái)獲得對(duì)應(yīng)分組匹配的文本。圖3-5說(shuō)明了命名分組的結(jié)構(gòu),具體的代碼見(jiàn)例3-32。

圖3-5 命名分組

例3-32 命名分組捕獲

因?yàn)閿?shù)字編號(hào)分組的歷史更長(zhǎng),為保證向后兼容性,即便使用了命名分組,每個(gè)命名分組同時(shí)也具有數(shù)字編號(hào),其編號(hào)規(guī)則沒(méi)有變化。從例3-33可以看到,在全部使用命名分組的情況下,仍然可以使用數(shù)字編號(hào)來(lái)引用分組。

例3-33 命名分組捕獲時(shí)仍然保留了數(shù)字編號(hào)

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

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

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

表3-3 不同語(yǔ)言中命名分組的記法

注1:Java 5和Java 6都不支持命名分組,根據(jù)目前看到的JRE的文檔,Java 7開(kāi)始支持命名分組[5],其記法與.NET相同。
注2:Ruby 1.9以上版本才支持使用命名分組。
注3:iOS 11以上版本才支持使用命名分組。
注4:支持ES2017(TC39)的JavaScript才支持使用命名分組。
1 在PHP 5.2.2以后可以使用\k<name>或者\(yùn)k'name',在PHP 5.2.4之后可以使用\k{name}和\g{name}。

3.4 非捕獲分組

到目前為止,總共介紹了括號(hào)的三種用途:分組,將相關(guān)的元素歸攏到一起,構(gòu)成單個(gè)元素;多選結(jié)構(gòu),規(guī)定可能出現(xiàn)的多個(gè)子表達(dá)式;引用分組,將子表達(dá)式匹配的文本存儲(chǔ)起來(lái),供之后引用。

這三種用途并不是彼此獨(dú)立的,而是互相重疊的:?jiǎn)渭兊姆纸M可以視為“只包含一個(gè)多選分支的多選結(jié)構(gòu)”;整個(gè)多選結(jié)構(gòu)也會(huì)被視為單個(gè)元素,可以由單個(gè)量詞限定。最重要的是,無(wú)論是否需要引用分組,只要出現(xiàn)了括號(hào),正則表達(dá)式在匹配時(shí)就會(huì)把括號(hào)內(nèi)的子表達(dá)式存儲(chǔ)起來(lái),提供引用。如果并不需要引用,保存這些信息無(wú)疑會(huì)影響正則表達(dá)式的性能;如果表達(dá)式比較復(fù)雜,要處理的文本又很多,更可能?chē)?yán)重影響性能。

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

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

非捕獲分組不需要保存匹配的文本,整個(gè)表達(dá)式的效率也因此提高,但是看起來(lái)不如捕獲分組美觀,所以很多人不習(xí)慣這種記法。不過(guò),如果只需要使用括號(hào)的分組或者多選結(jié)構(gòu)的功能,而沒(méi)有用到引用分組,則應(yīng)當(dāng)盡量使用非捕獲型括號(hào)。

如果不習(xí)慣這種記法,比較好的辦法是,在寫(xiě)正則表達(dá)式時(shí)先統(tǒng)一使用捕獲分組,確保正確之后,再把不需要引用的分組改為非捕獲分組—當(dāng)然,引用分組的編號(hào)可能也要調(diào)整(在上例中,只需要取月份信息,把第一個(gè)分組改為非捕獲分組之后,取月份信息對(duì)應(yīng)分組的編號(hào)從2變?yōu)?)。

在本書(shū)中,為了使代碼簡(jiǎn)潔和易于閱讀,除非特殊標(biāo)注,否則不管匹配完成之后是否會(huì)引用文本,都使用捕獲分組。

3.5 補(bǔ)充

3.5.1 轉(zhuǎn)義

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

括號(hào)的轉(zhuǎn)義與它們都不同,與括號(hào)有關(guān)的所有三個(gè)元字符(、)、|都必須轉(zhuǎn)義。因?yàn)槔ㄌ?hào)非常重要,所以無(wú)論是開(kāi)括號(hào)還是閉括號(hào),只要出現(xiàn),正則表達(dá)式就會(huì)嘗試尋找整個(gè)括號(hào),如果只轉(zhuǎn)義了開(kāi)括號(hào)而沒(méi)有轉(zhuǎn)義閉括號(hào),一般會(huì)報(bào)告“括號(hào)不匹配”的錯(cuò)誤。另一方面,多選結(jié)構(gòu)中的|也必須轉(zhuǎn)義(多選結(jié)構(gòu)可以不用括號(hào)只出現(xiàn)|),所以,也不要忘記對(duì)|的轉(zhuǎn)義,否則就可能出現(xiàn)例3-36的問(wèn)題。

例3-36 括號(hào)的轉(zhuǎn)義

3.5.2 URL Rewrite

提到括號(hào)的分組和引用功能,就不能不提到URL Rewrite。URL Rewrite是常見(jiàn)Web服務(wù)器中都具備(也必需)的功能,它用來(lái)進(jìn)行網(wǎng)址的轉(zhuǎn)發(fā),下面是一個(gè)轉(zhuǎn)發(fā)的例子。

■ 外部訪(fǎng)問(wèn)URL

http://www.example.com/blog/2006/12

■ 內(nèi)部實(shí)現(xiàn)

http://www.example.com/blog/posts.php?year=2006&month=12

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

一般來(lái)說(shuō),URL Rewrite都是使用轉(zhuǎn)發(fā)規(guī)則實(shí)現(xiàn)的,每條轉(zhuǎn)發(fā)規(guī)則對(duì)應(yīng)一類(lèi)URL,以正則表達(dá)式解析并提取出所需要的信息,重組之后再轉(zhuǎn)發(fā)。比如上面的轉(zhuǎn)發(fā),就需要先提取年、月、日的信息進(jìn)行重組。很自然的,我們會(huì)想到使用括號(hào)和引用分組的功能來(lái)實(shí)現(xiàn)。下面就以剛才提到的日期轉(zhuǎn)發(fā)為例,看上面的轉(zhuǎn)發(fā)規(guī)則在當(dāng)前主流的Web服務(wù)器中如何配置。

Microsoft IIS

在Web.config配置文件中,找到<rewrite>節(jié)點(diǎn),在<rules>下新增下面的代碼。

其中<match>節(jié)點(diǎn)中的url是外部訪(fǎng)問(wèn)的URL。對(duì)轉(zhuǎn)發(fā)的URL而言,能接收的都是path部分,如果URL是http://www.example.com/blog/2006/12,則path就是blog/2006/12。正則表達(dá)式以^blog開(kāi)頭,分別用[0-9]{4}、[0-9]{2}匹配其中的年、月信息,因?yàn)橹蟮霓D(zhuǎn)發(fā)需要用到這些信息,所以必須使用捕獲分組以便引用。另外,因?yàn)閁RL最后可能出現(xiàn)反斜線(xiàn)/,也可能不出現(xiàn),意義沒(méi)有區(qū)別,所以使用了量詞/?。

Action節(jié)點(diǎn)中的url則是轉(zhuǎn)發(fā)之后(也就是內(nèi)部使用)的URL,轉(zhuǎn)發(fā)到blog/posts.php,且將年、月信息作為請(qǐng)求參數(shù),附在后面。在IIS中,通過(guò){R:num}的記法引用分組,其中num為對(duì)應(yīng)分組的編號(hào);另外,因?yàn)檫@是一個(gè)XML文件,所有的&必須轉(zhuǎn)義為&amp;,URL中的&也不例外。

關(guān)于IIS中URL Rewrite的具體信息,可以參考下面的詳細(xì)文檔。

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

Apache

在httpd.conf配置文件中,找到虛擬主機(jī)對(duì)應(yīng)的配置字段,首先確認(rèn)啟用了URL Rewrite功能,也就是保證出現(xiàn)了下面這行:

然后編寫(xiě)規(guī)則,上面的轉(zhuǎn)義對(duì)應(yīng)的規(guī)則如下:

以RewriteRule開(kāi)頭的行指定了轉(zhuǎn)發(fā)規(guī)則,RewriteRule之后是外部URL和轉(zhuǎn)發(fā)的URL,最后是可選出現(xiàn)的標(biāo)志位(flags,[L]表示“如果URL匹配成功,按本條規(guī)則轉(zhuǎn)發(fā)之后,不再考慮其他轉(zhuǎn)發(fā)規(guī)則”),這幾個(gè)字段之間用任意空白字符分隔。在Apache中,分組的引用使用$num的形式,其中num為分組對(duì)應(yīng)的編號(hào)。

關(guān)于Apache中URL Rewrite的具體信息,可以參考下面的詳細(xì)文檔。

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配置文件中找到對(duì)應(yīng)虛擬主機(jī)的配置字段,在其中添加下面的規(guī)則。

以rewrite開(kāi)頭的行指定了轉(zhuǎn)發(fā)規(guī)則,rewrite之后是外部URL和轉(zhuǎn)發(fā)的URL,最后是可選出現(xiàn)的標(biāo)志位(flags,last的含義與Apache轉(zhuǎn)發(fā)規(guī)則中的[L]相同),這幾個(gè)字段之間也是用任意空白字符分隔(要注意,行的末尾必須有分號(hào);)。在Nginx中,使用$num的記法引用分組,其中num為分組對(duì)應(yīng)的編號(hào)。

相對(duì)來(lái)說(shuō),Nginx的轉(zhuǎn)發(fā)功能最為強(qiáng)大,因?yàn)锳pache和IIS的轉(zhuǎn)發(fā)一般都只限于單條語(yǔ)句,但是Nginx的轉(zhuǎn)發(fā)可以使用復(fù)雜的判斷邏輯,比如下面的轉(zhuǎn)發(fā)首先判斷瀏覽器的user-agent,如果是IE則轉(zhuǎn)發(fā),否則不轉(zhuǎn)發(fā)。

關(guān)于Nginx中URL Rewrite的具體信息,可以參考下面的詳細(xì)文檔。

http://wiki.nginx.org/HttpRewriteModule

3.5.3 一個(gè)例子

這部分內(nèi)容來(lái)自一位朋友的問(wèn)題,這個(gè)問(wèn)題相當(dāng)有迷惑性和代表性,所以不妨列在這里,希望能解開(kāi)更多讀者的類(lèi)似疑惑。

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

其實(shí)答案很簡(jiǎn)單—因?yàn)楸磉_(dá)式(\w+\.?)+中存在量詞+,所以在整個(gè)正則表達(dá)式的匹配過(guò)程中,括號(hào)內(nèi)的\w+\.?會(huì)多次匹配:第1次匹配aaa.,第2次匹配bbb.,第3次(也就是最后)匹配ccc,最終這個(gè)捕獲分組匹配的文本就是ccc。調(diào)用re.findall()時(shí),因?yàn)榇嬖诶ㄌ?hào)(也就是捕獲分組),默認(rèn)返回捕獲分組匹配的文本,也就是ccc。

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

要搞清楚這個(gè)問(wèn)題,需要記住:捕獲分組的個(gè)數(shù)是不能動(dòng)態(tài)變化的—單個(gè)正則表達(dá)式里有多少個(gè)捕獲分組,一次匹配成功之后,結(jié)果中就必然存在多少個(gè)對(duì)應(yīng)的元素(捕獲分組匹配的文本)。如果不能預(yù)先規(guī)定匹配結(jié)果中元素的個(gè)數(shù),就不能使用捕獲分組。如果要匹配數(shù)目不定的多段文本,必須通過(guò)重復(fù)多次匹配完成。具體到這個(gè)例子,在re.findall('\w+\.?','aaa.bbb.ccc')中,整個(gè)正則表達(dá)式會(huì)匹配成功3次,得到3個(gè)子串;如果把字符串改為aaa.bbb.ccc.ddd,則整個(gè)正則表達(dá)式會(huì)匹配成功4次,得到4個(gè)子串。


[1]一般來(lái)說(shuō),最后的x可以是小寫(xiě)也可以是大寫(xiě),但也有些部門(mén)規(guī)定身份證號(hào)碼最后的x必須是大寫(xiě)X,這里為講解方便,只考慮了小寫(xiě)x的情況;如果要兼容大寫(xiě)X或保證只能出現(xiàn)大寫(xiě)X,只需要修改最后的字符組[0-9x]即可。

[2]雖然常見(jiàn)的頂級(jí)域名是cn、com、info之類(lèi)的,最長(zhǎng)4個(gè)字母,最短2個(gè)字母,但情況并非都是如此。在有些內(nèi)部系統(tǒng)中,主機(jī)名中并不包含點(diǎn)號(hào),可以視為頂級(jí)域名,所以這里認(rèn)為頂級(jí)域名也是一個(gè)普通域名字段。

[3]雖然HTML 4.0規(guī)范中沒(méi)有限制tag名的大小寫(xiě),但XHTML規(guī)范要求都使用小寫(xiě),在平時(shí)處理中,tag名也大多使用小寫(xiě),如果實(shí)在要處理大寫(xiě)的情況,可以使用字符組,比如img寫(xiě)作[iI][mM][gG],更省事的辦法是指定不區(qū)分大小寫(xiě)的模式,?83。

[4]在很多文檔中都表示為\n,但\n容易誤解為換行符,所以本書(shū)中統(tǒng)一用\num表示。

[5]可參考http://cr.openjdk.java.net/~sherman/6350801/webrev.00/regex/Pattern.html#groupname。

主站蜘蛛池模板: 乐东| 大竹县| 凤山县| 儋州市| 渭源县| 北票市| 栖霞市| 眉山市| 乌兰县| 河东区| 晋中市| 包头市| 和平县| 隆安县| 卢氏县| 胶南市| 奉化市| 塘沽区| 衡东县| 平潭县| 台安县| 保亭| 荔波县| 咸丰县| 葫芦岛市| 潜山县| 桂林市| 莫力| 皮山县| 揭东县| 孝感市| 定南县| 榆社县| 社会| 浠水县| 南岸区| 肥西县| 万州区| 汤阴县| 禹州市| 济阳县|