条款二十九:使用正则表达式的捕获功能
虽然使用正则表达式可以非常方便地判断字串之间的模式匹配,但其作用远不止于此——它尤其适合对文本内容的分析和处理。而借助正则表达式的捕获功能,我们还可以从字串中自由提取感兴趣的部分。
1. 捕获变量:$1,$2,$3…
在使用正则表达式解析并捕获文本时,经常用到捕获变量$1, $2, $3等,依次类推。捕获变量(capture variable)与正则表达式中的圆括号相对应,有时也称为捕获缓存(capture buffer)。正则表达式内的每对圆括号都会“捕获”括号内匹配的文本,并将其存储到捕获变量中。比如从URL析取主机名和资源路径:
$_ = 'http://www.perl.org/index.html';
if (m#^http://([^/]+)(.*)#) {
print "host = $1\n"; # www.perl.org
print "path = $2\n"; # /index.html
}
只有匹配成功时,捕获变量才会被赋值。若是没有匹配,那么对应位置上的捕获变量不会改变其值,所以可能仍然留有之前匹配时得到的内容。来看具体例子,继续上面的代码,由于匹配失败,不会更新$1和$2,所以打印出来的仍然是之前匹配的结果:
$_ = 'ftp://ftp.uu.net/pub/';
if (m#^http://([^/]+)(.*)#) {
print "host = $1\n"; # 仍然是www.perl.org
print "path = $2\n"; # 仍然是/index.html
}
即使圆括号内的表达式能在字串内匹配多次,它也只会捕获最后一次匹配的内容。比如下面的$2,它可以匹配主机名后的每一段路径,但最终结束时捕获的是最后一段:
$_ = 'ftp://ftp.uu.net/pub/systems';
if (m#^ftp://([^/]+)(/[^/]*)+#) {
print "host = $1\n"; # ftp.uu.net
print "fragment = $2\n"; # /systems
}
要找出捕获变量数字和圆括号间的对应关系,不管括号嵌套多复杂,也只需要从左起依次数左括号的序数即可:
$_ = 'ftp://ftp.uu.net/pub/systems';
if (m#^ftp://([^/]+)((/[^/]*)+)#) {
print "host = $1\n"; # ftp.uu.net
print "path = $2\n"; # /pub/systems
print "fragment = $3\n"; # /systems
}
“数左括号”法则适用于所有正则表达式,即便是多选结构中的括号也不例外:
$_ = 'ftp://ftp.uu.net/pub';
if (m#^((http)|(ftp)|(file)):#) {
print "protocol = $1\n"; # ftp
print "http = $2\n"; # 空字串
print "ftp = $3\n"; # ftp
print "file = $4\n"; # 空字串
}
特殊变量$+保存了最后一个非空捕获变量的内容,接续上例可以看到:
print "\$+ = $+\n"; # ftp, 来自$3
而这种“沿袭”还不止于此!在新作用域内的捕获变量会自动本地化,唯一不同的是,本地化后变量依然沿袭外部作用域的取值,而不会像以往那样重新初始化:
$_ = 'ftp://ftp.uu.net/pub';
if (m#^([^:]+)://(.*)#) {
print "\$1, \$2 = $1, $2\n"; # ftp,ftp.uu.net/pub
{
# 开始时,$1和$2的值来自外部作用域
print "\$1, \$2 = $1, $2\n"; # ftp,ftp.uu.net/pub
if ( $2 =~ m#([^/]+)(.*)# ) {
print "\$1, \$2 = $1, $2\n"; # ftp.uu.net,/pub
}
}
# 退出内层作用域后,$1和$2又恢复了原来的值
print "\$1, \$2 = $1, $2\n"; # ftp, ftp.uu.net/pub
}
请注意,此处本地化使用的是local,而非my(见条款43)。
2. 捕获的反向引用
正则表达式本身可以用反向引用匹配或调用之前捕获的内容,以原子\1、\2、\3等表示,并依次匹配相应的缓存。
关于反向引用,有一个非常典型(但未必有用)的例子,即单词重复的处理。比如,下面的表达式会找出连续重复两次的字符:
/(\w)\1/;
匹配两个或两个以上的连续重复字符:
/(\w)\1+/;
对于嵌套使用的圆括号,一定要仔细计数,以便使用正确的反向引用。比如在下面这个表达式中,因为重复两次的字符是在左边起第二个圆括号内,所以应该使用\2的形式表示反向引用:
/((\w)\2){2,}/;
我们还可以重复使用反向引用。比如下面的例子就是用来搜寻同一个元音字符重复出现四次的表达式。第一行比较繁琐,但便于观察重复出现的反向引用模式;第二行的表达方式更自然,可以直接计数:
/([aeiou]).*\1.*\1.*\1/;
/([aeiou])(.*\1){3}/;
或许你还无法体会反向引用的妙处和用场,但这个特性真的非常强劲。比如处理定界符的识别和匹配,有了反向引用,写出来的正则表达式可以非常简洁:
q/"stuff"/ =~ /(['"]).*\1/;
也许贪婪匹配并不合适,万一吃掉太多引号的话结果多半不对。所以恰当的写法是用懒惰匹配(见条款34):
q/"stuff","more"/ =~ /(['"]).*?\1/;
你还可以用这种更酷的写法,顺便解决转义引号的误判问题:
q/"stuff\"more/ =~ /(['"])(\\\1|.)*?\1/;
不过,这种方法也有缺点:无法用来匹配成对的圆括号(即便不考虑嵌套括号的情形),而对于原本写成转义形式的字符,我们也有许多其他又快又好的处理办法。
3. 捕获并替换
捕获及匹配变量常常用于替换操作。在替换字串中出现的$1、$2、$&等变量,指的是在当前匹配区内捕获的缓存,它们不受先前赋值的影响,仅限当前正则表达式内有效。所以知道了这一点,就能理解下面交换两个单词位置的写法了:
s/(\S+)\s+(\S+)/$2 $1/;
对于简单的HTML标签转换,可以通过散列表查询,取得要转换的目标形式:
my %ent = { '&' => 'amp', '<' => 'lt', '>' => 'gt' };
$html =~ s/([&<>])/&$ent{$1};/g;
利用替换操作还可以生成缩略词。比如取新闻组名称的首字母:
$newsgroup =~ s/(\w)\w*/$1/g;
要是替换的目的是去除匹配的内容,而非保留改写,那就不要使用捕获,用了也是浪费。请看下面两行代码,一样是删除行首空格,第一行又是贪婪又是捕获,第二行则干脆利落直接删除,既快又好:
s/^\s*(.*)/$1/; # 显然是浪费
s/^\s+//; # 好多了
在需要计算替换结果的情况下,我们可以用/e(eval)修饰,这是一种解决运行时判决替换结果的技巧。比如,对于所有不存在的文件,在其文件名后追加not found的信息:
s/(\S+\.txt)\b/ -e $1 ? $1 : "<$1 not found>"/ge; (注意:这里-e选项是判断文件是否存在)
使用/e作替换时,我们常常结合/x修饰及成对的正则表达式界定符一起使用,以便增加程序的可读性(见条款37):
s{
(\S+\.txt)\b # 所有以.txt结尾的文件名
}{
-e $1 ? $1 : '<$1 not found>'
}gex;
4. 列表上下文中的匹配
在列表上下文中,匹配操作会根据所捕获的缓存,返回与其对应的列表。如果匹配失败,则返回空列表,但此时捕获变量$1、$2、$3等不会受其影响,依然保留原来的值。这应该是匹配符最常用的特性之一了。只需一步便能扫描并同时分割字串。下面这条依据RFC 2822规范析取邮件头信息的语句,就是利用这一特性写得非常简单紧凑:
my ( $name, $value ) = /^([^:]*):\s*(.*)/;
你可以有选择性地提取合适的内容。比如下面的代码,返回的邮件标题将不会包含回复邮件时加上的前缀Re::
my ( $bs, $subject ) = /^subject:\s+(re:\s*)?(.*)/i;
由于我们并不需要标题的前缀,所以利用列表切片,可以只返回标题内容:
my $subject = (/^subject:\s+(re:\s*)?(.*)/i)[1];
或者,直接省掉捕获内容的缓存,对Re:部分使用非捕获括号:
my ($subject) = /^subject:\s+(?:re:\s*)?(.*)/i
结合map循环返回匹配结果的写法,可以使代码更为流畅简洁。比如下面这行提取邮件发送日期的代码,从邮件头数组到最后的日期内容,一气呵成:
my ($date) = map { /^Date:\s+(.*)/ } @msg_hdr;
我们知道,失败的匹配会返回空列表,而正是由于这种特性,上面的写法可以忽略所有非日期行的邮件头,而将找到的第一行能匹配的日期赋于$date变量。
5. 用正则表达式切词
捕获功能还有一项比较有趣的应用,就是对字符串切词(tokenize)——利用空白字符、数字、标识符、操作符等诸如此类的词法元素,将字符串切分为一系列单词的操作。
如果你用Perl写过(或尝试过)计算机语言的语法解析工具,可能就会觉得Perl好像缺少了什么理应提供的功能,使得解析起来困难重重。而事实上,这的确非常难。问题在于解析字符串时,要确定的是如何根据字符串开头,推算出众多可能模式中最为合理的一种。而Perl擅长的,却是以固定模式测试字符串。这两者确实很难搭配到一起。
来看一个解析数学算式的例子。算式应该包含数字、括号以及+、-、*、/等算符。这里我们不考虑空白字符的影响,假设解析之前已经先行去掉。
最初看似直观的做法,是枚举各种可能的情况,然后将捕获到的数字或算符依次压入数组:
my @tokens;
while ($_) {
if (/^(\d+)/) {
push @tokens, 'num', $1;
}
elsif (/^([+\-\/*()])/) {
push @tokens, 'punct', $1;
}
elsif (/^([\d\D])/) {
die "invalid char $1 in input";
}
$_ = substr( $_, length $1 );
}
虽然代码不够美观,但效率上勉强还能接受。因为这里全部使用了以定位锚^开始的正则表达式,所以只在开头尝试匹配,不会逐个位置一一测试,运行起来相当快。
但如果解析较长字串的话,上面代码的效率会大幅下降,问题出在末尾的substr操作。或许你会想到一个解决办法,将匹配字串最后所在位置当作字串长度,保存到变量$pos中,节约length函数的计算开销:
if ( substr( $_, $pos ) =~ /^(\d+)/ ) { ... }
可惜,这么做并不会快很多;并且对较短字串而言,速度反而可能会下降。
其实有一种方法不但效率不错,而且还不受匹配字符的长短影响,它利用/g修饰在标量上下文中的返回结果,以判断字符串是否属于某种特定的计算模式。每次执行/g匹配,正则表达式引擎都会从上次成功匹配的结束位置开始寻找新的匹配。这就可以利用单条正则表达式匹配某种算式模式,从而免去手工维护匹配位置的麻烦:
# 使用m//g的方法
while (
/
(\d+) | # 数字
([+\-\/*()]) | # 运算符
(\D) # 数字以外的字符
/xg
)
{
if ( $1 ne "" ) {
push @tok, 'num', $1;
}
elsif ( $2 ne "" ) {
push @tok, 'punct', $2;
}
else {
die "invalid char $3 in input";
}
}
6. 要点
仅在匹配成功时使用捕获变量。
在列表上下文中使用匹配操作符,就能以列表的形式保存匹配结果。