« 7. 正規表示式 | Perl 學習手札目录 | 9. 再談控制結構 »

8. 更多關於正規表示式

8. 更多關於正規表示式
正規表示式確實能夠完成很多字串比對的工作,可是當然也需要花更多的時間去熟悉這個高深的學問。如果你從來沒有用過正規表示式,你可以在學Perl時學會用Perl,然後在很多其他Unix環境下的應用程式裡面使用。當然,如果你曾經用過正規表示式,那麼可以在這裡看到一些更有趣的用法。我們在上一章已經介紹了正規表示式的一些基本概念,千萬別忘記,那些只是正規表示式最基本的部份,因為Perl能夠妥善的處裡字串幾乎就是仰賴正規表示式的強大功能。所以我們要來介紹更多關於正規表示式的用法。
8.1 只取一瓢飲
當你真正使用了正規表示式去進行字串比對的時候,你會發現,有時候會有可選擇性的比對。比如我希望找「電腦」或「資訊」這兩個詞是否在一篇文章裡,也就是只要「電腦」或「資訊」中任何一個詞出現在文章裡都算是比對成功,那麼我們就應該使用管線符號/|/來表式。所以我們的樣式應該試寫成這樣:/電腦|資訊/。
還有可能,你會想要找某個字串中部份相等的比對,就像這樣:


/f(oo|ee)t/   # 找 foot 或 feet /it (is|was) a good choice/  # 在句子中用不同的字 /on (March|April|May)/   # 顯然也可以多個選擇
8.2 比對的字符集合
在Perl中的所有的命名規則都必須以字母或底線作為第一個字元,那麼我們如果要以正規表示式來描述這樣的規則應該怎麼作呢?你總不希望你的樣式表達寫成這個樣子吧?

(/a|b|c|d|.......|z|A|B|C|D|......|_|)

這樣的寫法也確實是太過壯觀了一些。那麼我們應該怎麼減少自己跟其他可能看到這支程式的程式設計師在維護時的負擔呢?Perl提供了一種不錯的方式,也就是以「集合」的方式來表達上面的那個概念。因此剛剛的寫法以集合的方式來表達就可以寫成這樣:
[a-zA-Z_]
很顯然的,有些時候我們希望比對的字元是屬於數字,那麼就可以用[0-9]的方式。如果有需要,你也可以這麼寫[13579]來表示希望比對的是小於10的奇數。
有時候你會遇到一個問題,你希望比對的字元也許是各種標點,也就是你在鍵盤上看到,躲在數字上緣的那一堆字符,所以你想要寫成這樣的集合:
[!@#$%^&*()_+-=]
可是這時候問題就出現了,我們剛剛使用了連字號(-)來取得a-z,A-Z的各個字符,可是這裡有一個[+-=]會變成甚麼樣子呢?這恐怕會產生出讓人意想不到的結果。所以我們為了避免這種狀況,必須跳脫這個特殊字元,所以如果你真的希望把連字號放進你的字符集合的話,就必須使用(\-)的方式,所以剛剛的字符集合應該要寫成:
[!@#$%^&*()_+\-=]
另外,在字符集合還有一個特殊字元^,這被稱為排除字元。不過他的效用只在集合的開始,例如像是這樣:
[^24680]
這就表示比對24680以外的字元才算符合。

8.3 正規表示式的特別字元
就像我們在介紹正規表示式的概念的時候所說的,Perl是逐字元在處理樣式比對的。可是對於某一些字元,我們卻很難使用一般鍵盤上的按鍵去表達這些字元。所以我們就需要一些特殊字元的符號。這些就是Perl在處理正規表示式時常用的一些特殊字元:


\s:很多時候,我們回看到要比對的字串中有一些空白,可是很難分辨他們到底是空格,跳格符號或甚至是換行符號 (註一),這時候我們可以用\s來對這些字元進行比對。而且\s對於空白符號的比對掌握非常的高,他可以處理(\n\t\f\r )這五種字元。除了原來的空白鍵,以及我們所提過的跳格字元(\t),換行字元(\n)外,\s還會比對藉以表示回行首的\r跟換頁字元\f。 \S:在大部份的時候,正規表示式特殊字元的大小寫總是表示相反的意思,例如我們使用\s來表示上面所說的五種空白字元,那麼\S也就是排除以上五種字元。 \w:這個特殊字元就等同於[a-zA-Z]的字符集合,例如你可以比對長度為3到10的英文單字,那就要寫成:\w{3,10},同樣的,你就可以比對英文字母或英文單字了。 \W:同樣的,如果你不希望看到任何在英文字母範圍裡的字符,不妨就用這個方式避開。 \d:這個特殊的字元就是字符集合[0-9]的縮寫。 \D:其實你也可以寫成[^0-9],如果你不覺得麻煩的話。

這些縮寫符號也可以放在中括號括住的集合內,例如你可以寫成這樣:[\d\w_],這就表示字母,數字或底線都可以被接受。而且看起來顯然比起[a-zA-Z0-9_]舒服多了。
另外,你也可以這麼寫[\d\D],這表示數字或不是數字,所以就是所有字元,不過既然要全部字元,那就不如用"."來表示了。

8.4 一些修飾字元
現在是不是越來越進入裝況了呢?我們已經可以使用一般的比對樣式來對需要的字串進行比較了。於是我們拿到了一篇文字,就像這樣:


I use perl and I like perl. I am a Perl Monger.

我們現在希望找出裡面關於Perl的字串,這樣該相當簡單,所以我們把這串文字定義為字串$content。然後只要用這樣的樣式來比對:

$content =~ /perl/;

不過好像不太對勁,或許我們應該改寫成這樣:

$content =~ /Perl/;

可是萬一我們打算從檔案裡面取出一篇文字,然後去比對某個字串,這時候我們不知道自己會遇到的是Perl或perl。既然如此,我們可以用字符集合來表示,就像我們之前說過的樣子:

$content =~ /[pP]erl/;

可是我要怎麼確定不會寫成PERL呢?其實你可以考慮忽略大小寫的比對方式,所以你只要這樣表示:

$content =~ /perl/i;

其中的修飾字元i就是告訴Perl,你希望這次的比對可以忽略大小寫,也就是不管大小寫都算是比對成功。所以你有可能比對到Perl,perl,PERL。當然也可能有pErL這種奇怪的字串,不過有時候你會相信沒人會寫出這樣的東西在自己的文章裡。
Perl在進行比對的修飾字元,除了/i之外,我們還有/s可用。我們剛剛稍微提到了可以使用萬用字元點號(.)來進行比對,可是使用萬用字元卻有一個問題,也就是如果我們拿到的字串不在同一行內,萬用字串是沒辦法自動幫我們跨行比對,就像這樣:


my $content = "I like perl. \n I am a perl monger. \n"; if ($content =~ /like.*monger/) { print "*$1*\n"; }

我們想要找到like到monger中間的所有字元,可是因為中間多了換行符號(\n),所以Perl並不會找到我們真正需要的東西。這時候我們就可以動用/s來要求Perl進行跨行的比對。因此我們只要改寫原來的樣式為:

$content =~ /like.*monger/s

那麼就可以成功的進行比對了。可是如果有人還是喜歡用Perl Monger或是PERL MONGER來表達呢?我們當然還是可以同時利用忽略大小寫的修飾字元,因此我們再度重寫整個比對樣式:

$content =~ /like.*monger/is

這兩個修飾字元對於比對確實非常有用。

8.5 取得比對的結果
雖然樣式比對的成功與否對我們非常有用,可是很多時候我們並無法滿足於這樣的用法。尤其當我們使用了一些量詞,或修飾字元之後,我們還會希望知道自己到底得到了甚麼樣的字串。就以剛剛的例子來看,我的比對樣式是表示從like開始,到monger結束,中間可以有隨便任何字元。可是我要怎麼知道我到底拿到了甚麼呢?這時候我就需要取得比對的結果了。
Perl有預設變數來讓你取得比對的結果,就是以錢號跟數字的結合來表示,就像這樣:($1,$2,$3....)。
而用法也相當簡單,你只要把需要放入預設變數的比對結果以小括號刮起括就可以了,就以我們剛剛的例子來看,你只要改寫比對樣式,就像這樣:


my $content = "I like perl. \n I am a perl monger. \n"; if ($content =~ /(like.*monger)/s) { print "$1\n"; }

這裡的$1就是表示第一個括號括住的的比對結果。所以Perl會送出這樣的結果:


[hcchien@Apple]% perl ch3.pl like perl. I am a perl monger

當然,預設的比對變數也是可以一次擷取多個比對結果,就像下面的例子:


my $content = "I like perl. \n I am a perl monger. \n"; if ($content =~ /(perl)\s(monger)/s) {   # $1 = "perl", $2 = "monger" print "$1\n";   # 印出 perl } 不過我們如果再把這個小程式改寫成這樣呢? my $content = "I like perl. \n I am a perl monger. \n"; if ($content =~ /((perl)\s(monger))/s) { print "$1\n$2\n$3\n"; }

結果非常有趣:


[hcchien@Apple]% perl ch3.pl perl monger perl monger

看出來了嗎?我們用括號拿到三個比對變數,而Perl分配變數的方式則是根據左括號的位置來進行。因此最左邊的括號是整個比對結果,也就是"perl monger",接下來是"perl",最後才是"monger"。相當有趣,也相當實用。
不過在使用這些暫存變數有一些必須注意的部份,那就是這些變數的生命週期。因為這些變數回被放在記憶體中,直到下次比對成功,要注意,是比對成功。所以如果你的程式是這麼寫的話:



my $content = "Taipei Perl Monger"; $content =~ /(Monger$)/; # $1 現在是 Monger print $1; $content = /(perl)/; # 比對失敗 print $1; # 所以還是印出 Monger

當你第一次成功比對之後,Perl會把你所需要的結果放如暫存變數$1中,所以你第一次列印$1時就會看到Perl印出Monger,於是我們繼續進行下一次的比對,這次我們希望比對perl這個字串,並且把比對要的字串同樣的放入$1之中。可惜我們的字串中,並沒有perl這個字串,而且我們也沒有加上修飾符號去進行忽略大小寫的比對,因此這次的比對是失敗的,可是Perl並不會先清空暫存變數$1,因此變數的內容還是我們之前所比對成功的結果,也就是Monger,這從最後印出來的時候就可以看出來了。
比較容易的解決方式就是利用判斷式去根據比對的成功與否決定是否列印,就像這樣:


my $content = "Taipei Perl Monger"; print $1 if ($content =~ /(Monger$)/); # 因為比對成功,所以會印出Monger print $1 if ($content = /(perl)/); # 這裡就不會印出任何結果了

8.6 定位點
要能夠精確的描述正規表示式,還有一項非常重要的工具,就是定位點。其中你可以指定某個樣式必須要被放在句首或是句尾,比如你希望比對某個字串一開始就是"Perl"這個字串。那麼你可以把你的樣式這樣表示:

/^Perl/

其中的^就是表示字串開始的位置,也就是只有在開始的位置比對到這個字串才算成功。 當然,你可以可以使用$來表示字串結束的位置。以這個例子來看:


my $content = "Taipei Perl Monger"; if ($content =~ /Monger$/s) {   # 以定位字元進行比對 print "*Match*";   # 在這裡可以成功比對 }

8.7 比對與替換
就像很多編輯器的功能,我們不只希望可以找到某個字串,還希望可以進行替換的功能。當然正規表示式也有提供類似的功能,甚至更為強大。不過其實整個基礎還是基於比對的原則。也就是必須先比對成功之後才能開始進行替換,所以只要你能了解整個Perl正規表示式的比對原理,接下來要置換就顯得容易多了。現在我們先來看一下在Perl的正規表示式中該怎麼描述正規表示式中的替換。
我們可以使用s///來表示替換,其中第一個部份表示比對的字串,第二個部份則是要進行替換的部份。還是舉個例子來看會清楚一些:


my $content = "I love Java"; print $content if ($content =~ s/Java/Perl/);  # 假如置換成功,則印出替換過的字串

當然,就像我們所說的,置換工作的先決條件是必須完成比對的動作之後才能進行,因此如果我們把剛剛的程式改寫成


my $content = "I love Java"; print $content if ($content =~ s/java/perl/);

那就甚麼事情也不會發生了。當你重新檢查字串$content時,就會發現正如我們所預料的,Perl並沒有對字串進行任何更動。
不過有時候我們會有一些問題,就像這個例子:


my $content = "水果對我們很有幫助,所以應該多吃水果"; print $content if ($content =~ s/水果/零食/);  # 把水果用零食置換

看起來好像很容易,我們把零食取代水果,可是當結果出來時,我們發現了一個問題。Perl的輸出是:「零食對我們很有幫助,所以應該多吃水果」。當然,這跟我們的期待是不同的,因為我們實在想吃零食啊。可是Perl只說了零食對我們有幫助,我們還是得吃水果。
沒錯,我們注意到了,Perl只替換了一次,因為當第一次比對成功之後,Perl就接收到比對成功的訊息,於是就把字串依照我們的想法置換過,接著....收工。好吧,那我們要怎麼讓Perl把整個字串的所有的「水果」都換成「零食」呢?我們可以加上/g這個修飾字元,這是表示全部置換的意思。所以現在應該會是這個樣子:


my $content = "水果對我們很有幫助,所以應該多吃水果"; print $content if ($content =~ s/水果/零食/g);  # 把水果全部換成零食吧

就像我們在比對時用的修飾字元,我們在這裡也可以把那些修飾字元再拿出來使用。就像這樣:


my $content = "I love Perl. I am a perl monger"; print $content if ($content =~ s/perl/Perl/gi);

我們希望不管大小寫,所有字串中的Perl一律改為Perl,所以就可以在樣式的最後面加上/gi兩個修飾字元。而且使用的方式和在進行比對時是相同的方式。

8.8 有趣的字串內交換
這是個有趣的運用,而且使用的機會也相當的多,那就是字串內的交換。這樣聽起來非常難以理解,舉個例子來看看。
我們有一個字串,就像這樣:

$string = "門是開著的,燈是關著的"

看起來真是平淡無奇的一個句子。可是如果我們希望讓門關起來,並且打開燈,我們應該怎麼作呢?
根據我們剛剛學到的替換,這件事情好像很簡單,我們只要把門跟燈互相對調就好,可是應該怎麼作呢?如果我們這麼寫:

$string =~ s/門/燈/;

那整個字串就變成了「燈是開著的,燈是關著的」,那接下來我們要怎麼讓原來「燈」的位置變成「門」呢?所以這種作法似乎行不通,不過既然要交換這兩個字,我們是不是有容易的方法呢?利用暫存變數似乎是個可行的方法,就像這樣:


my $string = "門是開著的,燈是關著的"; print $string if ($string =~ s/(門)(.*)(,)(燈)(.*)/$3$2$1$4/);

看起來好像有點複雜,不過卻非常單純,我們只要注意正規表示式裡面的內容就可以了。在樣式表示裡面,非常簡單,我們要找門,然後接著是「門」和「燈」中間的那一串文字,緊接在後面的就是「燈」,最後的就全部歸在一起。按照這樣分好之後,我們希望如果Perl比對成功,就把每一個部份放在一個暫存變數中。接下來就是進行替換的動作,我們把代表「門」跟「燈」的暫存變數$1及$3進行交換,其餘的部份則維持不變。我們可以看到執行之後的結果就像我們所期待的一樣。
當然,這樣只是最簡單的交換,如果沒有正規表示式,那真的會非常的複雜,不過現在我們還可以作更複雜的交換動作。

8.9 不貪多比對
其實在很多狀況下,我們常常不能預期會比對甚麼樣的內容,就像我們常常會從網路上抓一些資料回來進行比對,這時候我們也許有一些關鍵的比對樣式,但是大多數的內容卻是未知的。因此比對的萬用字元(.)會經常被使用,可是一但使用了萬用字元,就要小心Perl會一路比對下去,一直到不合乎要求為止,就像這樣:


<table> <tr><td>first</td></tr> <tr><td>second</td></tr> <tr><td>third</td></tr> </table>

這是非常常見的HTML語法,假設我們希望找到其中的三個元素,所以就必須過濾掉那些HTML標籤。如果你沒注意,也許會寫成:


my $string = "<table><tr><td>first</td></tr><tr><td>second</td></tr><tr><td> third </td></tr></table>"; if ($string =~ m|<tr><td>(.+)<\/td><\/tr>|) { print "$1"; }

可是當你看到執行結果時可能會發現那並不是你要的結果,因為程式印出的$1居然是:

first</td></tr><tr><td>second</td></tr><tr><td>third

讓我們來檢查一下程式出了甚麼問題。我們的比對樣式中告訴Perl,從<tr><td>開始比對,然後比對所有字元,一直到遇到</td></tr>時結束。 而且Perl也很符合我們的期望,他找到了符合我們需求的最大集合。這就是重點了,Perl預設會去找到符合需求的最大集合。因此在這裡他就取得了比對結果"first</td></tr><tr><td>second</td></tr><tr><td>third"。可是我們要的卻是「從<tr><td>開始,遇到</td></tr>就結束」,而不是「找出字串中<tr><td>到</td></tr>的最大字串」。
可是我們剛剛的比對樣式中並沒有告訴Perl:「遇到</td></tr>就停下來」,所以他會一直比對到字串結束,然後找出符合樣式的最大字串,這就是所謂的貪多比對。相對於此,我們就應該告訴Perl,請他以不貪多的方式進行比對。所以我們就在比對的量詞後面加上問號(?)來表示不貪多,並且改寫剛剛的比對樣式:


$string =~ m|<table><tr><td>(.+?)<\/td><\/tr>|

如此一來,就符合我們的要求了。

8.10 如果你有疊字
在正規表示式中,有一種比對的技巧稱為回溯參照 (backreference)。我們如果可以用個好玩的例子來玩玩回溯參照也是不錯的,比如我們有個常見的句子:「庭院深深深幾許」。如果我希望比對中間三個深,我可以怎麼作呢?當然,直接把「深深深」當作比對的樣式是個方法,不過顯而易見的,這絕對不是個好方法。至少你總不希望看到有人把程式寫成這樣吧:


my $string = "庭院深深深幾許"; print $string if ($string =~ /深深深/);   # 這樣寫程式好像真的很糟

這時候回溯參照就是一個很好玩的東西,我們先把剛剛的程式改成這樣試試:


my $string = "庭院深深深幾許"; print $string if ($string =~ /(深)\1\1/);

你應該發現了,我們把「深」這個字先放到暫存變數中,然後告訴Perl:如果有東西長的跟我們要比對的那個變數裡的東西一樣的話,那麼就用來繼續比對吧。可是這時候你卻不能使用暫存變數$1,因為暫存變數是在比對完成之後才會被指定的,而回溯參照則是在比對的期間發生的狀況。剛剛那個例子雖然可以看出回溯參照的用途,可是要了解他的有用之處,我們似乎該來看看其他的例子:


my $string = "/Chinese/中文/"; if ($string =~ m|([\/\|'"])(.*?)\1(.*?)\1|) {   # 這時候我們有一堆字符集合 print "我們希望用 $3 來替換 $2 \n"; }

看出有趣的地方了嗎?我們在字符集合裡面用了一堆符號,因為不論在字符集合裡的那一個符號都可以算是正確比對。但是我們在後面卻不能照舊的使用[\/\|'"]來進行比對,為甚麼呢?你不妨實驗一下這個例子:


my $string = "/Chinese|中文'"; if ($string =~ m|([\/\|'"])(.*?)[\/\|'"](.*?)[\/\|'"]|) { print "我們希望用 $3 來替換 $2 \n"; }

很幸運的,我們在這裡還是比對成功。為甚麼呢?我們來檢查一下這次的比對過程:首先我們有一個字符集合,其中包括了/|'"四種字符,而這次我們的字串中一開始就出現了/字符,正好符合我們的需求。接下來我們要拿下其他所有的字元,一直到另一個相同的字符集合,不過我們這次拿到了卻是|字符,最後我們拿到了一個單引號(')。顯然不單是方便,因為沒有使用回溯參照的狀況下,我們拿到了錯誤的結果。
那我們回過頭來檢查上一個例子就會清楚許多了,我們一開始還是一個字符集合,而且我們也比對到了/字符。接下來我們要找到跟剛剛比對到相同的內容 (也就是要找到下一個/),然後還要再找最後一次完全相同的比對內容。我們經常會遇到單引號(')或雙引號(")必須成對出現,而利用回溯參照就可以很容易的達成這樣的要求。

8.11 比對樣式群組

我們剛剛說了關於回溯參照的用法,不過如果我們的比對並沒有那麼複雜,是不是也有簡單的方式來進行呢?我們都知道很多人喜歡用blahblah來進行沒有什麼意義的留言,於是我們想把這些東西刪除,可是他們可能是寫"blahblah"或是"blahblahblah"等等。這時候使用回溯參照可能會寫成這樣:


my $string = "blahblahblah means nothing"; if ($string =~ s/(blah)\1*//) { print "$string"; }

當然這樣的寫法並沒有錯,只是好像看起來比較礙眼罷了,因為我們其實可以用更簡單的方法來表達我們想要的東西,那就是比對樣式群組。這是小括號(())的另外一個用途,所以我們只要把剛剛的比對樣式改成這樣:/(blah)+/就可以了。這樣一來,Perl就會每次比對(blah)這個群組,然後找尋合乎要求的群組,而不是單一字元(除非你想把某一個字元當群組,只是我們並不覺得這樣的方式會有特殊的需求)。而當我們設定好某個群組之後,他的操作方式就跟平常在寫比對樣式沒什麼兩樣了,我們就可以利用/(blah)+/找出(blah)這個群組出現超過一次的字串。如果你覺得不過癮,/(blah){4,6}/來確定只有blah出現四到六次才算比對成功也是可以的。

8.12 比對樣式的控制
一開始使用正規表示式的人總有一個疑問,為甚麼要寫出正確比對的樣式這麼不容易。而比對錯誤的主要原因通常在於得到不必要的資料,也就是比對樣式符合了過多的文字,當然,還有可能是比對了不被我們期待的文字。就像我們有這樣的一堆字串:


I am a perl monger I am a perl killer it is so popular.

如果你的比對樣式是/p.*r/,那麼你會比對成功:


perl monger perl killer popular

可是這跟我們的需求好像差距太大,於是你希望用這樣的樣式來進行比對:/p\w+\s\w+r/,那你也還會得到


perl monger perl killer

這兩種結果。所以怎麼在所取得的資訊中寫出最能夠精確比對的樣式確實是非常重要,也需要一些經驗的。

習題:
1. 延續第七章的第一題,比對出perl在字串結尾的成功結果。
2. 繼續比對使用者輸入的字串,並且確定是否有輸入數字。
3. 利用回溯參照,找出使用者輸入中,引號內(雙引號或單引號)的字串。
4. 找出使用者輸入的第一個由p開頭,l結尾的英文字。


註一:有時候因為作業系統的不同,換行符號並不會被忠實的呈現。