本節書摘來自異步社群《正規表達式經典執行個體(第2版)》一書中的第2章,第2.16節,作者: 【美】jan goyvaerts , steven levithan著,更多章節内容可以通路雲栖社群“異步社群”公衆号檢視
問題描述
找出在一對html粗體标簽之間的任何單詞,但是不要把标簽包含到正規表達式比對中。例如,如果目标文本是my <b>cat</b> is furry,那麼唯一的比對應當是cat。
解決方案
javascript和ruby 1.8支援順序環視(lookahead)‹(?=)›,但是不支援逆序環視(lookbehind)‹(?<=< b >)›。
讨論
環視
現代的正則流派都支援四種類型的環視(lookaround),它們擁有特殊的能力,可以放棄在環視内部的正規表達式所比對的文本。實質上,環視會檢查某些文本是否可以被比對,但是并不會實際去比對它。
向回看的環視被稱作是逆序環視。這是唯一可以從右向左而非從左向右周遊目标文本的正規表達式結構。肯定型逆序環視(positive lookbehind)的文法是‹(?<= ⋯)›。‹(?<=›4個字元構成了起始括号。你能在逆序環視内部放入什麼内容(這裡由‹⋯›表示)在不同正規表達式流派中是不一樣的。但是簡單的字面文本總是沒問題的,如‹(?<=< b >)›。
逆序環視會檢查在逆序環視中的文本是否直接出現在正規表達式引擎所到達位置的左邊。如果用‹(?<=< b >)›來比對my < b >cat b > is furry,隻有到正規表達式在目标文本中的字母c處開始進行比對嘗試時,逆序環視才會比對成功。正則引擎接着會進入逆序環視分組,告訴它向左邊看。‹< b >›在c的左邊成功比對。正則引擎會在這個時候退出逆序環視,并且丢棄逆序環視所比對到的任何文本。換句話說,正在進行的比對會回到引擎剛剛進入逆序環視的地方。在這個例子中,正在進行比對的是目标字元串中c之前的一個長度為0的比對。逆序環視隻會測試或者斷言‹< b >›是否可以被比對;但是它并不會實際上去比對它。環視結構是以也被稱作長度為0的斷言。
在逆序環視比對之後,字元組簡寫‹w+›會嘗試去比對一個或者多個單詞字元。它會比對cat。‹w+›并不屬于任何類型的環視或者分組,是以它會正常地比對文本cat。我們說‹w+›比對并且消耗(consume)了cat,而環視則隻能比對内容,卻從來不會消耗任何東西。
向前看的環視,也就是按照正規表達式通常周遊文本的方向,被稱作順序環視(lookahead)。順序環視在本書中的所有正則流派中都擁有同等的支援。肯定型順序環視(positive lookahead)的文法是‹(?=⋯)›。這3個字元‹(?=›構成了該分組的起始括号。在一個正規表達式中可以使用的任何符号都可以在順序環視内部使用,在這裡用‹⋯›來表示。
當‹(?<=< b >)w+(?=< /b >)›中的‹w+›比對了my < b >cat b > is furry中的cat的時候,正則引擎就進入了順序環視。在這個時候順序環視唯一特殊的行為是正則引擎會記住它已經比對了的文本部分,并把它同順序環視關聯起來。‹ b >›随後會正常比對。現在正則引擎退出順序環視。在環視中的正規表達式成功比對,是以環視自身也就比對成功了。正則引擎還原在進入環視之前它記住的正在進行的比對,進而丢棄由環視比對的文本。這樣我們整體上的比對程序就回到了cat。因為我們正規表達式也在此結束,是以cat就成為了最終的比對結果。
否定型環視
把環視中的等号換成感歎号的話,‹(?!⋯)›就變成了否定型順序環視(negative lookahead)。否定型順序環視與肯定型順序環視用起來是一樣的,唯一的差別是,肯定型順序環視會在順序環視中的正則式比對時成功比對,而否定型順序環視則正好相反,它在當順序環視内的正則式比對時,比對失敗。
比對的過程則是完全相同的。引擎會在進入否定型順序環視的時候儲存目前比對程序,然後試圖正常地比對順序環視中的正規表達式。如果這個子表達式比對的話,那麼否定型順序環視會失敗,而正則引擎會進行回溯。如果這個子表達式不能比對的話,那麼引擎會恢複儲存的比對程序,然後繼續處理正規表達式的剩餘部分。
類似的,‹(?
不同層次的逆序環視
順序環視用起來比較容易。本書中讨論的所有正則流派都支援在順序環視中放入一個完整的正規表達式。在正規表達式中可以使用的任何符号都可以用于順序環視之内。你甚至可以在順序環視内嵌套其他順序環視和逆序環視分組。你的大腦可能需要多繞幾個彎,但是正則引擎會把這一切都處理得很好。
逆序環視的情況則不同。正規表達式軟體總是設計成按照從左向右的方式查找目标文本。向回查找的實作通常需要一些特殊的處理:正則引擎會判斷你在逆序環視中輸入了多少個字元,回退那麼多數量的字元,然後再在目标文本中從左向右比對位于逆序環視中的文本。
基于這個原因,最早的實作中隻允許在逆序環視中包含固定長度的字面文本。盡管perl和python仍需要逆序環視擁有固定的長度,但它們已經允許固定長度的正規表達式記号,如字元組,以及所有分支字元數都相同的選擇分支。
pcre和ruby 1.9則更進一步,允許逆序環視中使用不同分支長度的選擇分支,隻要各分支的長度是不變的。它們可以處理類似如下的正則式:‹(?<=one|two|three|forty- two|gr[ae]y)›,但是無法處理更為複雜的情況。
pcre和ruby 1.9在内部會把這個表達式擴充為6個逆序環視測試。首先,它們會回跳3個字元來測試‹one|two›,接着回跳4個字元來測試‹gray|grey›,然後回跳5個字元來測試‹three›,最後回跳9個字元測試‹forty-two›。
java對于逆序環視則更進一步。java允許在逆序環視中使用任意的有限長度的正規表達式。這意味着你可以使用除了無限長度量詞‹*›、‹+›和‹{42,}›之外的所有符号。java的正則引擎在内部會計算在逆序環視中的正規表達式可能會比對的文本的最小和最大長度。如果它比對失敗的話,那麼引擎會多回退一個字元再試,直到逆序環視成功比對或者嘗試過了最大字元數目。
似乎這些聽起來都不是很高效,事實上也正是如此。逆序環視用起來是非常友善的結構,但是它的速度就很一般了。稍後,我們會講解在根本不支援逆序環視的javascript和ruby 1.8中的一個解決方案。這個解決方案實際上會比使用逆序環視的效率要高很多。
.net架構中的正規表達式引擎是唯一可以實際上從右向左應用一個完整正規表達式的引擎1ff。.net允許在逆序環視中使用任何符号,而且它會實際上從右向左來應用正規表達式。在逆序環視中的正規表達式和目标文本都是按照從右向左來進行掃描的。
比對相同的文本兩次
如果在正規表達式的開始處使用逆序環視,或者在正規表達式的結尾處使用順序環視,其效果就是要求在正則比對之前或者之後出現一些東西,但不要把它們包含到比對中。如果在正規表達式的中間使用環視的話,就可以對同一段文本進行多次測試。
在執行個體2.3的“流派相關的特性”小節中,我們講解了如何使用字元組補集來比對一個泰國語的數字。隻有在.net和java中才會支援字元組補集。
如果一個字元既是泰國語字元(任何類别),又是數字(任意字母表),那麼它就是一個泰國語數字。如果使用順序環視,你可以在同一個字元上檢查這兩個要求:
這個正規表達式隻能用于在執行個體2.7所講解的支援unicode字母表的3種流派。但是使用順序環視來多次比對同一個字元的思想則可以用于本書中讨論的所有流派。
當正則引擎查找‹(?=p{thai})p{n}›的時候,它首先會在開始進行比對嘗試的字元串中的每一個位置進入順序環視。如果該位置的字元不在泰國語字母表(也就是說‹p{thai}›比對失敗)中,那麼這次順序環視就會失敗。這也會導緻整個比對嘗試失敗,并迫使正則引擎到下一個字元處重新進行嘗試。
如果正規表達式遇到一個泰國語字元,‹p{thai}›成功比對。是以,環視‹(?=p{thai})›也會比對成功。當引擎退出環視的時候,它會恢複之前的比對程序。在這個例子中,也就是在剛找到泰國語字元之前的長度為0的比對。接下來要比對的是‹p{n}›。因為順序環視已經丢棄了它的比對,是以‹p{n}›會同‹p{thai}›已經比對了的那個字元進行比較。如果該字元擁有unicode屬性number的話,那麼‹p{n}›會比對成功。因為‹p{n}›并不在環視之内,是以它會真正比對這個字元,同時我們也就找到了想要的泰國語數字。
環視是固化分組
當正規表達式引擎退出一個環視分組的時候,它會丢棄掉環視比對的文本。因為該文本被丢棄了,是以由位于環視之内的選擇分支或者量詞所記住的任意回溯位置也都會被丢棄。這樣實際上就會把順序環視和逆序環視都變成了固化分組。執行個體2.15中詳細講解了固化分組的概念。
在絕大多數情形下,環視的原子特性是無關緊要的。一個環視隻是用來檢查位于環視中的正規表達式是比對成功還是失敗的斷言。它可以通過多少種不同方式比對并不重要,因為它不會消耗目标文本中的任何字元。
當你在順序環視(以及逆序環視,如果你的正則流派支援)之内使用捕獲分組的時候,它的固化特性才會産生意義。雖然順序環視不會消耗任何文本,但是正則引擎會記住文本中哪些部分被位于順序環視中的任何捕獲分組比對了。如果順序環視位于正規表達式的結尾處,那麼實際上你的捕獲分組所比對的文本是正規表達式自身沒有比對的。如果這個順序環視位于正規表達式中間,那麼你的多個捕獲分組比對到的目标文本可能會相重疊。
環視的固化特性唯一能改變整個正規表達式的比對的情況是,你在環視之外使用一個反向引用來指向在環視之内所建立的捕獲分組。思考這個正規表達式:
乍一看,你可能會認為這個正規表達式能夠比對123x12。‹d+›會把12捕獲到第一個捕獲分組中,接着‹w+›會比對3x,最後‹1›會再次比對12。
但這不可能發生。正規表達式會進入環視及捕獲分組。貪心的‹d+›會比對123。這個比對存儲到第一個捕獲分組中。引擎接着退出順序環視,把目前比對重新設定為字元串的開始,并且丢棄由貪心的加号所記住的回溯位置,但是會在第一個捕獲分組中保留所存儲的123。
現在,貪心的‹w+›會在字元串開始處進行嘗試。它會把123x12都吃掉。這時指向123的‹1›在字元串結尾處比對失敗。‹w+›會回溯一個字元。‹1›還是會失敗。‹w+›會繼續回溯,直到它放棄了除了目标文本中第一個1之外的所有字元。‹1›在第一個1之後還是會比對失敗。
如果正則引擎能夠傳回到順序環視中,放棄123而選擇12,那麼最後的12會比對‹1›。但是正則引擎并不會這樣做。
正則引擎此時并不存在可以選擇的任何回溯位置。‹w+›已經回退到頭了,而環視迫使‹d+›把它的回溯位置都丢掉了。是以比對嘗試會宣告失敗。
代替逆序環視
perl 5.10、pcre 7.2及更高版本,提供了使用‹k›代替逆序環視的機制。當正則引擎在正規表達式中遇到‹k›時,引擎會保持之前所比對的文本。比對嘗試會如不存在‹k›一樣繼續。但‹k›之前所比對的文本并不會包含在整個比對結果中。‹k›之前的捕獲分組所儲存的文本仍可以用于‹k›之後的反向引用。隻有整個比對結果受‹k›影響。
結果是許多情況下都可以使用‹k›代替肯定型逆序環視。與‹(?<=before)text›相同,‹beforektext›僅比對緊跟在before之後的text。在perl和pcre中使用‹k›而非肯定型逆序環視的好處是可以在‹k›前使用完整的正規表達式文法,而逆序環視有各種限制,如不允許使用量詞。
‹k›和逆序環視的主要差別是,使用‹k›時,正則式嚴格地從左向右比對。它永遠不會向回看,而逆序環視則會向回看。當‹k›後面或逆向環視後面的比對,與‹k›前面或逆向環視内比對的文本相同時,這個差別就會帶來影響。
正則式‹(?<=a)a›在字元串aaa中可以找到2個比對。在字元串開始處進行的第一次比對嘗試會失敗,因為正則引擎無法在開始處之前找到一個a。在第一和第二個a之間的位置進行的比對嘗試可以成功。正則引擎向回看找到字元串中第一個a滿足逆序環視條件,正則式中第二個‹a›比對字元串中第二個a。在第二和第三個a之間的位置進行的比對嘗試同樣會成功。正則引擎向回看找到字元串中第二個a滿足逆序環視條件,随後正則式比對第三個a。在字元串末尾進行的最後一次比對嘗試失敗。向回看第三個a滿足逆序環視條件,但字元串中沒有更多字元可以比對正則式第二個‹a›。
正則式‹aka›隻能在同一字元串中找到1個比對。在字元串開始處進行的第一次比對嘗試成功。正則式中第一個‹a›比對字元串中第一個a。‹k›将這一部分比對排除出最終傳回的比對結果,但并不改變目前比對過程。随後正則式中第二個‹a›比對字元串中第二個a,作為最終傳回的完整比對結果。第二次比對嘗試從字元串中第二和第三個a之間的位置開始。正則式中第一個‹a›比對字元串中第三個a。‹k›将這一比對排除出最終比對結果,正則引擎正常前進。不過字元串中已經沒有字元可供正則式中第二個‹a›比對,于是比對嘗試失敗。
由此可見,使用‹k›時,正則式比對過程正常進行。正則式‹aka›找到的比對與正則式‹a(a)›中捕獲分組的比對相同。你不能用‹k›多次比對字元串中同一部分。而逆序環視則可以。可以用‹(?<=p{thai})(?<=p{nd})a›比對一個緊跟在屬于泰語字母表和數字的單個字元之後的a。如果嘗試的是‹p{thai}kp{nd}ka›,那比對的則是一個泰語字元緊跟一個數字再緊跟一個a,即使隻傳回a作為比對結果。而這和使用‹p{thai}p{nd}(a)›比對相同3個字元時,捕獲分組所傳回的結果相一緻。
不使用逆序環視的解決方案
雖然前面講的這麼複雜,但是如果你用的是ruby 1.8或者javascript,那麼這些對你都毫無用處,因為你根本就不能使用逆序環視。使用這兩種正則流派無法以上述方式解決前面所給出的問題,但是你可以通過使用捕獲分組來解決需要逆序環視的問題。下面給出的這個替代方案也可以在所有其他正則流派中使用:
作為逆序環視的替代,我們使用了一個捕獲分組來比對起始标簽:‹< b >›。我們還把所需要的比對部分,也就是‹w+›,放到了另一個捕獲分組中。
當把這個正規表達式應用到my < b >cat b > is furry之上的時候,這個正規表達式的完整比對會是< b >cat。第一個捕獲分組會儲存< b >,而第二個會儲存cat。
如果題目的要求是隻比對cat(在兩個< b >标簽之間的單詞),即你隻想提取文本中的這部分内容的話,那麼可以通過隻儲存第二個捕獲分組所比對的文本,而不是整個正規表達式比對的文本來達到這一目标。
如果要求是想要進行查找和替換,而隻替換在兩個标簽之間的單詞的話,那麼可以使用一個反向引用來指向第一個捕獲分組,把起始标簽重新添加到替代文本中。在這個例子中,實際上并不需要捕獲分組,因為起始标簽總是相同的。但是當它可變的時候,捕獲分組會重新插入與前面比對到的一模一樣的内容。執行個體2.21對此有更詳細的講解。
最後,如果你真的想要模拟逆序環視的話,可以使用兩個正規表達式來完成。首先,使用普通表達式,而不是不使用逆序環視,來查找你的正規表達式。當它比對成功時,把在比對部分前面的目标文本子串複制到一個新的字元串變量中。然後用第二個正規表達式,加上字元串結束定位符(‹z›或‹$›),進行你在逆序環視中所做的測試。這個定位符會確定第二個正則式的比對一定位于該字元串的結尾。因為剪切字元串的地方是第一個正規表達式比對的地方,是以這樣就會把第二個比對剛好放到第一個比對的左邊。
在javascript中,可以使用如下的代碼來完成這項任務: