您的位置:寻梦网首页编程乐园Perl 编程perl常问问题集
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.cnnb.net/tianyige/tppmsgs/msgs1.htm#106 找到一个精细复杂的副常式(给 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

译者:陈彦铭

中译版着作权所有:陈彦铭、萧百龄及两只老虎工作室。本中译版遵守并使用与原文版相同的使用条款发行。