Perl入门教程 - 第3部分

编辑注:这个备受推崇的系列正在进行更新。您可能对以下的新版本感兴趣,可在以下位置找到:

目录

本系列第1部分
本系列第2部分
本系列第4部分
本系列第5部分
本系列第6部分

简单匹配
元字符
字符类
标志
子表达式
小心!
搜索和替换
玩一玩!

在本系列的前两篇文章中,我们介绍了流程控制、数学和字符串操作以及文件。现在我们将探讨Perl最强大和有趣的方式来处理字符串,即正则表达式,或简称regexes。(规则是这样的:在你第50次输入“正则表达式”之后,你会发现你接下来的50次输入都是“regexp”)。

正则表达式足够复杂,以至于你可以写一本书来专门讲解它们(实际上有人写了——《Jeffrey Friedl的《精通正则表达式》》)。

简单匹配

最简单的正则表达式是匹配表达式。它们使用诸如ifwhileunless之类的关键字进行测试。或者,如果你真的很聪明,可以使用andor的测试。如果一个匹配的正则表达式在字符串中找到了你想要匹配的内容,它将返回一个真值。

 $user_location = "I see thirteen black cats under a ladder.";
    if ($user_location =~ /thirteen/) {
        print "Eek, bad luck!\n";
    }

注意正则表达式的语法:一个在斜杠对中的字符串。代码$user_location =~ /thirteen/询问是否在$user_location中出现了字面字符串thirteen。如果出现了,则测试评估为真;否则,评估为假。

元字符

元字符是具有特殊意义的字符或字符序列。我们已经在双引号字符串的上下文中讨论了元字符,其中序列\n代表换行符,而不是反斜杠,而字符n\t代表制表符。

正则表达式有一系列丰富的元字符,可以让您提出有趣的问题,例如,“这个表达式是否出现在字符串的末尾?”或者“这个字符串是否包含一系列数字?”

两个最简单的元字符是^$。它们分别表示“字符串的开始”和“字符串的结束”。例如,正则表达式/^Bob/将匹配“Bob was here”、“Bob”和“Bobby”。它不会匹配“Bob and David”,因为Bob没有出现在字符串的开始位置。另一方面,$字符表示你正在匹配字符串的末尾。正则表达式/David$/将匹配“Bob and David”,但不会匹配“David and Bob”。以下是一个简单的程序,它将读取文件中的行,并仅打印出看起来像是HTML文件的URL:

for $line (<URLLIST>) {
        # "If the line starts with http: and ends with html...."
        if (($line =~ /^http:/) and
            ($line =~ /html$/)) {
            print $line;
        }
    }

一组有用的元字符被称为通配符。如果您曾经使用过Unix shell或Windows DOS提示符,您应该熟悉通配符字符,如*?。例如,当您输入ls a*.txt时,您会看到所有以字母a开头并以.txt结尾的文件名。Perl比这复杂一些,但遵循相同的原理。

在Perl中,通用的通配符字符是.。正则表达式中的点会匹配任何字符,除了换行符。例如,正则表达式/a.b/会匹配包含a、另一个非换行符字符,然后是b的任何内容——如“aab”、“a3b”、“a b”等等。

如果您想字面匹配一个元字符,您必须使用反斜杠转义它。正则表达式/Mr./会匹配包含“Mr”后跟另一个字符的任何内容。如果您只想匹配实际包含“Mr.”的字符串,您必须使用/Mr\./

单独使用.元字符并不太有用,这就是为什么Perl提供了三个通配限定符+?*。每个限定符都代表不同的含义。

+限定符最容易理解:它表示匹配前面的字符或元字符一次或多次。正则表达式/ab+c/将匹配“abc”、“abbc”、“abbbc”等。

*限定符匹配前面的字符或元字符零次或多次。这与+限定符不同!/ab*c/将匹配“abc”、“abbc”等,就像/ab+c/一样,但它也会匹配“ac”,因为该字符串中没有b的出现。

最后,?限定符会匹配前面的字符零次或一次。正则表达式/ab?c/将匹配“ac”(没有b的出现)和“abc”(有一个b的出现)。它不会匹配“abbc”、“abbbc”等。

我们可以将我们的URL匹配代码重写为使用这些元字符。这将使它更简洁。我们不需要使用两个独立的正则表达式(/^http://html$/),而是将它们合并成一个正则表达式:/^http:.+html$/。要了解它做了什么,从左到右阅读:这个正则表达式会匹配任何以“http:”开头,后跟任意字符一次或多次,并以“html”结尾的字符串。现在,我们的过程是

 for $line (<URLLIST>) {
        if ($line =~ /^http:.+html$/) {
           print $line;
        }
    }

记住/^something$/构造——它非常有用!

字符类

我们已经讨论了一个特殊的元字符.,它会匹配任何字符(除了换行符)。但您通常会想只匹配特定类型的字符。Perl提供了一些元字符来实现这一点。<\d>会匹配单个数字,\w会匹配任何单个“word”字符(在Perl中,这意味着字母、数字或下划线),而\s匹配空白字符(空格和制表符,以及\n\r字符)。

这些元字符与其他字符一样工作:您可以与它们匹配,也可以使用+*这样的限定符。正则表达式/^\s+/将匹配任何以空白字符开始的字符串,而/\w+/将匹配包含至少一个“word”字符的字符串。(但请记住,Perl对“word”字符的定义包括数字和下划线,所以无论您是否认为“_”或“25”是单词,Perl都认为是!)

对于\d的一个很好的用途是测试字符串是否包含数字。例如,您可能需要验证一个字符串是否包含美国风格的电话号码,其格式为555-1212。您可以编写如下代码

 unless ($phone =~ /\d\d\d-\d\d\d\d/) {
 print "That's not a phone number!\n";
    }

所有那些\d元字符使得正则表达式难以阅读。幸运的是,Perl允许我们改进这一点。您可以使用花括号内的数字来指示要匹配的数量,如下所示

 unless ($phone =~ /\d{3}-\d{4}/) {
 print "That's not a phone number!\n";
   }

字符串 \d{3} 表示精确匹配三个数字,而 \d{4} 表示精确匹配四个数字。如果你想使用数字范围,可以使用逗号进行分隔;省略第二个数字将使范围成为开区间。\d{2,5} 将匹配两个到五个数字,而 <\w{3,}> 将匹配至少三个字符长的单词。

您还可以对 \d\s\w 元字符进行取反,以引用除该类型字符以外的任何内容。\D 匹配非数字;\W 匹配任何不是字母、数字或下划线的字符;\S 匹配任何非空白字符。

如果这些元字符不能满足您的需求,您可以定义自己的。您通过将允许的字符列表括在方括号中来定义字符类。例如,只包含小写元音字母的类是 [aeiou]/b[aeiou]g/ 将匹配包含“bag”、“beg”、“big”、“bog”或“bug”的任何字符串。您可以使用破折号来表示字符范围,如 [a-f]。(如果 Perl 没有提供 \d 元字符,我们也可以用 [0-9] 来实现同样的功能。)您可以将字符类与量词组合使用。

 if ($string =~ /[aeiou]{2}/) {
 print "This string contains at least
        two vowels in a row.\n";
    }

您还可以通过以 ^ 字符开始来取反字符类。取反的字符类将匹配您未列出的任何内容。[^aeiou] 匹配除小写元音字母以外的所有字符。(是的,^ 也可以表示“字符串开头”,所以请小心使用。)

标志

默认情况下,正则表达式匹配是区分大小写的(也就是说,/bob/ 不匹配“Bob”)。您可以在正则表达式后放置标志来修改其行为。最常用的标志是 i,它使匹配不区分大小写。

 $greet = "Hey everybody, it's Bob and David!";
    if ($greet =~ /bob/i) {
        print "Hi, Bob!\n";
    }

稍后我们将讨论更多标志。

子表达式

您可能希望同时检查多个内容。例如,您正在编写一个“情绪计”来扫描发出的电子邮件中可能有害的短语。您可以使用竖线字符 | 来分隔您正在寻找的不同内容。

 # In reality, @email_lines would come from your email text, 
   # but here we'll just provide some convenient filler.
   @email_lines = ("Dear idiot:",
                   "I hate you, you twit.  You're a dope.",
                   "I bet you mistreat your llama.",
                   "Signed, Doug");

   for $check_line (@email_lines) {
       if ($check_line =~ /idiot|dope|twit|llama/) {
           print "Be careful!  This line might
              contain something offensive:\n",
                 $check_line, "\n";
       }
   }

匹配表达式 /idiot|dope|twit|llama/ 如果“idiot”、“dope”、“twit”或“llama”在任何位置出现,则匹配成功。

您可以使用正则表达式执行的一些更有趣的操作之一是 子表达式匹配 或分组。子表达式类似于嵌套在较大的正则表达式中的另一个较小的正则表达式,并且放置在括号内。导致子表达式匹配的字符串将存储在特殊变量 $1 中。我们可以使用它来使我们的情绪计更明确地了解电子邮件中的问题。

 for $check_line (@email_lines) {
       if ($check_line =~ /(idiot|dope|twit|llama)/) {
           print "Be careful!  This line contains the
                  offensive word $1:\n",
                 $check_line, "\n";
       }
   }

当然,您可以将匹配表达式放在子表达式中。您的情绪监测程序可以扩展以防止您发送包含连续三个感叹号的电子邮件。我们将使用特殊量词 {3,} 来确保我们获取 所有 的感叹号。

 for $check_line (@email_lines) {
        if ($check_line =~ /(!{3,})/) {
            print "Using punctuation like '$1' 
                   is the sign of a sick mind:\n",
                  $check_line, "\n";
        }
    }

如果您的正则表达式包含多个子表达式,结果将存储在名为 $1$2$3 等的变量中。以下是将“lastname, firstname”格式的名称改回正常格式的代码。

 $name = "Wall, Larry";
   $name =~ /(\w+), (\w+)/;
   # $1 contains last name, $2 contains first name

   $name = "$2 $1";
   # $name now contains "Larry Wall"

您甚至可以将子表达式嵌套在彼此内部 - 它们按照从左到右的顺序排列。以下是如何从包含 hh:mm:ss 格式时间戳的字符串中分别检索完整时间、小时、分钟和秒的示例。(注意,我们使用 {1,2} 量词,以确保像“9:30:50”这样的时间戳也能被匹配。)

 $string = "The time is 12:25:30 and I'm hungry.";
    $string =~ /((\d{1,2}):(\d{2}):(\d{2}))/;
    @time = ($1, $2, $3, $4);

以下是一个可能有用的提示:您可以在从列表赋值时将值分配给标量值列表。如果您更喜欢有可读性的变量名而不是数组,请尝试使用此行。

 ($time, $hours, $minutes, $seconds) = ($1, $2, $3, $4);

使用子表达式时,将值分配给变量列表的情况很常见,Perl 为您提供了一个方便的快捷方式。

 ($time, $hours, $minutes, $seconds) =
         ($string =~ /((\d{1,2}):(\d{2}):(\d{2}))/);

小心!

正则表达式有两个陷阱会导致您的 Perl 程序中出现错误:它们始终从字符串开头开始,并且量词始终尽可能多地匹配字符串。

以下是计算字符串中所有数字并显示给用户的简单代码。我们将使用 while 循环遍历字符串,重复匹配直到计数所有数字。

 $number = "Look, 200 5-sided, 4-colored pentagon maps.";
    while ($number =~ /(\d+)/) {
        print "I found the number $1.\n";
        $number_count++;
    }
    print "There are $number_count numbers here.\n";

实际上,这段代码非常简单,但它不起作用!当你运行它时,Perl 会不断地打印 我找到了数字 200。Perl 总是从字符串的开头开始匹配,所以它总是会找到 200,而永远不会到达后面的数字。

您可以通过在正则表达式中使用 g 标志来避免这种情况。这个标志会告诉 Perl 在返回时记住它在字符串中的位置。当您插入 g 标志时,我们的代码看起来是这样的

 $number = "Look, 200 5-sided, 4-colored pentagon maps.";
    while ($number =~ /(\d+)/g) {
        print "I found the number $1.\n";
        $number_count++;
    }
    print "There are $number_count numbers here.\n";

现在我们得到了预期的结果

 I found the number 200.
    I found the number 5.
    I found the number 4.
    There are 3 numbers here.

第二个陷阱是量词总是尽可能多地匹配字符。看看这个示例代码,但不要运行它

 $book_pref = "The cat in the hat is where it's at.\n";
    $book_pref =~ /(cat.*at)/;
    print $1, "\n";

猜一猜:现在 $1 中是什么?现在运行代码。这看起来是不是很反直觉?

匹配表达式 (cat.*at) 是贪婪的。它包含 cat in the hat is where it's at,因为那是最大的匹配字符串。记住,从左到右读取:先读取“cat”,然后读取任意数量的字符,然后是“at”。如果你想匹配字符串 cat in the hat,你必须重新编写你的正则表达式,使其不再那么贪婪。有两种方法可以做到这一点

  1. 使匹配更精确(尝试 /(cat.*hat)/)。当然,这仍然可能不起作用 - 尝试使用这个正则表达式与 The cat in the hat is who I hate 进行比较。

  2. 在量词后面使用一个 ? 字符来指定非贪婪匹配。.*? 而不是 .* 意味着 Perl 会尝试匹配尽可能小的字符串,而不是最大的字符串

    # 现在我们得到“cat in the hat”在 $1 中。$book_pref =~ /(cat.*?at)/;

搜索和替换

既然我们已经谈到了 匹配,正则表达式还可以为你做另一件事:替换

如果你曾经使用过文本编辑器或文字处理器,你熟悉搜索和替换功能。Perl 的正则表达式功能包括类似的功能,即 s/// 操作符,其语法如下:s/regex/replacement string/。如果你正在测试的字符串与 regex 匹配,则匹配的部分将被替换为 replacement string 的内容。例如,此代码会将猫改为狗

 $pet = "I love my cat.\n";
    $pet =~ s/cat/dog/;
    print $pet;

你还可以在匹配表达式中使用子表达式,并使用它们创建的变量 $1$2 等。替换字符串将像双引号字符串一样替换这些或任何其他变量。记住我们用来将 Wall, Larry 改为 Larry Wall 的代码?我们可以将其重写为一个单独的 s/// 语句!

 $name = "Wall, Larry";
    $name =~ s/(\w+), (\w+)/$2 $1/;  # "Larry Wall"

s/// 可以接受标志,就像匹配表达式一样。两个最重要的标志是 g(全局)和 i(不区分大小写)。通常,替换只会发生一次,但指定 g 标志将使其在正则表达式匹配字符串的情况下一直发生。尝试这段代码,然后删除 g 标志再试一次

 $pet = "I love my cat Sylvester, and my other cat Bill.\n";
   $pet =~ s/cat/dog/g;
   print $pet;

注意,没有 g 标志,Bill 就不会变成狗。

i 标志的工作方式与我们只使用匹配表达式时一样:它强制你的匹配搜索不区分大小写。

综合运用

正则表达式有很多实际应用。我们将以一个 httpd 日志分析器为例。在我们上一篇文章中,其中一个玩弄项目是编写一个简单的日志分析器。现在,让我们使它更有趣:一个按文件类型分解日志结果并按小时列出总请求数的日志分析器。

(完整源代码.)

首先,让我们看一个httpd日志的示例行

 127.12.20.59 - - [01/Nov/2000:00:00:37 -0500] 
    "GET /gfx2/page/home.gif HTTP/1.1" 200 2285

我们首先要做的就是将其拆分为字段。请记住,split()函数将正则表达式作为其第一个参数。我们将使用/\s/在每个空白字符处拆分行

 @fields = split(/\s/, $line);

这给我们10个字段。我们关注的是第四个字段(请求的时间和日期)、第七个字段(URL)以及第九和第十个字段(服务器响应的HTTP状态码和字节数)。

首先,我们希望确保将任何以斜杠结尾的URL请求(如/about/)转换为从该目录请求索引页(/about/index.html)。我们需要转义斜杠,这样Perl就不会将它们误认为是s///语句的终止符。

 $fields[6] =~ s/\/$/\/index.html/;

这一行难以阅读,因为我们每次遇到字面斜杠字符时都需要将其转义。这个问题如此普遍,以至于得到了一个名字:倾向牙签综合征。这里有一个避免倾向牙签综合征的技巧:您可以将表示正则表达式和s///语句的斜杠替换为任何其他匹配对字符,如{}。这允许我们编写更易读的正则表达式,其中不需要转义斜杠

 $fields[6] =~ s{/$}{/index.html};

(如果您想使用此语法与匹配表达式一起使用,您需要在前面加上一个m/foo/将被重写为m{foo}.)

现在,我们假设任何返回状态码为200(请求成功)的URL请求是请求URL扩展名的文件类型(对/gfx/page/home.gif的请求返回一个GIF图像)。任何没有扩展名的URL请求都返回纯文本文件。请记住,点号是一个元字符,因此我们需要将其转义出来!

 if ($fields[8] eq '200') {
           if ($fields[6] =~ /\.([a-z]+)$/i) {
               $type_requests{$1}++;
           } else {
               $type_requests{'txt'}++;
           }
        }

接下来,我们想检索每个请求发生的小时。小时是$fields[3]中的第一个由冒号包围的两位数字字符串,所以我们只需要查找它。请记住,Perl将在字符串中找到第一个匹配项时停止。

 # Log the hour of this request
        $fields[3] =~ /:(\d{2}):/;
        $hour_requests{$1}++;

最后,让我们重写原始的report()子程序。我们一遍又一遍地做同样的事情(打印部分标题和该部分的内容),所以我们将这部分内容提取到一个新的子程序中。我们将新的子程序称为report_section()

 sub report {
    print ``Total bytes requested: '', $bytes, ``\n''; print "\n";
    report_section("URL requests:", %url_requests);
    report_section("Status code results:", %status_requests);
    report_section("Requests by hour:", %hour_requests);
    report_section("Requests by file type:", %type_requests);
}

新的report_section()子程序非常简单

 sub report_section {
    my ($header, %type) = @_; print $header, "\n";
    for $i (sort keys %type) {
        print $i, ": ", $type{$i}, "\n";
    }

    print "\n";
}

我们使用keys函数返回%type散列中的键的列表,并使用sort函数将其按字母顺序排序。我们将在下一篇文章中更多地使用sort

试试看!

像往常一样,这里有一些示例练习

  1. 良好的写作规则之一是“避免被动语态”。与其说报告被卡尔阅读,不如说卡尔阅读了报告。编写一个程序,读取一个句子文件(每行一个句子),检测并消除被动语态,然后打印结果。(尽管如此,请忽略不规则动词或大写字母。)

示例解决方案示例测试句子

  1. 您有一串电话号码。列表很混乱,您唯一知道的是,每个号码有7个或10位数字(区号是可选的),如果有的话,分机号将显示在“x”之后。例如,“416 555-1212”,“5551300X40”和“(306) 555.5000 ext 40”都是可能的。编写一个fix_phone()子程序,将这些号码转换为标准格式“(123) 555-1234”或“(123) 555-1234 Ext 100”,如果存在分机号。假设默认区号为“123”。

示例解决方案.

标签

反馈

这篇文章有问题吗?请帮助我们通过在GitHub上打开一个issue或pull request来解决问题。