perlfaq6 - perl常問問題集,第六篇

目錄


篇名

perlfaq6 -正規表示式(原文版 Revision: 1.17, Date: 1997/04/24 22:44:10 中譯版 $Revision: 1.4 $, $Date: 1997/08/03 17:22:55 $)


概述

本節之所以出人意料地小是因為在這份 FAQ 的其它部份已散布著與正規表示式有關的答案了。例如說,從一串文字中擷取 URL,以及檢查字串是否含數字,這些都是以正 規表示式來處理的,但是這些問題的答案得到本 FAQ的其它部份去找 (更精確地說,是資料和網路那兩部份)。


我該如何使用正規表示式才不至於寫出不合語法且難以維護的程式碼?

以下提供三個技巧使得你的正規表示式易懂又好維護。

在正規表示式外圍作註解。
用 Perl的註解方式描述你所作的事以及你如何作到它。

    #把每行變成「第一個字、冒號,和剩餘的字元數」這樣的格式。
    s/^(\w+)(.*)/ lc($1) . ":" . length($2) /ge;

在正規表示式內部作註解。
/x修飾子會要直譯器忽略正規表示式內的任意空白 (在特定字元類別 [character class]中例外),同時也讓你在式子中使用平常的註解方法。你應該能想像得到, 加上一些空白與註解幫助會有多大。

/x讓你把下面這行:

    s{<(?:[^>'"]*|".*?"|'.*?')+>}{}gs;

變成:

    s{ <                    #箭頭括弧區起始
        (?:                 #劃分「勿追溯前段」(non-backreferencing)的括弧
             [^>'"] *       #有零個以上、不是 >、 ',或 "的字元
                |           #或者是
             ".*?"          #一段雙引號圈起來的區域 (吝嗇式對應)
                |           #或者是
             '.*?'          #一段單引號圈起來的區域 (吝嗇式對應)
        ) +                 #以上區域出現一次或多次
       >                    #箭頭括弧區結束
    }{}gsx;                 #用空字串來替換;也就是殺掉

雖然它看來還是不夠簡明易懂,但至少大大有助於解釋這個模式 (pattern)的意義。

換個不同的區隔字元 (delimiter)。
儘管我們平常都把正規表示式的模式 (patterns)想作是以 /字元來區隔,但實際上用幾乎任何字元來作都行。perlre文件中有說明。例如,上面的 s///便是用大括號來當區隔字元的。選擇另一個區隔字元可以免除在模式中得避開 (quote)區隔字元的困擾。例如:

    s/\/usr\/local/\/usr\/share/g;      #選錯區隔字元的後果【譯註:
                                        #常被戲稱為「搭牙籤」症候群 ;-)】

    s#/usr/local#/usr/share#g;          #這樣不是好多了?!


我無法對應到超過一行的內容,哪裡出了問題?

若不是你的字串裡少了換行字元,就是你在模式裡用了錯誤的修飾子。

有很多方法將多行的資料結合成一個字串。如果你希望在讀入輸入資料時自動得到 這項功能,你得重新設定 $/變數 (若為段落,設成 '';若要將整個檔案讀進一字 串,設成 undef ),以容許你一次能讀入一行以上的輸入。

請參考 prelre,其中有選擇 /s/m (或二者都用)的說明: /s讓萬用字元 (``.'')能對應到換行字元【譯註:通常換行字元不在 ``.'' 的對應範圍內】, /m則讓 ``^''和 ``$''兩個符號能夠對應到任何換行字元的前後,而不只是像平常 那樣只能對應到字串頭尾。你所需要確定的是你的確有個多行的字串。

例如說,以下這個程式會偵測出同一段落裡重覆的字,即使它們之間有換行符號相隔 (但是不能隔段)。在這個例子裡,我們不需要用到 /s,因為我們並未在任何要跨行對應的正規表示式中使用 ``.''。我們亦無需使用 /m,因為我們不想讓 ``^''或 ``$''去對應 到字串中每個換行字元前後的位置。但重點是,我們得把 $/ 設成與內定值相異的值,否則我們實際上是無法讀入一個多行的資料的。

    $/ = '';            #讀入一整段,而非僅是一行。
    while ( <> ) {
        while ( /\b(\w\S+)(\s+\1)+\b/gi ) {
            print "在段落 $.找到重複的字 $1\n";
        }
    }

以下的程式能找出開頭為 ``From ''的句子 (許多郵件處理程式都會用到這個功能):

    $/ = '';            #讀入一整段,而非僅是一行。 
    while ( <> ) {
        while ( /^From /gm ) { # /m使得 ^也會對應到 \n之後
            print "開頭為 From的段落 $.\n";
        }
    }

以下的程式會抓出在一個段落裡所有夾在 START與 END之間的東西。

    undef $/;           #把整個檔案讀進來,而非只是一行或一段
    while ( <> ) {
        while ( /START(.*?)END/sm ) { # /s使得 .能跨越行界
            print "$1\n";
        }
    }


我如何取出位於不同行的兩個模式間之內容?

你可以使用看起來有點怪的 Perl ..運算元 (在 perlop文件中有說明):

    perl -ne 'print if /START/ .. /END/' file1 file2 ...

如果你要的是整段文字而非各單行,你該使用:

    perl -0777 -pe 'print "$1\n" while /START(.*?)END/gs' file1 file2 ...

但是當 STARTEND之間的東西作巢狀(內含)式分布 (nested occurrences)的時候 ,你便得面對本篇中所提到的對稱式文字對應的問題。


我把一個正規表示式放入 $/但卻沒有用。錯在哪裡?

$/必須是個字串,不能是一個正規表示式。Perl得留一手,讓 Awk還有點可驕傲 之處。 :-)

事實上,如果你不介意把整個檔案讀入記憶體的話,不妨試試看這個:

    undef $/;
    @records = split /your_pattern/, <FH>;

Net::Telnet模組 (CPAN裡有)具有一項功能,可監視著輸入流 (input stream)、等待特定的模式出現,或是在規定時間到了還沒等到時,送出逾時 (timeout)訊息。

    ##開一個有三行的檔案
    open FH, ">file";
    print FH "The first line\nThe second line\nThe third line\n";
    close FH;

    ##取得一個可讀/寫的檔案處理把手
    $fh = new FileHandle "+<file";

    ##把它附著成一個 "stream"物件
    use Net::Telnet;
    $file = new Net::Telnet (-fhopen => $fh);

    ##等到第二行出現了,就把第三行印出來。
    $file->waitfor('/second line\n/');
    print $file->getline;


如何在 LHS端【譯註:式子中運算元左端部份】作不區別大小寫式的替換,但在 RHS端【右端】保留大小寫區別?

答案端看你如何定義「保留大小寫區別」(preserving case)。下面這個程式依照每個 字母的順序,在替換動作完成後保留原來的大小寫。如果用來替換的字其字母數比被替 換者多,那麼最後一個字母的大小寫就會被用作決定替換字剩餘字母的大小寫之依據。

    #原作者為 Nathan Torkington,經 Jeffrey Friedl調整
    #
    sub preserve_case($$)
    {
        my ($old, $new) = @_;
        my ($state) = 0; # 0 = no change; 1 = lc; 2 = uc
        my ($i, $oldlen, $newlen, $c) = (0, length($old), length($new));
        my ($len) = $oldlen < $newlen ? $oldlen : $newlen;

        for ($i = 0; $i < $len; $i++) {
            if ($c = substr($old, $i, 1), $c =~ /[\W\d_]/) {
                $state = 0;
            } elsif (lc $c eq $c) {
                substr($new, $i, 1) = lc(substr($new, $i, 1));
                $state = 1;
            } else {
                substr($new, $i, 1) = uc(substr($new, $i, 1));
                $state = 2;
            }
        }
        #把剩下的 new部份作處理 (當 new比 old長時)
        if ($newlen > $oldlen) {
            if ($state == 1) {
                substr($new, $oldlen) = lc(substr($new, $oldlen));
            } elsif ($state == 2) {
                substr($new, $oldlen) = uc(substr($new, $oldlen));
            }
        }
        return $new;
    }

    $a = "this is a TEsT case";
    $a =~ s/(test)/preserve_case($1, "success")/gie;
    print "$a\n";

這會印出:

    this is a SUcCESS case


如何使 \w對應到附重音記號 (accented)的字元?

請參考 perllocale說明文件


如何作一個適合不同 locale【譯註:國家、地區在文字編碼上各自的慣例】的 /[a-zA-Z]/對應?

一個字母可以用 /[^\W\d_]/表示,不論你的 locale為何。非字母則可用 /[\W\d_]/表示 (假定你不把 ``_''當成字元)。


在一個正規表示式裡如何引入 (quote)變數?

Perl解析器於間隔字元不是單引號時,會展開正規表示式裡的 $variable@variable變數。同時也要記得,一個 s///替換式右側部份是當成雙引號括起來處理的 (詳情請參看 perlop說明文件)。更別忘記,任何一個正規表示式裡的特殊字元都會先被解譯、處理, 除非你在替換模式前加 \Q。以下即為一例。

    $string = "to die?";
    $lhs = "die?";
    $rhs = "sleep no more";

    $string =~ s/\Q$lhs/$rhs/;
    # $string現在成了 "to sleep no more"

少了 \Q,則這個正規表示式同時也會錯誤地對應到 ``di''。【譯註:因為 /die?/ 這個式子表示 ``di''後頭的 ``e''可有零個或一個】


/o到底是幹麼用的?

當你在一個正規表示式裡用一個變數來作對應時,每次通過它時都要重新評估一次(re- evaluation),有時甚至要重新編譯(recompilation)。/o會在第一次用到那個變數 時把它鎖定。在一個無變數的正規表示式裡面,此情形永遠為真,而且事實上,當你整 個程式在被編譯成內部(位元)碼的同時,你所用的模式亦然。

除非在模式裡有變數轉譯的情況發生,否則使用 /o是無關痛癢的。在模式中有變數並且又有 /o修飾子的情況下,正規表示式引擎則既不會知道也不會去管這個模式在 第一次評估之後其中變數是否又有所改變。

/o常被用來額外提高執行效率。當重覆評估無關緊要 (因為事先知道該變數的 值不會改變);或是在有些罕見的情況下,故意不讓正規表示式引擎察覺到變數值已改變 時,便可透過此一手段,避免持續評估,來達到提高效率的目的。

下面以一個 ``paragrep'' (「段落grep」)程式作範例:

    $/ = '';  #使用段落模式
    $pat = shift;
    while (<>) {
        print if /$pat/o;
    }


如何使用正規表示式將檔案中 C語言樣式的註解刪掉?

雖然這件事實際上做得到,但卻比你想像中更加困難。例如下面的單行小程式 (one-liner):

    perl -0777 -pe 's{/\*.*?\*/}{}gs' foo.c

只能在大部分(但非全部)的情況下成功。你知道,這程式對某些種類的 C程式顯得太 簡陋、單純了,尤其是那些被雙引號括起來、看似註解的字串。針對它們,你需要像 這個 Jeffrey Friedl所寫的這樣的程式:

    $/ = undef;
    $_ = <>;
    s#/\*[^*]*\*+([^/*][^*]*\*+)*/|("(\\.|[^"\\])*"|'(\\.|[^'\\])*'|\n+|.[^/"'\\]*)#$2#g;
    print;

當然,這程式可以用 /x加上空白與注解使它更容易讓人看懂。


我能用 Perl的正規表示式去對應成對的符號嗎?

雖然 Perl的正規表示式比「數學的」正規表示式要來得強大,因為它們有追溯前段 (\1之類)這樣方便的功能,但它們仍然不夠強大。你依然得用非正規表示式 的技術去解析這類文字,譬如像兩端用小括號或大括號包含起來的文字。

你可以在 http://www.perl.com/CPAN/authors/id/TOMC/scripts/pull_quotes.gz 找到一個精細複雜的副常式(給 7-bit ASCII專用),它可以抓出成對甚至於巢狀分布 的單一字元,像 `'{},或 ()

CPAN中的 C::Scan模組包含一個這樣的副常式供內部使用,但無說明文件。


有人說正規表示式很貪婪,那是什麼意思?該如何避免它所帶來的問題?

大部分的人所說的貪婪是指正規表示式會盡可能地對應到最多的東西。技術上來說,真正貪婪 的是量化子 (?, *, +,{})而非整個模式;Perl較喜歡作區域性的貪婪以得 到立即的快感,而不是對整個式子的貪婪。如欲使用同樣的量化子作非貪婪式對應的話 【譯註:即所謂的吝嗇(stingy)式對應】,用 (??, *?, +?, {}?)。例如:

        $s1 = $s2 = "I am very very cold";
        $s1 =~ s/ve.*y //;      #貪婪式;結果為 I am cold
        $s2 =~ s/ve.*?y //;     #吝嗇式;結果為 I am very cold

注意到在第二個替換中一碰到 ``y''就停止整個對應了嗎? *?量化子有效率地告訴正 規表示式引擎,一但對應到一個模式,就馬上把控制權移交下去,這行為就好比你手上有 個燙手山芋時所會採取的行動一樣。


如何處理每一行的每個字?

用 split函數:

    while (<>) {
        foreach $word ( split ) { 
            #在此作你想對 $word作的動作
        } 
    }

請注意這裡所謂的字和英文中對字的定義不同;它們可能只是一段連續的、非空白的 字元罷了。

若欲處理的是一連串純字母的話,可以考慮用:

    while (<>) {
        foreach $word (m/(\w+)/g) {
            #在此作你想對 $word作的動作
        }
    }


我如何印出文字出現頻率或行出現頻率的綱要?

要作到這點,你得解讀、分析輸入字元流內的每個字。在此我們假設所謂的「字」 局限於一串由字母、連字號,或撇號所組成的字,而非前一問題中提到的一串 非空白字元集合那種概念:

    while (<>) {
        while ( /(\b[^\W_\d][\w'-]+\b)/g ) {   # "`sheep'"會漏失掉
            $seen{$1}++;
        }
    }
    while ( ($word, $count) = each %seen ) {
        print "$count $word\n";
    }

如果你要算行數,則用不著使用正規表示式:

    while (<>) { 
        $seen{$_}++;
    }
    while ( ($line, $count) = each %seen ) {
        print "$count $line";
    }

如果你希望這些輸出經過排列,請參看有關 Hashes的那部分。


如何能作近似對應?

參考 CPAN裡的 String::Approx模組。


我如何有效率地一次對應多個正規表示式?

下面是個超沒效率的例子:

    while (<FH>) {
        foreach $pat (@patterns) {
            if ( /$pat/ ) {
                # do something
            }
        }
    }

要避免以上的方法,要不你就選用 CPAN 中幾個實驗性的正規表示式擴充模組其中一個 (對你的目的來說可能效率還是不夠好),或是自己寫個像下面這樣的東西 (自 Jeffrey Friedl書中的一個函式所得到的靈感):

    sub _bm_build {
        my $condition = shift;
        my @regexp = @_;  #這裡不可用 local(),得用 my()
        my $expr = join $condition => map { "m/\$regexp[$_]/o" } (0..$#regexp);
        my $match_func = eval "sub { $expr }";
        die if $@;  # $@【錯誤變數】裡面有東西;這不該出現!
        return $match_func;
    }

    sub bm_and { _bm_build('&&', @_) }
    sub bm_or  { _bm_build('||', @_) }

    $f1 = bm_and qw{
            xterm
            (?i)window
    };

    $f2 = bm_or qw{
            \b[Ff]ree\b
            \bBSD\B
            (?i)sys(tem)?\s*[V5]\b
    };

    #餵我 /etc/termcap
    while ( <> ) {
        print "1: $_" if &$f1;
        print "2: $_" if &$f2;
    }


為何我用 \b作字界搜尋時會失敗呢?

有兩個常見的錯誤觀念是將 \b做為 \s+的同義詞,還有把它當成界定空白及非空白字元間的邊界。兩者都不對。\b是介於一個 \w字元和 \W 字元之間的部分(亦即 \b是一個「字」的邊緣)。它是一個長度為 0的標的物,就像 ^$,以及所有其它的標示字元 (anchors)一樣,在對應時並不消耗、佔掉任何字元。perlre使用說明中對各正規表示式超字元 (metacharacters)的特性和使用都有做解釋。

以下是錯誤使用 \b的例子,並附上修正:

    "two words" =~ /(\w+)\b(\w+)/;          #錯誤!
    "two words" =~ /(\w+)\s+(\w+)/;         #正確

    " =matchless= text" =~ /\b=(\w+)=\b/;   #錯誤!
    " =matchless= text" =~ /=(\w+)=/;       #正確

雖然它們也許不能作到你以為它們能作的事,但 \b\B仍然相當有用。要看看正確使用 \b的範例,請參考「如何於多行文字中抓出重複字」一問題內所附之範例。

\Bis\B這個模式是使用 \B的一個例子。它只會對應到出現在一個字內部的 ``is'',例如 ``thistle'',但不會對應到 ``this''或 ``island''。


為什麼每當我用 $&, $`,或 $'時程式的速度就慢下來了呢?

因為不管在程式中哪一個角落,一旦 Perl看見你需要這類的變數時,它就得 在每次模式對應時準備好提供這些變數的值。$1, $2 等等的使用也是以同樣的方式處理的。所以每當你的模式中含有捕捉用的小括號 (capturing parentheses)時, 你就得付出同樣的代價。但若你從不在你的程式中用到 $&等這些東西,那麼 沒有捕捉用小括號的正規表示式就不用付出額外的速度作代價。所以,請盡可能避免使用 $&, $'及 $`,但若真的無法避免 (有些演算法的確需要它們),就盡量用 吧,反正你已經付出代價了。


正規表示式中的 \G能給我什麼好處?

\G在一個對應式或替換式中要和 /g修飾子合起來用 (若無 /g它就會被忽眷)。它是用來標示上一個成功的模式對應完成後所停在的位置,亦即 pos()點。

例如說,你有一行信件文字是按標準的 mail及 Usenet記法 (就是以 > 字元作開始)作成引言的,而你現在要把每個開頭的 >都換成 :。那麼你可以用下面的方法來作:

     s/^(>+)/':' x length($1)/gem;

或者使用 \G,更簡單也更快:

    s/\G>/:/g;

更複雜的方法可能要用到記號賦予器 (tokenizer)。下面看來像 lex語法分析器程式 碼的例子是 Jeffrey Friedl提供的。它在 5.003 版因為其版本中的臭蟲而無法執行,但在 5.004或以上的版本的確可行。(請注意到 /c的使用,它的存在是為了防止 /g在對應失敗時將搜尋位置歸零到字串的開始。)

    while (<>) {
      chomp;
      PARSER: {
           m/ \G( \d+\b    )/gcx    && do { print "number: $1\n";  redo; };
           m/ \G( \w+      )/gcx    && do { print "word:   $1\n";  redo; };
           m/ \G( \s+      )/gcx    && do { print "space:  $1\n";  redo; };
           m/ \G( [^\w\d]+ )/gcx    && do { print "other:  $1\n";  redo; };
      }
    }

當然,上面這個本來也可以寫成像

    while (<>) {
      chomp;
      PARSER: {
           if ( /\G( \d+\b    )/gcx  {
                print "number: $1\n";
                redo PARSER;
           }
           if ( /\G( \w+      )/gcx  {
                print "word: $1\n";
                redo PARSER;
           }
           if ( /\G( \s+      )/gcx  {
                print "space: $1\n";
                redo PARSER;
           }
           if ( /\G( [^\w\d]+ )/gcx  {
                print "other: $1\n";
                redo PARSER;
           }
      }
    }

但是這麼作就不能讓那些正規表示式的式子上下對齊一目瞭然了。


Perl正規表示引擎是 DFAs或 NFAs?它們是 POSIX相容的嗎?

儘管 Perl的正規表示式看似 egrep(1)程式的 DFAs (deterministic finite automata,決定式有限自動機)特性,但事實上為了具備「退回原路」(backtracking) 與「追溯前段」( backreferencing)的功能,它們實作時是用 NFAs (non-deterministic finite automata,非決定式有限自動機)的。並且它們亦非 POSIX式的,因為那樣會造成在所有情況下都有最差的表現。(似乎有些人較注重確 保一致性,即使那同時也確保了緩慢的速度)。你可以在 Jeffrey Friedl所著的 ``精通正規表示式'' (Mastering Regular Expressions)一書中 (O'Reilly出版) ,獲得所有你想知道關於這些事的所有細節(在 perlfaq2裡面有該書的詳細資料)。


在無遞回的場合下用 grep或 map有什麼不對?

嚴格地說來,沒有什麼不對。不過就格式的角度看來,這樣會造成不易維護的程式碼。 因為你是使用了他們的副作用 (side-effects)而非使用他們的傳回值,不幸的是, 副作用容易讓人搞混。無遞回式的 grep()在寫法上不如 for (嗯,技術上說是 foreach啦)迴圈。


如何對應多位元組字母所構成的字串?

這很難,並且還沒有好的方法。Perl 並不直接支援多位元組字母。它假裝一個位元組和 一個字母是同義的。下面提供的方法來自 Jeffrey Friedl,他有一篇登在 Perl期刊 (The Perl Journal)第五期的文章討論的正是這個問題。

假設有一種怪異的火星語編碼協定,其中每兩個大寫的 ASCII字母代表一個火星 字母 (譬如 ``CV''這兩個位元組代表一個火星字母,就像 ``SG''、``VS''、``XX'',等雙字元組一樣)。至於其它位元則和在 ASCII 裡一樣表示單一字元。

所以,像 ``I am CVSGXX!''這樣的火星字串用了 12個位元去表示九個字母 'I',' ' ,'a','m',' ','CV','SG','XX','!'。

現在假設你要搜索這個字母:/GX/。Perl並不懂火星語,所以它會找到 ``I am CVSGXX!''中 ``GX'' 這兩個位元,即使事實上這個字母並不在其中:它之所以看來像是在那兒是因為 ``SG''和 ``XX''緊臨在一起罷了,實際上並非真有 ``GX''存在。這是個大問題。

以下有些處理的方法,雖然都得付出不少代價:

   $martian =~ s/([A-Z][A-Z])/ $1 /g; #讓相鄰的「火星」位元不再相鄰
   print "找到 GX了!\n" if $martian =~ /GX/;

或像這樣:

   @chars = $martian =~ m/([A-Z][A-Z]|[^A-Z])/g;
   #上面那行在理念上近似於:     @chars = $text =~ m/(.)/g;
   #
   foreach $char (@chars) {
       print "找到 GX了!\n", last if $char eq 'GX';
   }

這樣也可以:

   while ($martian =~ m/\G([A-Z][A-Z]|.)/gs) {  #也許不需要 \G
       print "找到 GX了!\n", last if $1 eq 'GX';
   }

不然乾脆這樣:

   die "對不起,Perl尚未支援火星文 )-:\n";

除此之外,CPAN裡面有個範例程式能將半寬 (half-width)的片假名轉成全寬 (full-width) [以 Shift-JIS或 EUC編碼的],這是拜 Tom之賜才有的成果。

現在已有很多雙 (和多)位元編碼法被廣泛的採用。這些方法中有些是採用 1-,2-, 3-,及 4位元組字母,混合使用。


作者與版權事宜

Copyright (c) 1997 Tom Christiansen and Nathan Torkington. All rights reserved.有關使用、(轉)發行事宜,詳見 perlfaq

譯者:陳彥銘

中譯版著作權所有:陳彥銘、蕭百齡及兩隻老虎工作室 。本中譯版遵守並使用與原文版相同的使用條款發行。