一般來說,遞迴的正規表示式用來匹配任意巢狀層次的結構或左右對稱的結構。例如匹配:
((((()))))
(hello (world) good (boy) bye)
<p>hello world <strong>hello world</strong> </p>
abc.def.ghij...stu.vwx.yz
abcdcba
123454321
遞迴正則在正規表示式裡算是比較靈活的部分,換句話說就是可能會比較難。下面這個正規表示式是在網上流傳的非常廣泛的遞迴正則的示例,它用來匹配巢狀任意次數的括號,括號內可以有其它字元,比如可以匹配(a(bc)de)
、(abc(bc(def)c)de)
。
# 使用了x修飾符,忽略正規表示式內的空白符號
/\( ( (?>[^()]+) | (\g<0>) )* \)/x
這似乎看不怎麼懂?其實即使知道了正則遞迴的方式,也還是很難看懂(至少,我分析了很久)。
難懂的原因大概是因為這裡使用的固化分組在多選分支|
中屬於一個技巧性的寫法,而且分組外還使用了量詞*
,這些結合起來就太難懂了。
正因為網上到處流傳這個例子,曾使我多次對遞迴正則的學習望而卻步。這裡我也不去解釋這個遞迴正則的含義,因為"太學術化"或者說"太裝xyx逼",而一般遞迴正則完全可以寫的很簡單但卻能實現目標。
如何寫出簡單易懂版本的遞迴正則並且理解遞迴正則的匹配方式,正是本文的目標。在後文,我介紹了一個更加簡單、更加容易理解的版本,同樣能實現這個遞迴匹配的需求。
為了解釋清楚遞迴正則,本文會以循序漸進的方式逐步深入到遞迴正則的方方面面。所以,篇幅可能稍大,其中大量篇幅都用在瞭解釋分析遞迴正則是如何遞迴匹配上。
注:
本文以Ruby的正規表示式來介紹遞迴正則,但對其它支援遞迴正則的語言也是能通用的。例如Perl、PHP、Python(自帶的re不提供,但第三方庫regex提供遞迴正則)等。
理解反向引用\N和\g
首先通過正規表示式的反向引用的用法來逐步引入遞迴正規表示式的用法。
正規表示式(abc|def) and \1xyz
可以匹配字串"abc and abcxyz"或"def and defxyz",但是不能匹配"abc and defxyz"或def and abcxyz
。這是因為,反向引用在引用的時候,只能引用之前分組捕獲成功後的那個結果。
reg = /(abc|def) and \1xyz/
reg =~ "abc and abcxyz" #=>0
reg =~ "def and defxyz" #=>0
reg =~ "def and abcxyz" #=>nil
reg =~ "abc and defxyz" #=>nil
但是,如果使用\g<1>
來代替\1
,那麼就能匹配這四種情形的字串(Perl中使用(?1)
對應這裡的\g<1>
):
reg = /(abc|def) and \g<1>xyz/
reg =~ "abc and abcxyz" #=>0
reg =~ "def and defxyz" #=>0
reg =~ "def and abcxyz" #=>0
reg =~ "abc and defxyz" #=>0
\g<1>
和\1
的區別在於:\1
在反向引用的時候,引用的是該分組捕獲到的結果值,\g<1>
則不是反向引用,而是直接將索引號為1的分組捕獲重新執行捕獲分組的匹配操作。相當於是/(abc|def) and (abc|def)xyz/
。
所以,\1
相當於是在引用的位置插入索引號為1的分組捕獲的結果,\g<1>
相當於是在此處插入索引號為1的分組捕獲表示式,讓其能再次進行分組表示式這部分的匹配操作。
如果把分組捕獲表示式看作是函式的定義,那麼開始匹配時表示呼叫該函式進行分組捕獲。而反向引用\N
則是在引用位置處插入該函式的返回值,\g<name>
則表示在此處再次呼叫該函式進行匹配。
\g<name>
的name可以是數值型的分組索引號,也可以是命名捕獲的名稱索引,還可以是0表示整個正規表示式自身。
/(abc|def) and \g<1>xyz/
/(?<var>abc|def) and \g<var>xyz/
/(abc|def) and \g<0>xyz/ # 錯誤正則,稍後分析
=begin
# Perl、Python(regex,非re)、PHP與之對應的方式:
\g<0> -> (?R)或(?0)
\g<N> -> (?N)
\g<name> -> (?P>name)或(?&name)
=end
前面兩種好理解,第三種使用\g<0>
就不太能理解了,繼續向下看。
初探遞迴正則:遞迴正則匹配什麼
\g<0>
表示正規表示式自身,所以這相當於是遞迴正規表示式,假如進行第一輪正規表示式替換的話,相當於:
/(abc|def) and (abc|def) and \g<0>xyzxyz/
當然,這裡只是為了幫助理解才將\g<0>
替換成正規表示式,但它不會真的直接替換正規表示式的定義。就像函式呼叫時,不會在呼叫函式的地方替換成函式定義裡的程式碼再去執行,函式定義了就能多次複用。
不管怎樣,不難發現這裡已經出現了無限遞迴的可能性,因為替換一輪後的正規表示式中再次包含了\g<0>
,它可以再次進行第二輪替換、第三輪替換......
那麼,對於/(abc|def) and \g<0>xyz/
這個遞迴的正規表示式來說,它能匹配什麼樣的字串呢?這才是理解正則遞迴時最需要關心的。
可以將上面的\g<0>
看作是一個佔位符,首先它可以匹配"abc and _xyz"或者def and _xyz
這種格式的字串,這裡我用了_
表示\g<0>
佔位符。遞迴一輪的話,它可以匹配"abc and def and _xyzxyz",這裡又會繼續遞迴下去,將沒完沒了。所以這裡先將該正則匹配什麼字串的問題保留,稍後再回頭分析。
事實上,/(abc|def) and \g<0>xyz/
是錯誤的正規表示式,它會提示我們,遞迴沒有終點:
/(abc|def) and \g<0>xyz/
#=>SyntaxError: never ending recursion
所以,使用遞迴正則必須要保證遞迴能夠有終點。
保證正則遞迴的終點
怎麼保證遞迴正則的終點呢?只要給\g<>
這部分做一個量詞的限定即可,比如:
\g<0>+ # 錯誤正則
\g<0>{3} # 錯誤正則
\g<0>{,3} # 錯誤正則
\g<0>* # 正確正則
\g<0>? # 正確正則
\g<0>{0} # 正確正則
pat|\g<0> # 正確正則
(\g<0>)* # 正確正則
(\g<0>)? # 正確正則
...
\g<0>+
表示遞迴至少1輪,但是這裡已經錯了,因為遞迴多次的時候,\g<0>
這個佔位符及其量詞+
將始終保留在最後一輪的結果中,於是導致無限遞迴。同理\g<0>{3}
這種表示嚴格遞迴三次的方式也是錯誤的,因為遞迴第三次後仍然保留了\g<0>{3}
佔位符及其量詞{3}
,這也將無限遞迴。
所以,只有\g<0>*
和\g<0>?
和\g<0>{0}
和pat|\g<0>
等這種能在量詞數量選擇意義上表示遞迴0次的方式才是正確的正規表示式語法,因為無論遞迴多少次,最後一次的佔位符的量詞都可以是0次,從而達到遞迴的終點,即停止遞迴。
所以,修改前面的正規表示式,假如使用?
量詞修飾\g<>
:
/(abc|def) and \g<0>?xyz/
再探遞迴正則:遞迴正則匹配什麼
回到之前遺留的問題,現在這個正確的遞迴正規表示式/(abc|def) and \g<0>?xyz/
能匹配什麼樣的字串呢?
按照之前的分析,它能匹配的字串的模式類似於abc and _?xyz
或者def and _?xyz
。
如果量詞?
取0次,那麼該遞迴正則匹配的是"abc and xyz"或"def and xyz":
reg = /(abc|def) and \g<0>?xyz/
reg =~ "abc and xyz" #=> 0
reg =~ "def and xyz" #=> 0
如果量詞?
取1次,那麼該遞迴一輪後的正則模式為abc and abc and _?xyzxyz
,其中任何一個"abc"替換成"def"都是滿足條件的。那麼這裡又有了\g<>
量詞的次數選擇問題。
假如這裡量詞?
取0次,也就是從開始到現在總體遞迴了一輪。那麼該遞迴正則匹配到是:
reg = /(abc|def) and \g<0>?xyz/
reg =~ "abc and abc and xyzxyz" #=> 0
reg =~ "abc and def and xyzxyz" #=> 0
reg =~ "def and def and xyzxyz" #=> 0
reg =~ "def and abc and xyzxyz" #=> 0
如果遞迴一輪後的量詞?
繼續取1次呢?那麼下一輪遞迴仍將會有量詞次數選擇的問題。
至此,應該理解了遞迴正則的基本匹配方式。不過這裡使用的\g<0>
遞迴還很基礎,下面將繼續逐步深入。
深入遞迴(1):括號分組內的\g
前面的遞迴示例中是將能表示遞迴的表示式\g<0>
部分放在分組的外面,這種情況下,只有\g<0>
這種形式才能算是遞迴,如果是\g<1>
或\g<name>
,就算不上是遞迴,充其量也就是個表示式的呼叫。
但是,當需要使用遞迴正則來解決問題的時候,遞迴表示式往往是在分組內部而不是在分組外部的。所以,前面解釋的遞迴方式其實非常少見。於是,要使用遞迴正則,還得繼續深入探索。
首先看一個非常簡單的組內遞迴正規表示式:
/(abc\g<1>?xyz)+/
這個表示式中,進行了一個分組捕獲,這個分組首先匹配abc
字元,然後在分組捕獲內使用了表示式\g<1>?
(注意這個?
是不能少的,當然?
也可以換成其它的前面解釋過的量詞),緊隨其後的是匹配字元xyz
。由於這裡的\g<1>?
放在1號索引對應的分組捕獲的內部,所以就形成了一個遞迴的正規表示式。
問題是,這個正規表示式能匹配什麼樣的字串呢?要學會遞迴正規表示式,必須會分析它能夠匹配什麼型別的字串。
仍然,以佔位符的方式來表示\g<1>
,那麼該遞迴正規表示式匹配的字串模式為:"abc_?xyz" * N
,這個* N
表示重複N次,因為這種表示式的括號分組外面有一個+
符號。
如果量詞?
選擇為0次,也就是不進行遞迴,則匹配字串"abcxyz" * N
:
/(abc\g<1>?xyz)+/ =~ "abcxyz" #=> 0
/(abc\g<1>?xyz)+/ =~ "abcxyzabcxyz"
#=> 0
/(abc\g<1>?xyz)+/ =~ "abcxyzabcxyzabcxyz"
#=> 0
/(abc\g<1>?xyz)+/ =~ "abcxyz" * 10
#=> 0
如果量詞?
選擇為1次,那麼進行一輪遞迴後,匹配的字串模式為:"abcabc_?xyzxyz" * N
。再次進行?
量詞的次數選擇,假如選0次,那麼匹配的字串是"abcabcxyzxyz" * N
:
/(abc\g<1>?xyz)+/ =~ "abcabcxyzxyz" #=> 0
/(abc\g<1>?xyz)+/ =~ "abcabcxyzxyzabcabcxyzxyz"
#=> 0
/(abc\g<1>?xyz)+/ =~ "abcabcxyzxyz" * 3
#=> 0
再繼續分析一輪遞迴。假設這是?
量詞選擇1次,那麼進行第二輪的遞迴,匹配的字串模式為:"abcabcabc_?xyzxyzxyz" * N
。
至此,應該不難推測出遞迴正規表示式/(abc\g<1>?xyz)+/
匹配的字串的模式:
"abcxyz" * N
"abcabcxyzxyz" * N
"abcabcabcxyzxyzxyz" * N
# 歸納後,即匹配如下通用模式:n和N均大於等於1
("abc" * n + "xyz" * n) * N
將目光集中於剛才的遞迴正規表示式/(abc\g<1>?xyz)+/
,如果能通過這個正規表示式直接推測匹配何種型別字串呢?
量詞+
或其它可能的量詞先不看,先將焦點放在分組捕獲。這個分組捕獲匹配的是abc_?xyz
,如果要進行遞迴N輪,那麼每一輪都是abc_?xyz
這種模式,直接將其替換到該正則中去觀察:abc(abc_?xyz)*xyz
,其中(abc_?xyz)*
表示這部分重複0或N次。當然替換後的這部分不是標準的正則,只是為了有助於理解才將不同地方的概念混在一起,我想並不會對你的理解造成歧義。
這樣理解起來就不難了。當然這個遞迴正則比較簡單,如果把上面的\g<1>?
換成\g<1>*
,看上去又會更復雜一點。那麼它匹配什麼樣的字串呢?
同樣的分析方式,將/(abc\g<1>*xyz)+/
看作是"abc_*xyz" * N
的結構,然後對*
取值,假設取值3次,所以遞迴後的結果看上去類似於:
"abc(abc_*xyz)(abc_*xyz)(abc_*xyz)xyz" * N
上面的每個括號裡都可以對量詞*
做選擇,但要到達遞迴的終點,最後(可能是遞迴了好多輪後)每一個遞迴裡的*
都必須取值0次才能終結這個遞迴。
所以,假如現在這3個括號裡的每個*
都選擇0次,那麼匹配的字串模式類似於:
"abc(abcxyz)(abcxyz)(abcxyz)xyz" * N
# 即等價於:n和N均大於等於1
( "abc" + "abcxyz" * n + "xyz" ) * N
例如:
/(abc\g<1>*xyz)+/ =~ ( "abc" + "abcxyz" * 1 + "xyz" ) * 1
#=> 0
/(abc\g<1>*xyz)+/ =~ ( "abc" + "abcxyz" * 1 + "xyz" ) * 2
#=> 0
/(abc\g<1>*xyz)+/ =~ ( "abc" + "abcxyz" * 4 + "xyz" ) * 2
#=> 0
假如上面三個括號裡第一個括號裡的*
取值1次,後面兩個括號裡的*
取值0次,那麼再次遞迴後,匹配的字串模式類似於:
"abc(abc(abc_*xyz)xyz)(abcxyz)(abcxyz)xyz" * N
沒錯,又要做量詞的次數選擇。假如這次*
取0次,那麼將終結本次遞迴匹配,它匹配的字串模式為:
"abc(abc(abcxyz)xyz)(abcxyz)(abcxyz)xyz" * N
那麼如果*
不是按照上面的次數進行選擇的,那麼匹配的字串模式是怎樣的?
沒有答案,唯一準確的答案就是迴歸這個正規表示式的含義:它匹配的字串模式為(abc\g<1>*xyz)+
。
深入遞迴(2):寫遞迴正則(入門)
前面一直都是根據給定的遞迴正規表示式去分析能匹配什麼樣的字串,這對於理解遞迴正則有所幫助。但是我們更想要掌握的是如何根據字串寫出遞迴的正規表示式。
一般來說,要使用遞迴正則去匹配,往往是要匹配巢狀的一些東西,如果不是匹配巢狀內容,很可能不會想到要去用遞迴正則。這裡,假設也要去匹配巢狀的東西。
先從簡單的巢狀開始。比如,如何匹配無限巢狀的空括號()
、(())
、((()))
,即"(" * n + ")" * n
?
分析一下。如果不遞迴的話,那就是匹配一對小括號()
,所以這兩小括號字元必須要在分組內,即(\(\))
。(如果使用\g<0>
來遞迴的話,則可以不用在分組內,不過這裡先不考慮這種情況。)
按照前文多次對遞迴正規表示式匹配何種字串的分析,用佔位符替代要遞迴的話,要匹配的巢狀括號的字串模式大概是這樣的:(_)
。所以遞迴表示式\g<1>
要在\(
和\)
的中間,即(\(\g<1>\))
。
這裡還少了個量詞來保證遞迴的終點。那麼使用什麼樣的量詞呢?
使用\g<1>*
肯定沒問題,只要*
號每次遞迴都只選擇量詞1次,並且最後一輪遞迴選擇0次終結遞迴即可,那麼匹配的模式是((_*))
、(((_*)))
等等,這正好符合巢狀匹配。
/(\(\g<1>*\))/ =~ "(" * 1 + ")" * 1
#=> 0
/(\(\g<1>*\))/ =~ "(" * 3 + ")" * 3
#=> 0
/(\(\g<1>*\))/ =~ "(" * 10 + ")" * 10
#=> 0
看別人寫的遞迴正則,往往會在分組後加上*
號量詞,即(\(\g<1>*\))*
,針對於這種模式的巢狀,其實這個*
是多餘的,它要匹配成功,這個量詞必須只能選0或1次。如果選擇多於1次,那麼匹配的字串模式就變成了"((_*))" * N
,更標準一點的表示方式是( "(" * n + ")" * n ) * N
,當然,前面也說了,這還有無數種其他的匹配可能。
所以,在這裡我不在分組的後面加*
或+
這樣的量詞。要繼續剛才的討論。
使用\g<1>?
這種量詞方式可以嗎?當然可以,上面分析\g<1>*
的時候,是說當每一輪遞迴時的*
次數選擇都是1次或0次,就能匹配無限巢狀的小括號。對於\g<1>?
來說當然也可以,因為?
也可以表示0或1次。
/(\(\g<1>?\))/ =~ "(" * 1 + ")" * 1
#=> 0
/(\(\g<1>?\))/ =~ "(" * 3 + ")" * 3
#=> 0
/(\(\g<1>?\))/ =~ "(" * 10 + ")" * 10
#=> 0
這兩種遞迴正規表示式,都是符合要求的,都能匹配無限巢狀的小括號。
下面是命名捕獲版本的:
/(?<var>\(\g<var>?\))/ =~ "(" * 3 + ")" * 3
#=> 0
也能直接使用\g<0>
作為巢狀表示式,這時甚至可以去掉分組:
/(?<var>\(\g<0>?\))/ =~ "(" * 3 + ")" * 3
#=> 0
# 去掉分組,直接遞迴這種本身
/\(\g<0>?\)/ =~ "(" * 3 + ")" * 3
#=> 0
這樣看上去,寫遞迴正則好像也不難。其實巢狀模式簡單的遞迴正則確實不難,只要理解遞迴的含義基本上就能寫出來。再看另一個示例。
深入遞迴(3):寫遞迴正則(進階)
假設要匹配的字串模式為:(abc(d(xy)e)fgh)
,其中每個括號內的字元長度任意。這似乎正是本文開頭所舉的例子。
這一個遞迴寫起來其實非常非常簡單:
# 為了可讀性,使用了x修飾符忽略表示式內的空白符號
/\( [^()]* \g<0>* [^()]* \)/x
# 匹配:
reg = /\( [^()]* \g<0>* [^()]* \)/x
reg =~ "(abc(d(xy)e)fgh)" #=> 0
reg =~ "(abc(d(xy)))" #=> 0
reg =~ "((()e)fgh)" #=> 0
reg =~ "((()))" #=> 0
其中\([^()]*
和[^()]*\)
是頭和尾,中間使用\g<0>
來無限巢狀頭和尾。邏輯其實很簡單。
相比於網上流傳的版本/\( ( (?>[^()]+) | (\g<0>) )* \)/x
,此處所給出的寫法應該容易理解的多。
再回頭擴充剛才的遞迴匹配需求,如果需要匹配的字串是ab(abc(d(xy)e)fgh)df
這種模式呢?另一個問題,這種字串模式和(abc(d(xy)e)fgh)
有什麼區別呢?
仔細比對一下,(abc(d(xy)e)fgh)
按左右括號劃分配對的話,它左右剛好能夠成對數:(abc (d (xy ) e) fgh)
(這裡用一個空格分隔,從內向外互相成對)。但ab(abc(d(xy)e)fgh)df
按左右括號劃分配對的話,得到的是ab( abc( d( xy )e )fgh )df
,顯然,它中間多了一層無法成對的內容xy
。
為了寫出按照這種成對劃分的遞迴表示式,先不考慮多出來無法成對的xy
這一層。那麼對應的遞迴正規表示式為:
/[^()]* \( \g<0>* \) [^()]*/x
其中[^()]*\(
是頭部,\)[^()]*
是尾部,中間用\g<0>*
實現頭尾成對的無限巢狀。
再來考慮中間多出來的無法成對的xy
這部分。其實直接將這部分放在\g<0>*
的左邊或右邊都無所謂。例如:
# 放\g<0>*的左邊
/[^()]* \( [^()]* \g<0>* \) [^()]*/x
# 放\g<0>*的右邊
/[^()]* \( \g<0>* [^()]* \) [^()]*/x
沒錯,寫遞迴的正規表示式就是這麼簡單粗暴。
只是,現實並不這麼美好,上面將多餘的無法配對的部分放在了遞迴表示式的左邊或右邊,但有時候這樣是不行的。
解決多餘無法成對內容的更通用方法是使用二選一的分支結構,即|
結合遞迴表示式一起使用,參見下一小節。
深入遞迴(4):遞迴結合二選一分支
要處理上面多出的無法成對的資料,可以通過二選一結構|
改寫成如下更通用的方式:
/[^()]* \( \g<0>* \) [^()]* |./x
進行匹配測試:
reg = /[^()]* \( \g<0>* \) [^()]* |./x
reg =~ "ab(abc(d(xy)e)fgh)df"
#=> 0
當遞迴正規表示式結合了|
提供的二選一分支功能時,|
左邊或右邊(和\g<>
相反的那一邊)都可以用來提供這些"孤兒"資料。
例如,上面示例中,當遞迴進行到發現xy
這部分是多餘的時候將無法繼續匹配,這時候將可以從二選一的另一個分支來匹配這個多餘的資料。
但是這個二選一分支帶來了一個新的問題:只要有無法匹配的,都可以去另一個分支匹配。假如右邊的分支是個.
,這就相當於多了一個萬能箱,什麼都可以從這裡匹配。
但如果無法匹配的多餘字元是右括號或左括號這個必須的字元呢?少了任何一個括號,都不再算是成對的巢狀結構,但卻因為二選一分支而匹配成功。
如何解決這個問題?第一,需要保證另一分支不是萬能的.
;第二,需將整個結構做位置錨定。例如:
/\A ( [^()]* \( \g<1>* \) [^()]* | [^()] ) \Z/x
注意,上面加了括號分組,所以\g<0>
隨之改變成\g<1>
,因為遞迴的時候並不需要將錨定也包含進來。
當然,上面示例中二選一分支的另一個分支所使用的是單字元匹配[^()]
,如果有多個連續的多餘字元,這會導致多次選中該分支。為了減少匹配的測試次數,可以將其直接寫成[^()]*
。
/\A ( [^()]* \( \g<1>* \) [^()]* | [^()]* ) \Z/x
但這有可能會在匹配失敗的時候導致大量的回溯,從而效能暴降。例如,如下失敗的匹配:
reg = /\A([^()]* \( \g<1>* \) [^()]* | [^()]* )\Z/x
# 匹配失敗效能暴降
(st=Time.now) ; (reg =~ "ab(abc(d(xy)e)fghdf") ; (Time.now - st)
#=> 1.7730072
(st=Time.now) ; (reg =~ "ab(abc(d(xy)e)fghdffds") ; (Time.now - st)
#=> 47.5858051
# 匹配成功則無影響
(st=Time.now) ; (reg =~ "ab(abc(d(xy)e)fgh)df") ; (Time.now - st)
#=> 5.9e-06
從結果發現,就這麼短的字串,第一個匹配失敗竟需要花費1.8秒,第二個字串更誇張,僅僅只是多了3個字元,耗費的時間飆升到47秒。
解決方法有很多種,這裡提供兩種:一種是將*
號直接移到分組外,這雖然並不等價,但並不影響最終的匹配結果;另一種是將該多選分支使用固化分組或佔有優先的模式。
reg1 = /\A([^()]* \( \g<1>* \) [^()]* | [^()] )*\Z/x
reg2 = /\A([^()]* \( \g<1>* \) [^()]* | (?>[^()]*) )\Z/x
# 匹配成功
(st=Time.now) ; (reg1 =~ "ab(abc(d(xy)e)fgh)df") ; (Time.now - st)
#=> 6.1e-06
(st=Time.now) ; (reg2 =~ "ab(abc(d(xy)e)fgh)df") ; (Time.now - st)
#=> 5.8e-06
# 匹配失敗
(st=Time.now) ; (reg1 =~ "ab(abc(d(xy)e)fghdf") ; (Time.now - st)
#=> 8.46e-05
(st=Time.now) ; (reg2 =~ "ab(abc(d(xy)e)fghdf") ; (Time.now - st)
#=> 0.0004223
深入遞迴(5):小心遞迴中的分組捕獲
在介紹示例之前,先驗證一下結論。
在遞迴過程中,可能也會有分組捕獲的表示式,所以,遞迴正則設定的相關變數值是最後一次分組捕獲對應的狀態。例如:
reg = /(abc|def) and \g<0>?xyz/
# 只遞迴一輪
reg =~ "abc and def and xyzxyz" #=> 0
# $~表示本次所匹配到的所有字串
$~
#=> #<MatchData "abc and def and xyzxyz" 1:"def">
# $1表示第一個分組捕獲所對應的內容
$1 #=> "def"
上面結果可以看出,在遞迴過程中,最後一輪的遞迴操作(此處示例即第一輪遞迴)設定了一些正則匹配時的變數,它會覆蓋在它之前的遞迴設定的結果。
再來看一個示例。現在有個需求:匹配任何長度的迴文字串(palindrome),比如1234321、abcba、好不好、abccba、好、好好、123321,該示例只能使用二選一的分支來實現。
這裡簡單分析一下,如何通過遞迴正則來實現該需求。
假設要匹配的這個字串是abcdcba
,先把多餘的字元d去掉,那麼要匹配的是abccba
,這也是我們想要匹配的一種字串模式。首先,左右配對的部分必須是完全一致的資料,這個遞迴正則其實很容易實現,用佔位符來描述,大概模式為:(.)_*\1
。將其替換成遞迴正規表示式:
/(.) \g<0>* \1/x
再來考慮多餘的那個字元,直接將其放在二選一分支的另一分支即可:因為二選一分支,所以這裡的\g<0>
就可以不用量詞修飾來保證遞迴的終點
/(.) \g<0> \1 |./x
最後,加上位置錨定。
/\A ( (.) \g<1> \2|.) \Z/x
似乎已經沒問題了,去測試匹配下:
/\A ( (.) \g<1> \2|.) \Z/x =~ "abcba"
#=> nil
結果卻並不如想象中那樣成功。
不過,這個正規表示式的邏輯確實是沒有問題的。例如,使用grep -P
(使用PCRE)執行等價的正則去匹配迴文字串。
$ grep -P "^((.)(?1)\2|.)$" <<<"abcdcba"
abcdcba
# 下面的則失敗
$ grep -P "^((.)(?1)\2|.)$" <<<"abcdcbad"
但是這個"正確的"正規表示式在Ruby中卻無法達到目標。這是因為Ruby中的遞迴也會設定分組捕獲,每個\2
所反向引用的就不再是每輪遞迴中同層次的分組捕獲(.)
的內容了,而是真正的從左向右的第二個分組捕獲括號所捕獲的內容。
好在,Ruby提供了更加靈活的分組捕獲的引用控制。除了\N
這種方式的反向引用,也可以通過\k<N>
或\k<name>
來引用,靈活之處在於\k<>
支援遞迴層次的偏移,例如\k<name+0>
表示取當前遞迴層次裡的name分組捕獲,\k<name+1>
和\k<name-1>
分別表示取當前遞迴層的下一層和上一層裡的name分組捕獲。
所以,在Ruby中改一下這個正規表示式就能正常工作:
/\A ( (.) \g<1> \k<2+0>|.) \Z/x =~ "abcba"
#=> 0
/\A ( (.) \g<1> \k<2+0>|.) \Z/x =~ "abcbaa"
#=> nil
當然,用命名捕獲也是可以的:
/\A (?<i> (?<j>.) \g<i> \k<j+0>|.) \Z/x
最後,可以將上面的正規表示式改動一番。上面正則中,多選分支的.
一直都是放在尾部的(放頭部也沒問題),但下面這種將多選分支和遞迴表示式嵌在一個分組內也是很常見的用法。下面這兩種遞迴正規表示式是等價的。
/\A (?<i> (?<j>.) \g<i> \k<j+0>|.) \Z/x
/\A (?<i> (?<j>.) (?:\g<i>|.) \k<j+0> ) \Z/x
(?:\g<i>|.)
進行了分捕獲的分組,分組將它們兩繫結在一個組內,如果不分組將會出錯,因為|
的優先順序太低。
不要濫用遞迴正則
雖然遞迴正則確實能解決一些特殊需求,但是能不用盡量不用,因為遞迴正則要配合量詞來修飾遞迴表示式,這本身不是問題,但是遞迴表示式很多時候在分組內,而分組本身可能也會用量詞去修飾,這樣兩個量詞一結合,一不小心可能就出現大量的回溯,導致匹配效率瘋狂下降。
前文已經演示過一個這樣的現象,僅僅只是多了3個字元,匹配失敗竟然需要多花費40多秒,而且隨著字元的增多,匹配失敗所需時間飆升的更快。這絕對是我們要去避免的。
所以,當寫出來的遞迴正規表示式裡又是分組、又是量詞,看上去還"亂七八糟"的結合在一起,很可能會出現效能不佳的問題。這時候可能需要去除錯優化,以便寫出高效能的遞迴正則,但這可能會耗去大量的時間。
所以,儘量想其它方法來解決遞迴正則想要實現的匹配需求,或者只寫看上去就很簡單的遞迴正則。