讀古今文學網 > 精通正則表達式(第3版) > 使用正則表達式修改文本 >

使用正則表達式修改文本

Modifying Text with Regular Expressions

到現在,我們遇到的例子都只是從字符串中「提取」信息。現在我們來看 Perl 和其他許多語言提供的一個正則表達式特性:替換(substitution,也可以叫「查找和替換(search and replace)」)。

我們已經看到,$var=~m/regex/嘗試用正則表達式來匹配保存在變量中的文本,並返回表示能否匹配的布爾值。與之類似的結構$var=~s/regex/replacement/則更進一步:如果正則表達式能夠匹配$var中的某段文本,則將這段匹配的文本替換為replacement。其中regex與之前m/…/的用法一樣,而replacement(位於第二個和第三個斜線之間)則是作為雙引號內的字符串。這就是說,在其中可以使用變量——例如$1、$2——來引用之前匹配的具體文本。

所以,使用$var=~s/…/…/可以改變$var中的文本(如果沒有找到匹配的文本,也就不會有替換發生)。例如,如果$var包括Jeff·Friedl,運行:

$var=~ s/Jeff/Jeffrey/;

$var的值就變成 Jeffery·Friedl。如果再運行一次,就得到 Jeffreyrey·Friedl。要避免這種情況,也許我們需要添加表示單詞分界的元字符。在第 1 章我們提到過,某些版本的egrep支持「\<」和「\>」作為「單詞起始」和「單詞結束」的元字符。Perl提供了統一的元字符「\b」來代表這兩者:

$var=~s/\bJeff\b/Jeffrey/;

這裡有個小測驗:與m/…/一樣,s/…/…/也可以使用修飾符,例如第47頁介紹的/i(將這個修飾符放在replacement之後)。那麼,這個表達式:

$var=~s/\bJeff\b/Jeff/i;

的功能是什麼呢?ϖ請翻到下頁查看答案。

例子:公函生成程序

Example:Form Letter

下面這個有趣的例子展示了文本替換的用途。設想有一個公函系統,它包含很多公函模板,其中有一些標記,對每一封具體的公函來說,標記部分的值都有所不同。

這裡有一個例子:

對特定的接收人,變量的值分別為:

準備好之後,就可以用下面的語句「填寫模板」:

其中的每個正則表達式首先搜索簡單標記,找到之後用指定的文本替換它。用於替換的文本其實是 Perl 中的字符串,所以它們能夠引用變量,就像上面的程序那樣。例如,中下畫線部分在程序運行時的值就是「fabulous$wunderprize」。如果只需要生成一份公函,完全可以不用變量替換,直接照需要的樣子生成就是。但是,使用變量替換能夠實現自動化的操作,例如可以從一個清單讀入信息。

我們還沒介紹過/g「全局替換」(global replacement)的修飾符。它告訴s/…/…/在第一次替換完成之後繼續搜索更多的匹配文本,進行更多的替換。如果需要檢查的字符串包含多行需要替換的文本,每條替換規則都對所有行生效,我們就必須使用/g。

結果是可以預見的,不過相當有趣:

舉例:修整股票價格

Example:Prettifying a Stock Price

另一個例子是,我在使用 Perl 編寫的股票價格軟件時遇到的問題。我得到的價格看起來是這樣「9.0500000037272」。這裡的價格顯然應該是 9.05,但是因為計算機內部表示浮點的原理,Perl有時會以沒什麼用的格式輸出這樣的結果。我們可以像溫度轉換例子中的那樣用printf來保證只輸出兩位小數,但是此處並不適用。當時,股價仍然是以分數的形式給出的,如果某個價格以1/8結尾,則應該輸出3位小數(「.125」),而不是兩位。

我把自己的要求歸結為:通常是保留小數點後兩位數字,如果第三位不為零,也需要保留,去掉其他的數字。結果就是或者會被修正為「12.375」,而被修正為「37.50」。這就是我要的結果。

那麼,我們該如何做呢?$price變量包含了需要修正字符串,讓我們用這個表達式:

$price=~s/(\.\d\d[1-9]?)\d*/$1/

(提示:49頁介紹了「\d」這個元字符,它用來匹配一個數字字符。)

最開始的「\.」匹配小數點。接下來的「\d\d」匹配開頭的兩位數字。「[1-9]?」匹配可能跟在後面的非零數字。到這裡,任何匹配的文本都是我們希望保留的,所以我們用括號把它保存到$1中。然後將$1放入replacement字符串中。如果能夠匹配的文本就是$1,我們就用$1替換$1——這樣做沒什麼意義。但如果在$1的括號之外還有能夠匹配的字符,因為它們沒有出現在replacement字符串中,所以會被刪除。也就是說,「被刪除的」文本是其他多餘的數字,也就是正則表達式末尾「\d*」匹配的字符。

請記住這個例子,在第 4 章我們會學習匹配過程背後的重要原理,那時候還會遇到這個例子。研究它可以學到非常有價值的知識。

自動的編輯操作

Automated Editing

寫作本章時,我遇到了另一個簡單但真實存在的例子。當時我需要登錄到太平洋對岸的一台機器上,但是網速非常慢。按下回車得等一分多鐘才能見到反應,而我只需要對某個文件進行一些小的改動,運行一個重要的程序。實際上,我要做的只是將出現的所有sysread改為read。改動的次數並不多,但因為網絡太慢,使用全屏編輯器顯然是不可能的。

下面是我的辦法:

% perl-p-i-e's/sysread/read/g'file

這條命令中的Perl程序是 s/sysread/read/g(是的,這就是一個完整的Perl程序——參數-e 表示整個程序接在命令的後面)。參數-p 表示對目標文件的每一行進行查找和替換,而-i表示將替換的結果寫回到文件。

請注意,這裡沒有明確寫出查找和替換的目標字符串(就是說,沒有 $var=~…),因為-p參數就表示對目標文件的每行文本應用這段程序。同樣,因為我用了/g這個修飾符,就可以保證在一行文本中可以進行多次替換。

儘管在這裡我只是對一個文件進行操作,但也很容易在命令行中列出多個文件,而 Perl 會把替換命令應用到每個文件的每一行文字。這樣,只需要一條簡單的命令,我就能夠編輯大量的文件。這樣簡單的編輯方式是 Perl 獨有的,但這個例子告訴我們,即使執行的是簡單的任務,作為腳本語言一部分的正則表達式的功能仍然非常強大。

處理郵件的小工具

A Small Mail Utility

來看另一個小工具的例子。一個文件中保存著E-mail 信息,我們需要生成一個用於回復的文件。在準備過程中,我們需要引用原始的信息,這樣就能很容易地把回復插入各個部分。在生成回復郵件的header時,我們還需要刪除原始信息郵件的header中不需要的行。

下一頁的補充內容是一個郵件文件的範本。header 包含了我們關心的字段:日期、主題等——但也包括了我們不關注的字段,這些字段需要刪除。如果我們的腳本程序叫做mkreply,而原始的信息保留在king.in中,我們會用下面的命令來生成回復模板:

% perl-w mkreply king.in > king.out

(-w它用來打開Perl的額外警告功能,☞38)

我們希望程序的輸出結果king.out包括下面的內容:

現在我們來分析。為了生成新的 header,我們需要知道目標地址(即本例中的 [email protected],來自原始信息中的 Reply-To 字段),收件人的姓名(The King),我們自己的地址和姓名,以及主題。另外,為了生成郵件正文的導入部分(introductory line),我們還需要知道原始郵件的日期。

這些工作可以分為下面3步:

1.從原始郵件的header中提取信息;

2.生成回復郵件header;

3.打印原始郵件信息,行首用『|>·』縮進。

這樣考慮有點超前了——在沒有決定程序如何讀入數據之前,就關心起如何處理數據了。幸運的是,Perl提供了神奇的「<>」操作符。在應用到變量$variable時,使用「$variable=<>」,這個有趣的結構能夠每次讀入一行數據。輸入的數據來自命令行中Perl腳本之後列出的文件名(例如上面例子中的king.in)。

請不要混淆操作符<>與Shell的重定向符號「>filename」或者是Perl的大於/小於號。Perl中的<>相當於其它語言中的getline()函數。

讀入所有輸入數據之後,<>很方便地返回未定義的值(作為布爾值處理),所以整個文件可以這樣處理:

我們會用類似的辦法來處理郵件,但是郵件本身的性質決定了我們必須對郵件 header 特殊處理。第一個空行之前的信息是header,之後的則是正文部分。為了只讀入header,我們可以使用下面這段代碼。

我們用「^\s*$」來檢查表示郵件header結束的空行。這個正則表達式檢查的是,當前的文本行是否有一個行開頭(其實每一行都有,由脫字符匹配),然後跟著任意數目的空白字符(儘管我們並不期望有任何空白字符),然後字符串結束(注3)。關鍵詞last會跳出while循環,停止處理header。

所以,在循環內部,在空行檢測之後,我們能夠按照自己的想法來處理 header 的每一行。在本例中,我們希望提取信息,例如郵件的主題和時間。

要提取主題,我們可以使用一個常見的技巧:

這段代碼嘗試匹配一個以『Subject:·』開頭,但不區分Subject大小寫的字符串。如果能夠匹配,後面的「.*」匹配這一行的其他部分。因為「.*」在括號中,所以之後我們能用$1 來訪問郵件的主題。在這個例子中,我們希望把它保存到變量$subject中。當然,如果正則表達式無法匹配這個字符串(大多數情況下都不能),結果就是 if語句返回結果為false,$subject變量沒有變化。

我們可以用同樣的辦法來處理Date和Reply-To字段:

From:所在的行稍微麻煩一點。首先,我們需要找到以『From:』開頭的行,而不是以『From·』開頭的第一行。我們需要的是:

From:[email protected] (The King)

它包含了郵件的發送地址,發送者的姓名在括號內,我們要提取的是姓名(譯注1)。

我們用「^From:·(\S+)」來提取發送地址。你可能猜到了,「\S」匹配的是所有的非空白字符(☞49),所以「\S+」匹配第一個空白之前的文本(或者目標文本末尾之前的所有字符)。在本例中,就是郵件的發送地址。匹配之後,我們希望匹配括號內的文字。顯然,此處也需要匹配括號本身。我們用「\(」和「\)」來匹配,轉義之後的括號不再具有特殊的含義。在括號內,我們希望匹配任何字符——除了括號之外的任何字符,所以採用「[^()]*」。記住,字符組的元字符不同於正則表達式的「普通」元字符,在字符組內部,括號不再具有特殊含義,因此也不需要轉義。

綜合起來,我們得到:

「^From:·(\s+)·\(([^]*)\)」

其中的括號有點多,初看起來不太好懂,圖2-4解釋得更清楚:

圖2-4:嵌套的括號,$1和$2

如果圖2-4的正則表達式能夠匹配,我們可以通過$2得到發送者的姓名,從$1得到可能的回復地址。

並非所有的E-mail信息都包含Reply-To字段,所以我們把$1暫定為回復地址。如果之後出現了$Reply-To字段,我們會重設$reply_address。綜合起來就得到:

這段程序檢查header的每一行,如果某個正則表達式能夠匹配,則設置相應的變量。header的許多行無法由這些正則表達式匹配,所以會被忽略。

while循環結束之後,我們就能夠生成回復郵件的header了(注4):

請注意,我們在主題之前加上了 Re:,表示這是一封回復郵件。最後,在 header 之後,我們列出原始郵件的內容:

print "On $date $from_name wrote:\n";

對於其他的輸入信息(也就是原始郵件的正文部分),我們在每一行之前添加『|>·』提示符:

有意思的是,這段程序也可以用另一種方法,使用正則表達式來加入引用提示符:

這條替換命令尋找「^」,在每個字符串的起始位置匹配。這條替換命令把字符串開頭那個「不存在的字符」「替換」為『|>·』,其實並沒有替換任何字符,只是在字符串的開頭加入『|>·』。在本例中這樣做有點濫用的嫌疑了,但是我們將在本章中看到類似(但更有用)的例子。

真實世界的問題,真實世界的解法

既然擺出了一個真實世界的例子,就應該指出這個解法在真實世界中的缺憾。我已經說過,這些例子的目的在於展示正則表達式的使用方法,而 Perl 程序不過是展示的手段。我使用的 Perl 程序並不一定使用了最有效或者最好的解法,但是,我希望它能說明正則表達式的用法。

同樣,真實世界的郵件信息比這個簡單問題中的郵件信息複雜很多。From:這一行就可能有許多種格式,而我們的程序只能處理一種。如果真正的From:這一行無法匹配我們的模式,則$from_name變量就不會設置,使用時保持在未定義的狀態(也就是「沒有值」的值的一種)。理想的解決辦法是修改這個正則表達式,讓它能夠處理各種不同的郵件地址/姓名格式,不過,作為第一步,在檢查原始郵件之後(生成回復模板之前),我們可以這樣:

Perl的defined函數檢查一個變量是否設置了值,而die函數用來發出錯誤信息,退出程序。

另一點需要考慮的是,程序假設 From:這一行出現在 Reply-To:之前。如果 From:出現在之後,就會覆蓋從Reply-To取得的$reply_address。

「真正的」真實世界

發送電子郵件的程序有許多類,每類程序對標準的理解都不一樣,所以處理電子郵件並不是件簡單的事情。我曾經想用 Pascal 程序來處理電子郵件,但我發現,如果沒有正則表達式,處理起來極其困難,困難到我決定先用Pascal寫一個類似Perl的正則表達式包,再來做其他事情。進入沒有正則表達式的世界之後才發現,自己已經習慣正則表達式的功能和便捷了,而我顯然不希望在沒有正則表達式的世界呆太久。

用環視功能為數值添加逗號

Adding Commas to a Number with Lookaround

大的數值,如果在其間加入逗號,會更容易看懂。下面的程序:

print "The US population is $pop\n";

可能輸出「The US population is 298444215」,但對大多數說英語的人來說,「298,444,215」看起來更加自然。用正則表達式該如何做呢?

動腦子想想這個問題,我們應該從這個數的右邊開始,每次數 3 位數字,如果左邊還有數字的話,就加入一個逗號。如果我們能把這種思路直接用到正則表達式中當然很好,可惜正則表達式一般都是從左向右工作的。不過梳理一下思路就會發現,逗號應該加在「左邊有數字,右邊數字的個數正好是3的倍數的位置」,這樣,使用一組相對較新的正則表達式特性——它們統稱為「環視(lookaround)」——輕鬆地解決這個問題。

環視結構不匹配任何字符,只匹配文本中的特定位置,這一點與單詞分界符「\b」、錨點「^」和「$」相似。但是,環視比它們更加通用。

一種類型的環視叫「順序環視(lookahead)」,作為表達式的一部分,順序環視順序(從左至右)查看文本,嘗試匹配子表達式,如果能夠匹配,就返回匹配成功信息。肯定型順序環視(positive lookahread)用特殊的序列「(?=…)」來表示,例如「(?=\d)」,它表示如果當前位置右邊的字符是數字則匹配成功。另一種環視稱為逆序環視,它逆序(從右向左)查看文本。它用特殊的序列「(?<=…)」表示,例如「(?<=\d)」,如果當前位置的左邊有一位數字,則匹配成功(也就是說,緊跟在數字後面的位置)。

環視不會「佔用」字符

在理解順序環視和其他環視功能時需要特別注意一點,即在檢查子表達式能否匹配的過程中,它們本身不會「佔用」任何文本。這可能有點難懂,所以我準備了下面的例子。正則表達式「Jeffrey」匹配:

但同樣的正則表達式,如果使用順序環視功能,即「(?=Jeffrey)」,則匹配標記的位置:

順序環視會檢查子表達式能否匹配,但它只尋找能夠匹配的位置,而不會真正「佔用」這些字符。不過,把順序環視和真正匹配字符的部分——例如「Jeff」——結合起來,我們能得到比單純的「Jeff」更精確的結果。結合之後的正則表達式是「(?=Jeffrey)Jeff」,下一頁的圖說明,它只能匹配「Jeffrey」這個單詞中的「Jeff」。它能夠匹配:

在此處它的匹配和單純的「Jeff」一樣,但是下面的情況不會 匹配:

…by Thomas Jefferson

「Jeff」自己能夠匹配這一行,但是因為不存在「(?=Jeffrey)」能夠匹配的位置,整個表達式就無法匹配。現在環視的好處還看得不是很明顯,但是請不用擔心,現在我們只需要關心順序環視的原理——我們很快會遇到能夠充分展現其價值的例子,。

受此啟發,你或許會發現「(?=Jeffrey)Jeff」和「Jeff(?=rey)」是等價的(能夠發現這一點的讀者很了不起)。它們都能匹配「Jeffrey」這個單詞中的「Jeff」。

我們還需要認識到,它們結合的順序非常重要。「Jeff(?=Jeffrey)」不會匹配上面的任何一個例子,而只會匹配後面緊跟有「Jeffrey」的「Jeff」。

圖2-5:「(?=Jeffrey)Jeff」的匹配

還有一點很重要,即環視結構使用特殊的表示法。就像45頁介紹的非捕獲型括號「(?:…)」一樣,它們使用特殊的字符序列作為自己的「開括號」。這樣的「開括號」序列有許多種,但它們都以兩個字符「(?」開頭。問號之後的字符用來標誌特殊的功能。我們曾經看到過「分組但不捕獲」的「(?:…)」、順序環視的「(?=…)」,以及逆序環視的「(?<=…)」結構,下面還會看到更多。

再來幾個順序環視的例子

我們馬上就要在數字間插入逗號了,不過現在先多看幾個環視的例子。首先我們要把所有格「Jeffs」替換為「Jeff』s」。不使用環視也能很容易做到這一點,即s/Jeffs/Jeff's/g(記住,/g 表示「全局替換」,☞51)。更好的辦法是添加單詞分界符錨點:s/\bJeffs\b/Jeff's/g。

我們也可以使用更複雜的表達式,例如s/\b(Jeff)(s)\b/$1'$2/g,但是這樣簡單的任務似乎不值得這麼麻煩,所以我們暫時仍然使用 s/\bJeffs\b/Jeff's/g。現在來看另一個正則表達式

s/\bJeff(?=s\b)/Jeff'/g

兩者唯一的區別在於,最後的「s\b」現在位於順序環視結構。下一頁的圖2-6說明了這個正則表達式的匹配情況。正則表達式變化之後,replacement字符串中的『s』也相應地被刪去了。

「Jeff」匹配之後,接下來嘗試的就是順序環視。只有當「s\b」在此位置能夠匹配時(也就是『Jeff』之後緊跟一個『s』和一個單詞分界符)整個表達式才能匹配成功。但是,因為「s\b」只是順序環視子表達式的一部分,所以它匹配的『s』不屬於最終的匹配文本。記住,「Jeff」確定匹配文本,而順序環視只是「選擇」一個位置。在此處使用順序環視的唯一好處在於,它保證表達式不會匹配任意的情況。或者從另一個角度來說就是,它容許我們在只匹配「Jeff」之前檢查整個「Jeffs」。

圖2-6:「\bJeff(?=s\b)」的匹配

為什麼不在最終匹配的結果中包含順序環視匹配過的文本呢?通常,這是因為我們希望在表達式的後面部分,或者在稍後應用正則表達式時,再次檢測這段文本。過幾頁,當我們真正開始解決在數值中加入逗號的問題時,就會明白它的作用。但是在上面的例子中,使用順序環視的原因在於:我們希望檢查整個「Jeffs」,因為這是我們希望加入撇號的地方,但是如果匹配的只是『Jeff』,就能減小replacement字符串的長度。因為『s』不再是最終匹配結果的一部分,也就不再是replacement的一部分,所以我們可以從replacement字符串中去掉它。

所以,儘管這兩種辦法所用的正則表達式和replacement字符串各不相同,它們的結果卻是一樣的。現在看起來,這些應用正則表達式的技巧都有些花架子的味道,但是我這麼做是有目的的,請繼續往下看。

比較上面的兩個例子,最後的「s」從「主(main)」表達式中移到了順序環視部分中。如果我們把開頭的「Jeff」照樣搬到逆序環視中呢?結果是「(?<=\bJeff)(?=s\b)」,它的意思是,找到這樣一個位置,它緊接在『Jeff』之後,在『s』之前。這正好就是我們希望插入撇號的地方。所以,我們這樣替換:

s/(?<=\bJeff)(?=s\b)/'/g

這個表達式很有意思,它實際上並沒有匹配任何字符,只是匹配了我們希望插入撇號的位置。在這種情況下,我們並沒有「替換」任何字符,而只是插入了一個撇號。圖2-7作了說明。在幾頁以前,我們看到過這樣的替換,使用s/^/|>·/在行首加入『|>·』。

圖2-7:「(?<=\bJeff)(?=s\b)」的匹配

如果我們把兩個環視結構調換位置,這個正則表達式的功能會改變嗎?也就是說,s/(?=s\b)(?<=\bJeff)/'/g的結果如何?ϖ請翻到下一頁查看答案。

「Jeffs」匹配總結 表2-1總結了我們見過的把Jeffs替換為Jeff』s的幾種辦法。

表2-1:解決「Jeffs」問題的幾種辦法

回到「在數值中加入逗號」之前,我先提一個關於這些表達式的問題。如果我希望找到不區分大小寫的「Jeffs」,在替換之後仍然保持原來的大小寫,使用/i能實現這個目標嗎?

提示:至少有兩個表達式無法做到這一點。ϖ請思考這個問題,答案見下頁。

回到逗號的例子…

你可能已經意識到了「Jeffs」的例子和插入逗號的例子之間存在某種聯繫,因為它們都需要通過正則表達式尋找到某個位置,然後插入文本。

我們已經知道我們希望插入逗號的位置必須滿足「左邊有數字,右邊數字的個數正好是 3的倍數」。第二個要求用逆序環視很容易解決,左邊只要有一位數字就能夠滿足「左邊有數字」的要求,這就是「(?<=\d)」。

現在來看「右邊數字的個數正好是3 的倍數」。3 位數字當然可以表示為「\d\d\d」,我們可以用「(…)+」來表示(3的)「若干倍」,再添加一個「$」來確保這些數字後面不存在其他字符(保證「正好」)。孤立的「(\d\d\d)+$」匹配從字符串末尾向前數的 3x 位數字,但是加入「(?=…)」的環視結構之後,它就能匹配「右邊數字的個數正好是 3 的倍數的位置」,例如中的標記位置。實際上並不是所有這些位置都符合要求——我們不希望在第一個數字之前加入逗號——所以我們添加「(?<=\d)」來限定匹配的位置。

代碼段如下:

確實輸出了我們期望的「The US population is 298,444,215」。不過,有點奇怪的是,「\d\d\d」兩邊的括號是捕獲型括號。但是在這裡,我們只用它來分組,把加號作用於 3 位數字,所以不需要把它們捕獲的文本保存到$1中。

我可以使用第 45 頁補充內容介紹的非捕獲型括號:「(?:…)」,得到「(?<=\d)(?=(?:\d\d\d)+$)」。這樣做的好處在於,見到這個正則表達式的人不會擔心與捕獲型括號關聯的$1是否會被用到;而且它的效率更高,因為引擎不需要記憶捕獲的文本。另一方面,即使是「(…)」也有點難以看懂,更不用說「(?…)」了,所以我在這裡選擇更清晰的表達方式。構建正則表達式時,經常需要權衡這兩個因素。從我個人來說,我願意在適用的所有地方使用「(?:…)」,但是在講解其他知識時選擇更清晰的表達方式(也是本書中的常見情況)。

單詞分界符和否定環視

現在假設,我們希望把這個插入逗號的正則表達式應用到很長的字符串中,例如:

很顯然程序沒有結果,因為「$」要求字符串以3的倍數位數字結尾。我們不能只去掉這裡的「$」,因為這樣會從左邊第一位數字之後,右邊第三位數字之前的每一個位置插入逗號——結果是「…of 2,9,8,4,4,4,215…」!

可能初看起來這問題有些棘手,但我們可以用單詞分界符「\b」來替換「$」。儘管我們處理的只是數字,Perl的「單詞」概念也能夠解決這個問題。就像「\w」(☞49)一樣,Perl和其他語言都把數字、字母和下畫線當作單詞的一部分。結果,單詞分界符的意思就是,在此位置的一側是單詞(例如數字),另一側不是(例如行的末尾,或者數字後面的空格)。

這個「一側如此這般,另一側如此那般」聽起來很耳熟,對嗎?因為這正是我們在「Jeffs」例子中所做的。區別之一在於,有一側必須使用否定的匹配。這樣看來,迄今為止我們用到的順序環視和逆序環視應該被稱作肯定順序環視(positive lookahead)和肯定逆序環視(positive lookbehind)。因為它們成功的條件是子表達式在這些位置能夠匹配。表2-2告訴我們,正則表達式還提供了相對應的否定順序環視和否定逆序環視。從名字就能看出,它們成功的條件是子表達式無法匹配。

表2-2:四種類型的環視

所以,如果單詞分界符的意思是:一側是「\w」而另一側不是「\w」,我們就能用「(?<!\w)(?=\w)」來表示單詞起始分界符,用「(?<=\w)(?!\w)」表示單詞結束分界符。把兩者結合起來,「(?<!\w)(?=\w)|(?<=\w)(?!\w))」就等價於「\b」。在實踐中,如果語言本身支持\b(\b更直接,效率也更高),這樣做有點多此一舉,但是可能的確有地方需要用到這兩個單獨的多選分支(☞134)。

對我們的逗號插入問題來說,我們真正需要的是「(?!\d)」來標記3位數字的起始計數位置。我們用它來取代「\b」或者「$」,得到:

這個表達式在處理類似「…tone of 12345Hz」的文本時效果很好;不幸的是,它同樣會匹配「…the 1970s…」中的年份。實際上,我們根本不希望這裡的正則表達式能夠匹配「…in1970…」。所以,我們必須知道期望用正則表達式處理的文本,以及開發的程序適合解決什麼樣的問題(如果數據包含年份信息,這個正則表達式可能就不適合)。

在關於單詞分界符和不希望匹配的字符的討論中,我們使用了否定順序環視,「(?!\w)」或「(?!\d)」。你可能還記得第49頁出現的表示「非數字」的字符「\D」,認為它可以取代「(?!\d)」。這並不正確。記住,「\D」的意思是,「某個不是數字的字符」,「某個字符」是必須的,只是它不能為數字。如果在搜索的文本中,數字之後沒有字符,「\D」是無法匹配的(在第 12 頁的補充內容中我們見到過類似的情況)。

不通過逆序環視添加逗號

逆序環視和順序環視一樣,所獲的支持十分有限(使用也不廣泛)。順序環視比逆序環視早出現幾年,儘管 Perl 現在兩者都支持,許多其他語言卻不是這樣。所以,想一想不用逆序環視來解決添加逗號的問題可能更有意義。來看下面的表達式:

它與之前的表達式的差別在於,開頭的「\d」所處的肯定逆序環視變成了捕獲型括號,replacement字符串則在逗號之前加入了相應的$1。

如果我們連順序環視也不用呢?我們可以用「\b」取代「(?!\d)」,但這個消除逆序環視的技巧是否對剩下的順序環視有效呢?也就是說,下面的辦法可行嗎?

$text=~s/(\d)((\d\d\d)+\b)/$1,$2/g;

ϖ 請翻到下頁查看答案。

Text-to-HTML轉換

Text-to-HTML Conversion

現在我們寫一個把 Text(純文本)轉換為 HTML(超文本)的小工具,如果要處理所有的情況,程序將非常難寫,所以現在我們只寫一個用於教學的小工具。

在目前我們看過的所有例子中,作為正則表達式應用對象的變量都只包含一行文本。對這個例子來說,把我們需要轉換的所有文本放在同一個字符串中比較方便。在 Perl 中,我們可以很容易地這樣做:

如果我們的樣本文件包含3個短行:

變量$text的內容就是:

在某些平台上,也可能是:

這是因為大多數系統採用換行符作為一行的終結符,而某些系統(主要是 Windows)使用回車/換行的結合體。我們會確保這個簡單的工具能應付這兩種情況。

處理特殊字符

首先我們需要確保原始文本中的『&』、『<』和『>』字符「不會出錯」,把它們轉換為對應的HTML編碼,分別是『&amp』、『&lt』和『&gt』。在HTML中這些字符有特殊的含義,編碼不正確可能會導致顯示錯誤。我稱這種簡單的轉換為「為HTML而加工(cooking the text for HTML)」,它的確非常簡單:

請注意,我們使用了/g來對所有的目標字符進行替換(如果不用/g,就只會替換第一次出現的特殊字符)。首先轉換&是很重要的,因為這三者的replacement中都有『&』字符。

分隔段落

接下來我們用HTML tag中表示分段的<p>來標記段落。識別段落的簡單辦法就是把空行作為段落之間的分隔。搜索空行的辦法有很多,最容易想到的是:

$text=~s/^$/<p>/g;

它可以匹配「行末尾緊隨行開頭的位置」。確實,我們已經在第10頁看到,在egrep之類的工具中這樣行得通,因為其中被檢索的文本通常只包含邏輯上的一行文本。在 Perl 中也同樣有效,對於之前看到過的E-mail的例子,我們知道每一個字符串只包含一個邏輯行。

但是,我已經在第55頁的腳注中提到過,「^」和「$」通常匹配的不是邏輯行的開頭和結尾,而是整個的字符串的開頭和結束位置(注 5)。所以,既然目標字符串中有多個邏輯行,就需要採取不同的辦法。

幸好,大多數支持正則表達式的語言提供了一個簡單的辦法,即「增強的行錨點」(enhanced line anchor)匹配模式,在這種模式下,「^」和「$」會從字符串模式切換到本例中需要的邏輯行模式。在Perl中,使用/m修飾符來選擇此模式:

$text=~s/^$/<p>/mg;

請注意這裡同時使用了/m和/g(你可以以任何順序排列需要使用的多個修飾符)。在下一章,我們會看到其他語言是如何處理修飾符的。

所以,如果我們從$text的『…chapter.Thus…』開始,會得到期望的『…chapter.<p>Thus…』。

不過,如果在「空行」中包含空格符或者其他空白字符,這麼做就行不通。為了處理空白字符,我們使用,或者是來匹配某些系統在換行符之前的空格符、製表

符或者回車符。這兩個表達式與「^$」是完全不同的,因為它們確實匹配了一些字符,而「^$」只匹配位置。不過,因為在本例中我們不需要這些空格符、製表符和回車符,匹配(然後用分段tag來替換)這些字符不會帶來任何問題。

如果你還記得第47頁的「\s」,你可能會想到,就像我們在第55頁E-mail的例子中所用的那樣。如果用「\s」取代「[·\t\r]」,因為「\s」能夠匹配換行符,所以整個表達式的意義就不再是「尋找空行及只包括空白字符的行」,而是「尋找連續、空行和只包括空白字符的行的結合」。也就是說,如果我們找到多個連續的這樣的文本行,一個「^\s*$」就能夠匹配它們。這樣的好處在於,只會留下一個<p>,而不是像以前那樣有多少空行就留下多少<p>。所以,如果$text有這樣的字符串:

我們用:

結果就是

不過,如果我們用:

結果要更好看一些:

所以,在最終的程序中,我們會使用「^\s*$」。

將E-mail地址轉換為超鏈接形式

Text-to-HTML 轉換的下一步是識別出 E-mail 地址,然後把它們轉換為「mailto」鏈接。例如,[email protected] 會被轉換為<a·href=「mailto:[email protected]」>[email protected]</a>。

用正則表達式來匹配或者驗證E-mail地址是常見的情況。E-mail地址的標準規範異常繁雜,所以很難做到百分之百的準確,但是一些簡單的正則表達式就可以應付遇到的大多數E-mail地址。E-mail地址的基本形式是username@hostname。在思考該用怎樣的表達式來匹配各個部分之前,我們先看看這個正則表達式的具體應用環境:

需要注意的一點是其中兩個用下畫線標注的反斜線,第一個在正則表達式(『\@』)中,另一個在replacement字符串的末尾。使用這兩個反斜線的理由各不相同。我會在稍後討論\@(☞77),現在我們只需要知道,Perl規定作為文本字符的@符號必須轉義。

先介紹replacement字符串中在『/』之前的反斜線比較好。我們已經看到,Perl中查找替換的基本形式是s/regex/replacement/modifier,用斜線來分隔。所以,如果我們需要在某個部分中使用斜線,就必須使用轉義,否則反斜線會被識別為分隔符,作為字符串的一部分。也就是說,如果我們希望在replacement字符串中使用</a>,就必須寫作<\/a>。

這麼做當然可以,但不太好看,所以Perl容許用戶自定義分隔符。例如s!regex!string!modifier,或者s{regex}{string}modifier。無論採用哪種形式,因為replacement字符串中的斜線不再與分隔符有衝突,也就不需要轉義。第二種形式的分隔符非常明顯,所以從現在開始我們採用這種形式。

回到程序中來,請注意整個地址是處於「\b…\b」之間的。添加這些單詞分界符能夠避免不完整匹配的情況,例如』。儘管遭遇這種無意義的字符串的幾率很小,但使用單詞分界符來避免此類匹配一點也不麻煩,所以我會這麼做。請注意我是如何用括號包圍整個 E-mail 地址的,這樣我們就能使用 replacement 字符串『<a·href=「mailto:$1」>$1</a>』。

匹配用戶名和主機名

現在我們來看匹配郵件地址所需要的用戶名和主機名的正則表達式。主機名,例如regex.info 或者 www.oreilly.com,它們由點號分隔,以『com』、『edu』、『info』、『uk』或者其他事先規定的字符序列結尾。匹配E-mail地址的最簡單的辦法是「\w+\@\w+(\.\w+)+」,用「\w+」來匹配用戶名,以及主機名的各個部分。不過,實際應用起來,我們需要考慮得更周到一些。用戶名可以包含點號和連字符(雖然用戶名不會以這兩種字符開頭)。所以,我們不應該使用「\w+」,而應該用「\w[-.\w]*」。這就保證用戶名以「\w」開頭,後面的部分可以包括點號和連字符。(請注意,我們在字符組中把連字符排在第一位,這樣就確保它們被作為連字符,而不是用來表示範圍。對許多流派來說,.-\w表示的範圍肯定是錯誤的,它會產生一個隨機的字母、數字和標點符號的集合,具體取決於程序和計算機所用的字符編碼。Perl能夠正確處理.-\w,但是使用連字符時多加小心是個好習慣。)

主機名的匹配要複雜一些,因為點號只能作分隔符,也就是說兩個點號之間必須有其他字符。所以在前面那個簡單的正則表達式中,主機名部分用「\w+(\.\w+)+」而不是「[\w.]+」。後者會匹配『..x..』。但是,即使是前者,也能夠匹配,所以我們需要更細心一些。

一個辦法是給出末尾部分的可能序列,跟在「\w+(\.\w+)*\.(com|edu|info)」之後(實際上,多選分支應該是 com|edu|gov|int|mil|net|org|biz|info|name|museum|coop|aero|[a-z][a-z],不過為了簡潔起見,我在這裡只列出幾項)。這樣就能容許開頭的「\w+」部分,然後是可能出現的「\.\w+」部分,最後才是我們指定的可能結尾。

實際上「\w」也不是很合適。「\w」能夠匹配ASCII字母和數字,這沒有問題,但有些系統中「\w」能夠匹配非ASCII字母,例如a、c、Ξ、A。在大多數流派中,下畫線也是可以的。但這些字符都不應該出現在主機名中。所以,我們或許應該用「[a-zA-Z0-9]」,或者是「[a-z0-9]」加上/i 修飾符(進行不區分大小寫的匹配)。主機名可能包括連字符,所以我們用「[-a-z0-9]」(再次注意,連字符應該放在第一位)。於是我們得到用來匹配主機名的「[-a-z0-9]+(\.[-a-z0-9]+)*\.(com|edu|info)」。

無論使用什麼正則表達式,記住它們應用的情境都是很重要的。「[-a-z0-9]+(\.[-a-z0-9]+)*\.(com|edu|info) 」這個正則表達式本身,可以匹配『run C:\\』,但是把它置入程序運行的環境中,我們就能確認,它會匹配我們期望的文本,而忽略不期望的內容。實際上,我會把它放入之前提到的。

$text=~s{\b(username regex\@hostname regex)\b}{<a href=「mailto:$1」>$1</a>}gi;(這裡用了s{…}{…}分隔符,以及/i),但這樣就必須折行。當然,Perl不關心這個問題,也不關心表達式是否美觀,但我關心。所以我要介紹/x修飾符,它容許我們重新編排這個表達式:

啊哈,現在看起來大不一樣了。語句末尾出現了/x(在/g和/i之後),它對這個正則表達式做了兩件簡單但有意義的事情。首先,大多數空白字符會被忽略,用戶能夠以「寬鬆排列(free-format)」編排這個表達式,增強可讀性。其次,它容許出現以#開頭標記的註釋。

要指出的是,加上/x之後,表達式中的大部分空格符變為「忽略自身」元字符(「ignore me」metacharacter),而#的意思是「忽略該字符及其之後第一個換行符之前的所有字符」(☞111)。它們不是作為字符組內部的元字符(也就是說,即便使用了/x,這些字符組也不是「隨意編排」的)來對待的,而且,同其他元字符一樣,如果希望把它們作為普通字符來處理,也可以對它們加以轉義。當然,「\s」總是能夠匹配空白字符,例如m/<a\s+href=…>/x。

請注意,/x只能應用於正則表達式本身,而不是replacement字符串。同樣,即使我們現在使用的是s{…}{…}的格式,修飾符接在最後的『}』之後(例如『}x』),但是在文中我們仍然使用「/x」代表「修飾符x」。

綜合起來

現在,我們可以把用戶名、主機名的部分,以及之前的開發成果結合起來,得到相對完整的程序:

所有的正則表達式都應用於同一個包含多行文本的字符串,需要注意的是,只有用於劃分段落的正則表達式才使用/m修飾符,因為只有那個正則表達式用到了「^」和「$」。對其他正則表達式使用/m並不會產生影響(只會令看程序的人迷惑)。

把HTTP URL轉換為鏈接形式

最後,我們需要識別HTTP URL,將它變為鏈接形式。也就是說把「http://www.yahoo.com」轉變為<a·href=http://www.yahoo.com/>http://www.yahoo.com/</a>。

HTTP URL的基本形式是http://hostname/path,其中的/path部分是可選的。於是我們得到下面的形式:

主機部分的匹配可以使用在E-mail例子中用過的子表達式。URL的path部分可以包括各種字符,在前一章中我們使用的是「[-a-z0-9_:@&?=+,.!/~*'%$]*」(☞25),它包括了除空白字符、控制字符和<>(){}之外的大多數ASCII字符。

在使用 Perl 解決這個問題之前,我們必須對@和$進行轉義。同樣,我會在稍後講解原因(☞77)。現在,我們來看hostname和path部分:

你可能注意了,在path之後沒有「\b」,因為URL之後通常都是標點符號,例如本書在O』Reilly的URL是:

http://www.oreilly.com/catalog/regex3/

如果末尾有「\b」,就不能匹配。

也就是說,在實際中,我們需要對表示URL結束的字符做一些人為的規定。比如下面的文本:

現在正則表達式能夠匹配標注出的文本了,當然末尾的標點顯然不應該作為URL的一部分。在匹配英文文本中的URL時,末尾的「[.,?!]」是不應該作為URL的一部分的(這並不是什麼規定,而是我的經驗,而且大多數時候都有效)。這很簡單,只需要在表達式的末尾添加一個表示「除「[.,?!]」之外的任何字符」的否定逆序環視,「(?<![.,?!])」即可。結果就是,在我們匹配到作為URL匹配的文本之後,逆序環視會反過頭來看一眼,保證最後的字符符合要求。如果不符合要求,引擎就會重新檢查作為URL的字符串,直到最終符合要求為止。也就是強迫去掉最後的標點,讓最後的逆序環視匹配成功(在第 5 章我們會看到另一個解決辦法☞206)。

插入之後,我們得到了完整的程序:

構建正則表達式庫

請注意,在這兩個例子中,我們使用同樣的正則表達式來匹配主機名,也就是說,如果要修改匹配主機名的表達式,我們希望這種修改會同時對兩個例子生效。我們可以在程序中多次使用變量$HostnameRegex,而不是把這個表達式寫在各處,雜亂無緒:

第一行使用了Perl的qr操作符。它與m和s操作符類似,接收一個正則表達式(例如,使用qr/…/,類似使用m/…/和s/…/…/),但並不馬上把這個正則表達式應用到某段文本中進行匹配,而是由這個表達式生成為一個「regex對像(regex object)」,作為變量保存。之後我們就能使用這個對象。(在本例中,我們用變量$HostnameRegex 來保存這個變量,供後面兩個正則表達式使用。)這樣做非常方便,因為程序看起來非常清楚。此外,我們的匹配主機名的正則表達式只存在一個「主源(main source)」,這樣無論在哪裡需要匹配主機名,都可以直接使用它。第6章(☞277)還有關於構建這種「正則表達式庫」的例子,具體講解見第7章(☞303)。

其他的語言也提供了創建正則表達式對象的方法,下一章我們會簡要介紹若干語言,而Java和.NET則在第8和第9章詳細講解。

為什麼有時候$和@需要轉義

你可能注意到了,『$』符號既可以作為表示字符串結束的元字符,又可以用來標記變量。通常,『$』的意思是很明確的,但如果在字符組內部,情況就有些麻煩,因為此時它不能用來表示字符串的結束位置,只能在轉義之後,用來標記變量。在轉義之後,『$』就只是字符組的一部分。而這正是我們所要的,所以我們需要在URL匹配的正則表達式中對它進行轉義。

@的情況與之類似。Perl 用@表示數組名,而 Perl 中的字符串或正則表達式中也容許出現數組變量。如果我們希望在正則表達式中使用@字符,就需要進行轉義,避免把它作為數組名。一些語言(Java、VB.NET、C、C#、Emacs、awk等)不支持變量插值(variable interpolation)。有些語言(例如Perl、PHP、Python、Ruby和Tcl)支持變量插值,但是方法各有不同。我們會在下一章詳細講解(☞101)。

回到單詞重複問題

That Doubled-Word Thing

我希望第 1 章提到的單詞重複問題能夠引發讀者對於正則表達式的興趣。在本章的開頭我給出了一堆難懂的代碼,將其作為解法之一:

對 Perl 有了些瞭解之後,我希望讀者至少能夠看懂常規的正則表達式應用——其中的<>,三個s/…/…/,以及print。不過,其他的部分仍然很難。如果你關於Perl的知識全部來自本章(而且關於正則表達式的知識都來自之前的章節),這個例子可能會超出你的理解能力。不過,如果細緻考察起來,我認為這個正則表達式並不複雜。在重讀程序之前,我們不妨回過頭看看第1頁的程序規格要求,並嘗試運行一次:

先來看這個Perl的解法,然後我們會看到一個Java的解法,接觸另一種使用正則表達式的思路。現在列在下面的程序使用了s{regex}{replacement}modifier的替換形式,同時使用了/x 修飾符來提高清晰程度(空間更充裕的時候,我們使用更易懂的『next unless』替換『next if!』)。除去這些,它與本章開頭的程序其實就是一模一樣的。

示例2-3:用Perl處理重複單詞

這小段程序中出現了許多我們沒見過的東西。下面我會簡要地介紹它們以及背後的邏輯,不過我建議讀者查看Perl的man page瞭解細節(如果是正則表達式相關的細節,可以查閱第7章)。在下面的描述中,「神奇」(magic)的意思是「這裡用到了讀者可能不熟悉的Perl的特性」。

∂ 因為單詞重複問題必須應付單詞重複位於不同行的情況,我們不能延續在E-mail的例子中使用的普通的按行處理的方式。在程序中使用特殊變量$/(沒錯,這確實是一個變量)能使用一種神奇的方式,讓<>不再返回單行文字,而返回或多或少的一段文字。返回的數據仍然是一個字符串,只是這個字符串可能包含多個邏輯行。

● 你是否注意到,<>沒有值賦給任何變量?作為while中的條件使用時,<>的神奇之處在於,它能夠把字符串的內容賦給一個特殊的默認變量(注 6)。該變量保存了 s/…/…/和print作用的默認字符串。使用這些默認變量能夠減少冗余代碼,但Perl新手不容易看明白,所以我還是推薦,在你習慣之前,把程序寫得更清楚一些。

÷如果沒有進行任何替換,那麼替換命令之前的 next unless會導致 Perl 中斷處理當前字符串(轉而開始下一個字符串)。如果在當前字符串中沒有找到單詞重複,也就不必進行下一步的工作。

≠ replacement字符串包含的就是「$1$2$3」,加上插入的ANSI轉義序列,把兩個重疊的詞標記為高亮,中間的部分則不標記高亮。轉義序列\e[7m用於標注高亮的開始,\e[m用於標注高亮的結束(在Perl的正則表達式和字符串中,\e用來表示ASCII的轉義字符,該字符表示之後的字符為ANSI轉義序列)。

仔細看看正則表達式中的那些括號,你會發現「$1$2$3」表示的完全就是匹配的文本。所以,除了添加轉義序列之外,整個替換命令並沒有進行任何實質修改。

我們知道$1 和$3 匹配的是同樣的文本(這也是整個程序的意義所在!),所以在replacement 中只用一個也是可以的。不過,因為這兩個單詞的大小寫可能有區別,我用了兩個變量。

≡ 這個字符串可能包括多個邏輯行,不過在替換命令標記了所有的重複單詞之後,我們希望只保留那些包含轉義字符的邏輯行。去掉不包含轉義字符的邏輯行之後,留下的就是字符串中我們需要處理的行。因為我們在替換中使用的是增強的行錨點匹配模式(/m修飾符),正則表達式「^([^\e]*\n)+」能夠找出不包含轉義字符的邏輯行。用這個表達式來替換掉所有不需要處理的行。結果留下的只是包含轉義字符的邏輯行,也即那些包含單詞重複的行(注7)。

≒ 變量$ARGV 提供了輸入文件的名字。結合/m 和/g,這個替換命令會把輸入文件名加到留下的每一個邏輯行的開頭。多酷!

最後,print會輸出字符串中留下的邏輯行以及轉義字符。while循環對輸入的所有字符串重複處理(每次處理一段)。

更深入一點:運算符、函數和對像

我之前已經強調過,在本章我以 Perl 作為工具來講解概念。Perl 的確是一種有用的工具,但我想要強調的是,這個問題利用其他語言的正則表達式解決起來也很容易。

同樣,因為 Perl 具有與其他高級語言不同的獨特風格,講解這些概念更加容易。這種獨特風格就是,正則表達式是「基礎級別(first-class)」的。也就是說,基本的運算符可以直接作用於正則表達式,就好像+和-作用於數字一樣。這樣減輕了使用正則表達式的「語法包袱」(syntactic baggage)。

其他許多語言並沒有這樣的特性。因為第3章中提到的原因(☞93),許多現代語言堅持提供專用的函數和對像來處理正則表達式。例如,可能有一個函數接收表示正則表達式的字符串,以及用於搜索的文本,然後根據正則表達式能否匹配該文本,返回真值或假值。更常見的情況是,這兩個功能(首先對一個作為正則表達式的字符串進行解釋(interpretion),然後把它應用到文本當中)被分割為兩個或更多分離的函數,就像下一頁的Java代碼一樣。這些代碼使用Java1.4以後作為標準的java.util.regex包。

在程序的上部我們看到,在 Perl 中使用的 3 個正則表達式在 Java 中作為字符串傳遞給Pattern.compile程序。通過比較我們發現,Java版本的正則表達式包含了更多的反斜線,原因是Java要求正則表達式必須以字符串方式提供。正則表達式中的反斜線必須轉義,以避免Java在解析字符串時按照自己的方式處理它們。

我們還應該注意到,正則表達式不是在程序處理文本的主體部分出現,而是在開頭的初始化部分出現的。Pattern.compile 函數所作的僅僅是分析這些作為正則表達式的字符串,構建一個「已編譯的版本(compiled version)」,將其賦給Pattern變量(例如regex1)。然後,在處理文本的主體部分,已編譯的版本通過 regex1.matcher(text)應用到文本之上,得到的結果用於替換。同樣,我們會在下一章探究其中的細節,在這裡我們只需要瞭解,在學習任何一門支持正則表達式的語言時,我們需要注意兩點:正則表達式的流派,以及該語言運用正則表達式的方式。

示例2-4:用Java解決重複單詞的問題