例子: 检索某台Web服务器上的页面中的重复单词(例如”this this”), 进行大规模文本编辑时, 这是一项常见的任务. 程序必须满足下面的要求:
1. 能检查多个文件, 挑出包含重复单词的行, 高亮标记每个重复单词(使用标准ANSI的转义字符序列(escape sequence)), 同时必须显示这行文字来自哪个文件.
2. 能跨行查找, 即使两个单词一个在某行末尾而另一个在下一行的开头, 也算重复单词
3. 能进行不区分大小写的查找, 例如”The the”, 重复单词之间可以出现任意数量的空白字符(空格符, 制表符, 换行符之类)
4. 能查找用 HTML tag 分隔的重复单词. HTML tag 用于标记互联网页上的文本, 例如, 粗体单词是这样表示的: “···it is very very important···”
$/ = ".\n";
while(<>){
next if !s/\b([a-z]+)((?:\s|<[^>]+>)+)(\1\b)/\e[7m$1\e[m$2\e[7m$3\e[m/ig;
s/^(?:[^\e]*\n)+//mg; # 去除为标记的行
s/^/$ARGV: /mg; # 在行首添加文件名
print;
}
该程序的主要功能依靠3个正则表达式:
「\b([a-z]+)((?:\s|<[^>]+>)+)(\1\b)」
「^(?:[^\e]*\n)+」
「^」
本章包括了一些常见的问题–验证用户的输入数据, 处理 E-mail header(电子邮件头), 把纯文本数据转换为超文本格式(HTML).在构造正则表达式时, 我会做些尽可能详细的讲解, 提供一些启示. 在这个过程中, 我们会见到一些 egrep 没有提供的结构和特性, 也会专门花很多篇幅来探讨其他重要的概念.
Perl 简单入门
现在来看一个简单的例子:
$celsius = 30;
$fahrenheit = ($celsius * 9 / 5) + 32; # 计算华氏温度
print "$celsius C is $fahrenheit F.\n"; # 返回摄氏和华氏温度
Perl 也提供了跟其他流行的语言类似的控制结构:
$celsius = 20;
while($celsius <= 45){
$fahrenheit = ($celsius * 9 / 5) + 32;
print "$celsius C is $fahrenheit F.\n"
$celsius = $celsius + 5;
}
下面我们来看在 Perl 中如何使用正则表达式.
使用正则表达式匹配文本
Perl 可以以多种方式使用正则表达式, 最简单的就是检查变量中的文本能否由某个正则表达式匹配. 下面的代码检查 $reply 中所含的字符串, 报告这个字符串是否全部由数字构成:
if($reply =~ m/^[0-9]+$/){
print "ongly digits\n";
}
else{
print "not only digits\n";
}
正则表达式是「^[0-9]+$」两边的 m/···/ 告诉 Perl 该对这个正则表达式进行什么操作. m 代表尝试进行”正则表达式匹配(regular expression match)”, 斜线用来标记界限. 之前的 =~ 用来连接 m/···/ 和欲搜索的字符串, 即本例中的 $reply. 此程序在其他语言中的思路有所不同.
请注意, 如果$reply 中包含任意的数字字符, $reply =~ m/[0-9]+/(相比之前的表达式, 去掉了开头的脱字符和结尾的美元符)的返回值就是 true. 两端的「^···$」保证整个 $reply 只包含数字.
现在把上面两个例子结合起来. 首先提示用户输入一个值, 接收这个输入, 用一个正则表达式来验证, 确保输入的是一个数值. 如果是, 我们就计算相应的华氏温度, 否则, 我们输入一条警报信息:
print "Enter a temperature in Celsius:\n";
$celsius = <STDIN> # 从用户处接受一个输入
chomp($celsius); # 去掉 $celsius 后面的换行符
if($celsius =~ m/^[0-9]+$/){
$fahrenheit = ($celsius * 9 / 5) + 32; # 计算华氏温度
print "celsius C is $fahrenheit F\n";
}
else{
print "Expecting a number, so I don't understand \"$celsius\".\n";
}
向更实用的程序前进
拓展这个例子, 容许输入负数和可能出现的小数部分.
if($celsius =~ m/^[-+]?[0-9]+(\.[0-9]*)?$/){}
成功匹配的副作用
我们再进一步, 让这个表达式能够匹配摄氏和华氏温度. 我们让用户在温度的末尾加上 C/F 来表示.
温度转换程序:
print "Enter a temperature (e.g., 32F, 100C):\n";
$input = <STDIN>; # 接收用户输入的一行文本
chomp($input); # 去掉文本末尾的换行符
if($input =~ m/^([-+]?[0-9]+)([CF])$/)
{
# 如果程序运行到此, 则已经匹配. $1保存数字, $2 保存"C"或者"F"
$InputNum = $1; # 把数据保存到已命名变量中...
$type = $2; # ...保证程序清晰易懂
if($type eq "C"){ # 'eq 测试两个字符是否相等
# 输入为摄氏温度, 则计算华氏温度
$celsius = $InputNum;
$fahrenheit = ($celsius * 9 / 5) + 32;
}else{
$fahrenheit = $InputNum;
$celsius = ($fahrenheit - 32) * 5 / 9;
} # 现在得到了两个温度值, 显示结果:
print "%.2f C is %.2f F\n", $celsius, $fahrenheit;
}else{
# 如果最开始的正则表达式无法匹配, 报警
print "Expecting a number followed by \"C\" or \"F\",\n";
print "so I don't understand \"$input\".\n";
}
错综复杂的正则表达式
在 Perl 之类的高级语言中, 正则表达式的使用与其他程序的逻辑是混合在一起的. 为了说明这一点, 我们对这个程序做三点改进: 像之前一样能够接收浮点数. 容许 f 或者 c 是小写, 容许数字和字母之间存在空格.
我们希望开发一个实际应用的程序, 所以必须容许一些除空格之外的空白字符(whitespace). 例如常见的制表符(tabs). 所以我们需要一个字符组来匹配两者「[ \t]*」
请把上面这个子表达式与「( *|\t*)」进行对比, 你能发现者其中的巨大差异吗?
「( *|\t*)」容许 或\t的匹配, 它能够匹配若干空格符以及若干制表符, 不过并不容许制表符和空格符的混合体.「[ \t]*」则可以.
在逻辑上, 「[ \t]*」与「( |\t)*」是等价的, 原因会在第4章解释, 字符组的效率通常还是会高一点.
许多流派的正则表达式提供了一种方便的办法\s.表示所有空白字符的字符组, 其中包括空格符, 制表符, 换行符和回车符, 在复杂的表达式中, \s*更易于理解.
温度转换程序–最终版本(Python)
import re
print("Enter a temperature (e.g., 32F, 100C):")
input_ = input()
result = re.match(r"^([-+]?[0-9]+(\.[0-9]*)?)\s*([CF])$", input_, re.I)
if result:
InputNum_ = result.group(1)
type_ = result.group(3)
if type_ == "C" or type_ == "c":
celsius = float(InputNum_)
fahrenheit = celsius * 9 / 5 + 32
else:
fahrenheit = float(InputNum_)
celsius = (fahrenheit - 32) * 5 / 9
print("%.2f C is %.2f F" % (celsius, fahrenheit))
else:
print("Expecting a number followed by \"C\" or \"F\",")
print("so I don't understand \"%s\"." % input_)
使用正则表达式修改文本
例子: 公函生成程序
下面这个有趣的例子展示了文本替换的用途. 设想有一个公函系统, 它包含很多公函模板, 其中有一些标记, 对每一封具体的公函来说, 标记部分的值都有所不同:
Dear =FIRST=,
You have been chosen to win a brand new =TRINKET=! Free!
Could you use another =TRINKET= in the =FAMILY= household?
Yes =SUCKER=, I bet you could! Just respond by...
对特定的接收人, 变量的值分别为:
$given = "Tom";
$family = "Cruise";
$wunderprize = "100% genuine faux diamond";
准备好之后, 就可以用下面的语句"填写模板":
$letter =~ s/=FIRST=/$given/g;
$letter =~ s/=FAMILY=/$family/g;
$letter =~ s/=SUCKER=/$given $family/g;
$letter =~ s/=TRINKET=/fabulous $wunderprize/g;
例子: 修整股票价格
使用 Perl 编写的股票价格软件时遇到的问题. 我得到的价格看起来是这样”9.0500000037272”. 这里的价格显然应该是9.05, 但是因为计算机内部表示浮点的原理, Perl 有时会以没什么用的格式输出这样的结果. 我们可以使用 printf 来保证只输出两位小数, 但是此处并不适用. 如果某个价格以1/8结尾, 则应该输出3位小数(“.125”), 而不是两位.
$price =~ s/(\.\d\d[1-9]?)\d*/$1/
自动的编辑操作
% perl -p -i -e 's/sysread/read/g' file
这样简单的编辑方式是 Perl 独有的, 但这个例子告诉我们, 即使执行的是简单的任务, 作为脚本语言一部分的正则表达式的功能仍然非常强大.
处理邮件的小工具
一个文件中保存着 E-mail 信息, 我们需要生成一个用于回复的文件. 在准备过程中, 我们需要引用原始的信息, 这样就能很容易地把回复插入各个部分. 在生成回复邮件的 header 时, 我们还需要删除原始信息邮件的 header 中不需要的行.
E-mail Message 范本
From elvis Thu Feb 29 11:15 2007
Received: from elvis@localhost by tabloid.org (8.11.3) id KA8CMY
Received: from tabloid.org by gateway.net (8.12.5/2) id N8XBK
To: jfriendl@regex.info (Jeffrey Friedl)
From: elvis@tabloid.org (The King)
Date: Thu, Feb 29 2007 11:15
Message-Id: <2007022939939.KA8CMY@tabloid.org>
Subject: Be seein’ ya around
Reply-To: elvis@hh.tabloid.org
X-Mailer: Madam Zelda’s Psychic Orb [version 3.7 PL92]
Sorry I haven’t been around lately. A few years back I checked into that ole heartbreak hotel in the sky, ifyaknowwhatImean.
The Duke says “hi”.
Elvis
我们希望程序的输出结果 king.out 包括下面的内容:
To: elvis@hh.tabloid.org (The King) # Reply-To: elvis…
From: jfriendl@regex.info (Jeffrey Friedl) # To: jfreindl…
Subject: Re: Be seein’ ya around # Subject: Be…
On Thu, Feb 29 2007 11:15 The King Wrote: # Data: Thu…
Sorry I haven’t been around lately. A few years back I checked into that ole heartbreak hotel in the sky, ifyaknowwhatImean.
The Duke says “hi”.
Elvis
Python 程序:
import re
f = open("%s/message" % path, "r")
while True:
msg = f.readline()
if re.match(r"^\s*$", msg):
break
elif re.match(r"^Subject: (.*)", msg, re.I):
result = re.match(r"^Subject: (.*)", msg, re.I)
subject = result.group(1)
elif re.match(r"^Date: (.*)", msg, re.I):
result = re.match(r"^Date: (.*)", msg, re.I)
data = result.group(1)
elif re.match(r"^Reply-To: (\S+)", msg, re.I):
result = re.match(r"^Reply-To: (\S+)", msg, re.I)
reply_address = result.group(1)
elif re.match(r"^From: (\S+) \(([^()]*)\)", msg, re.I):
result = re.match(r"^From: (\S+) \(([^()]*)\)", msg, re.I)
reply_address = result.group(1)
from_name = result.group(2)
print("To: %s (%s)" % (reply_address, from_name))
print("From: jfreidl\@regex.info (Jeffrey Friedl)")
print("Subject: Re: %s" % subject)
print()
print("On %s %s wrote:" % (data, from_name))
while True:
msg = f.readline()
if msg:
print(msg, end="")
else:
break
f.close()
真实世界的问题, 真实世界的解法
作为第一步, 在检查原始邮件之后(生成回复模板之前), 我们可以这样:
if( not defined($reply_address)
or not defined($from_name)
or not defined($subject)
or not defined($date))
{
die "couldn't glean the required information!";
}
另外一点需要考虑的是, 程序假设 From: 这一行出现在 Reply-To: 之前. 如果 From: 出现在之后, 就会覆盖从 Reply-To 取得的 $reply_address.
用环视功能为数值添加逗号
使用一组相对较新的正则表达式特性–它们统称为”环视(lookaround)”.
环视结构不匹配任何字符, 只匹配文本中的特定位置, 这一点与单词分界符\b, 锚点^和$相似.但是, 环视比它们更加通用.
一种类型的环视叫”顺序环视(lookahead)”, 作为表达式的一部分, 顺序环视顺序查看文本, 尝试匹配子表达式, 如果能够匹配, 就返回匹配成功信息. 肯定型顺序环视(positive lookahead)用特殊的序列「(?=···)」来表示, 例如「(?=\d)」它表示如果当前位置右边的字符是数字则匹配成功.
另一种环视称为逆序环视, 它用特殊的序列「(?<=···)」表示, 例如「(?<=\d)」如果当前位置的左边有一位数字, 则匹配成功.
$pop =~ s/(?<=\d)(?=(?:(\d\d\d))+$)/,/g;
print "The US population is $pop\n";
单词分界符和否定环视
现在假设, 我们希望把这个插入逗号的正则表达式应用到很长的字符串中, 例如:
$text = "The population of 298444215 is growing";
……
$text =~ s/(?<=\d)(?=(\d\d\d)+$)/,/g;
print "$text\n";
很显然程序没有结果, 因为$要求字符串以3的倍数位数字结尾. 我们不能只去掉这里的$, 因为这样会从左边第一位数字之后, 右边第三位数字之前的每一个位置插入逗号–结果是”2,9,8,4,4,4,215”!
可能初看起来这问题有些棘手, 但我们可以用单词分界符\b来替换$. 就像\w一样, Perl 和其他语言都把数字, 字母和下划线当做单词的一部分. 结果, 单词分界符的意思就是, 在此位置的一侧是单词(例如数字), 另一侧不是(例如行的末尾, 或者数字后面的空格).
四种类型的环视
- 肯定逆序环视 子表达式能够匹配左侧文本 (?<=……)
- 否定逆序环视 子表达式不能匹配左侧文本 (?<!……)
- 肯定顺序环视 子表达式能够匹配右侧文本 (?=……)
- 否定顺序环视 子表达式不能匹配右侧文本 (?!……)
所以, 如果单词分界符的意思是: 一侧是\w而另一侧不是\w, 我们就能用「(?<!\w)(?=\w)」来表示单词起始分界符, 用「(?<=\w)(?!\w)」表示单词结束分界符. 把两者结合起来, 「(?<!\w)(?=\w)|(?<=\w)(?!\w)」就等价于\b. 在实践中, 如果语言本身支持\b(\b更直接, 效率也更高), 这样做有点多此一举, 但是可能的确有地方需要用到这两个单独的多选分支.
对于我们的逗号插入问题来说, 我们真正需要的是(?!\d)来标记3位数字的起始计数位置. 我们用它来取代\b或者$:
$text =~ s/(?<=\d)(?=(\d\d\d)+(?!\d))/,/g
不通过逆序环视添加逗号
$text =~ s/(\d)((\d\d\d)+\b)/$1,$2/g;
结果并非我们的期望. 得到的是类似”181,421906”的字符串. 这是因为「(\d\d\d)+」匹配的数字属于最终匹配文本, 所以不能作为”未匹配的”部分, 供/g的下一次匹配迭代使用.
Text-to-HTML转换
现在我们写一个把 Text 转换为 HTML 的小工具, 如果要处理所有的情况, 程序将非常难写.
作为正则表达式应用对象的变量都只包含一行文本. 对这个例子来说, 把我们需要转换的所有文本放在同一个字符串中比较方便.
undef $/; # 进入"file-slurp"(文件读取)模式
$text = <>; # 读取命令行中指定的第一个文件
1.处理特殊字符
首先我们需要确保原始文件中的’&’ ‘<’和’>’字符”不会出错”, 把它们转换为对应的 HTML 编码, 分别是’&’ ‘<’和’>’. 在 HTML 中这些字符有特殊的含义, 编码不正确可能会导致显示错误:
$text =~ s/&/&/g; # 保证基本的 HTML…
$text =~ s/</</g; # …字符& < >…
$text =~ s/>/>/g; # …转换后不不错
请注意我们使用了/g来对所有的目标字符进行替换. 首先转换&是很重要的, 因为这三者的 replacement 中都有&字符.
2.分隔段落
识别段落的简单办法就是把空行作为段落之间的分隔.
$text =~ s/^$/<p>/g;
对于之前看到过的 E-mail 的例子, 我们知道每一个字符串只包含一个逻辑行. ^和$通常匹配的不是逻辑行的开头和结尾, 而是整个的字符串的开头和结束位置. 所以, 既然目标字符串中有多个逻辑行, 就需要采取不同的办法.
大多数支持正则表达式的语言提供了一个简单的办法, 即”增强的行锚点”匹配模式, 在这种模式下, ^和$会从字符串模式切换到本例中需要的逻辑行模式.在 Perl 中, 使用/m修饰符来选择此模式:
$text =~ s/^$/<p>/mg;
不过, 如果在”空行”中包含空格符或者其他空白字符, 这么做就行不通. 所以在最终的程序中,我们会使用「^\s*$」.
3.将 E- mail 地址转换为超链接形式
识别出 E-mail 地址, 然后把它转换为”mailto”链接.例如, jfriedl@oreilly.com 会被转换为
<a href="mailto:jfriedl@oreilly.com">jfriedl@oreilly.com</a>
E-mail 地址的基本形式是 username@hostname. 在思考该用怎样的表达式来匹配各个部分之前, 我们先看看这个正则表达式的具体应用环境:
$text =~ s/\b(username regex\@hostname regex)\b/<a href="mailto:$1">$1<\/a>/g;
需要注意其中两个反斜线, 第一个在正则表达式(\@)中, 另一个在 replacement 字符串的末尾.
我们先看第二个, Perl中查找替换的基本形式是 s/regex/replacement.modifier, 用斜线来分隔. 所以, 如果我们需要在某个部分中使用斜线, 就必须使用转义.
4.匹配用户名和主机名
匹配 E-mail 地址的最简单的办法是「\w+\@\w+(.\w+)+」, 用\w来匹配用户名, 以及主机名的各个部分. 不过, 实际应用起来, 我们需要考虑的更周到一些. 于是我们得到用来匹配主机名的「[-a-z0-9]+(.[a[z0-9]+)*.(com|edu|info)」.
无论使用什么正则表达式, 记住它们应用的情境都是很重要的.「[-a-z0-9]+(.[a[z0-9]+)*.(com|edu|info)」这个正则表达式本身, 可以匹配’run C:\startup.command at startup’, 但是把它置入程序运行的环境中, 我们就能确认, 它会匹配我们期望的文本, 而忽略不期望的内容.
5.把 HTTP URL 转换为链接形式
也就是说把”http://www.yahoo.com“转变为
<a href=http://www.yahoo.com/>http://www.yahoo.com/</a>
6.为什么有时候@需要转义
Perl 用@表示数组名, 而 Perl 中的字符串或正则表达式中也容许出现数组变量.
有些语言(例如Python)支持边梁插值, 但是方法各有不同.
回到单词重复问题
在本章开头我给出了一堆难懂的代码, 将其作为解法之一.
现在列在下面的程序使用了 s{regex}{replacement}modifier 的替换形式, 同时使用了/x修饰符来提高清晰程度(空间更充裕的时候, 我们使用更易懂的’next unless’替换’next if!’).
$/ = ".\n"; # ①设定特殊的"块模式"("chunk-mode"); 一块文本的终结为点号和换行符的结合体
while(<>) # ②
{
next unless s{ #③下面是正则表达式
### 匹配一个单词:
\b # 单词的开始位置
([a-z]+) # 把读取的单词存储至$1 (和\1)
### 下面是任意多的空白字符和/或tag
( # 把空白保存到%2
(?: # 使用非捕捉型括号
\s #空白字符
|
<[^>]+> # <TAG>形式的tag
)+ # 至少需要出现一次, 多次不受限制
)
### 现在再次匹配第一个单词:
(\1\b) # \b保证用来避免嵌套单词的情况, 保存到$3
} # 正则表达式结束
# 上面是正则表达式. 下面是 replacement 字符串, 然后是修饰符 /i /g /x
{\e[7m$1\e[m$2\e[7m$3\e[m}/ig; # ④
s/^(?:[^\e]*\n)+//mg; # ⑤去除为标记的行
s/^/$ARGV: /mg; # ⑥在行首添加文件名
print;
}
1.因为单词重复问题必须应对单词重复位于不同行的情况. 在程序中使用特殊变量$/能让<>不再返回单行文字, 而返回或多或少的一段文字, 返回的数据仍然是一个字符串, 只是这个字符串可能包含多个逻辑行.
2.你是否注意到,<>没有值赋给任何变量? 作为 while 中的使用条件时,<>能够把字符串的内容赋给一个特殊的默认变量. 该变量保存了 s/…/…/ 和 print 作用的默认字符串. 使用这些默认变量能够减少冗余代码, 但 Perl 新手不容易看明白.
3.如果没有进行任何替换, 那么替换命令之前的 next unless 会导致 Perl 中断处理当前字符串(转而开始下一个字符串). 如果在当前字符串中没有找到单词重复, 也就不必进行下一步工作.
4.ANSI 转义序列, 把两个重叠的词标记为高亮, 中间的部分则不标记.转义序列\e[7m用于标注高亮的开始,\e[m用于标注高亮的结束(在 Perl 的正则表达式中, \e用来表示 ASCII 的转义字符, 该字符表示之后的字符为 ANSI 转义序列).
5.这个字符串可能包括多个逻辑行, 不过在替换命令标记了所有的重复单词之后, 我们希望只保留那些包含转义字符的逻辑行. 正则表达式「^([^\e]*\n)+」能够找出不包含转义字符的逻辑行.