分类
Javascript

正则表达式中使用前后查找

我们见过的正则表达式通常都是用来匹配文本的,但有时我们还需要用正则表达式标记要匹配的文本的位置(而不仅仅是文本本身)。这就引出了前后查找(lookaround),对某一位置的前、后内容进行查找)的概念,我们将对此做专题讨论。

一、前后查找

我们还是先来看一个例子:你要把一个Web页面的页面标题提取出来。HTML页面标题是出现在标签之间的文字,而这对标签又必须嵌在HTML代码的部分里。下面就是这个例子:

文本

<TITLE>My Homepage</TITLE>

正则表达式

/<title>.*<\/title>/i

结果

<strong><TITLE>My Homepage</TITLE></strong>

标签以及这两个标签之间的任何文字。这个模式的效果与我们的预期基本相符,但不够理想。为什么这么说呢?因为只有页面标题内容才是我们需要的,而找到的匹配里还包含着标签。能不能只返回页面标题的文字部分呢?

办法之一是使用子表达式。我们可以利用子表达式把被匹配文本划分为3个部分:开始标签、标题文字、结束标签。把被匹配文本划分为多个部分之后,从它们当中提取日只提取出我们需要的东西就很容易了。

可是,明知是自己并不真正需要的东西(比如上例中的标签),还把它们检索出来岂不是毫无意义。“先想办法把它们检索出来,再以手动方式排除它们”,这既浪费时间,又容易招致不必要的后患。在遇到这类问题的时候,你真正需要的是这样一个模式,它包含的匹配本身并不返回,而是用于确定正确的匹配位置,它并不是匹配结果的一部分。换句话说,我们需要进行“前后查找”。

注意
我们将对向前查找(lookahead)和向后查找(lookbehind)都进行讨论。常见的正则表达式实现都支持前者,但支持后者的就没那么多了。

二、向前查找

向前查找指定了一个必须匹配但不在结果中返回的模式。向前查找实际就是一个子表达式,而且从格式上看也确实如此。从语法上看,一个向前查找模式其实就是一个以?=开头的子表达式,需要匹配的文本跟在=的后面。

提示
有些正则表达式文档使用术语“消费”(consume)来表述“匹配和返回文本”的含义,在向前查找里,被匹配的文本不包含在最终返回的匹配结果里。这被称为“不消费”。

我们来看一个例子。例子里的原始文本是一些URL地址,而我们的任务是把它们的协议名部分提取出来(为下一步处理做准备)。下面就是这个例子:

文本

http://www.riafan.com/
https://mail.riafan.com
ftp://ftp.riafan.com

正则表达式

/.+(?=:)/g

结果

http://www.riafan.com/
https://mail.riafan.com
ftp://ftp.riafan.com

在上面列出的URL地址里,协议名与主机名之间以:分隔。表达式.+匹配任意文本(第1个匹配是http),子表达式(?=:)匹配:。

注意
被匹配到的:并没有出现在最终的匹配结果里。我们用?=向正则表达式表明:只要找到:就行了,不要把它包括在最终的匹配结果里,用术语来说,就是“不消费”它。

为了更好地理解?=的作用,我们再来看一个同样的例子,但这次不使用向前查找元字符:

正则表达式

/.+(:)/g

结果

http://www.riafan.com/
https://mail.riafan.com
ftp://ftp.riafan.com

子表达式(:)正确地匹配到了:并包含了该字符,该字符出现在了最终的匹配结果里。

这两个例子的区别是前一个用来匹配:的模式是(?=),后一个用来匹配;的模式是(:)。这两个模式所匹配的东西是一样的,都是紧跟在协议名后面的那个:,它们之间的区别只是被匹配到的字符有没有出现在最终的匹配结果里而已。在使用向前查找的时候,正则表达式分析器将向前查找并处理:匹配,但不会把它包括在最终的搜索结果里。模式.+(:)查找到并且匹配结果包含:,模式+(?=:)查找到但匹配结果不包含:。

注意
向前查找(和向后查找)匹配本身其实是有返回结果的,只是这个结果的字节长度永远是0而已。因此,前后查找操作有时也被称为零宽度(zero-width)匹配操作。

提示
任何一个子表达式都可以转换为一个向前查找表达式,只要给它加上一个?=前级即可。在同一个搜索模式里可以使用多个向前查找表达式,它们可以出现在模式里的任意位置(而不仅仅是出现在整个模式的开头,就像你们在上面看到的那样)。

三、向后查找

正如我们刚看到的那样,?=将向前查找(查找出现在被匹配文本之后的字符,但不消费那个字符)。天此,?=被称为向前查找操作符。除了回前查找,许多正则表达式实现还支持向后查找,也就是查找出现在被匹配文本之前的字符但不包含它,向后查找操作符是?<=。

?<=与?=的具体使用方法大同小异;它必须用在一个子表达式里,而且后跟要匹配的文本。

下面是一个例子。你从某个数据库里搜索出了一份产品目录,但你只需要把那些产品的价格提取出来:

文本

电视:¥3000
洗衣机:¥2000
冰箱:¥1500
空调:¥2500
共计:4

正则表达式
/¥[0-9.]+/g

结果

电视:¥3000
洗衣机:¥2000
冰箱:¥1500
空调:¥2500
共计:4

[0-9.1+匹配价格。如上所示的匹配结果符合你的预期。但如果不想让¥出现在最终的匹配结果里,我们该怎么办?从这个模式里简单地把¥去掉能达到目的吗?

正则表达式

/[0-9.]+/g

结果
电视:¥3000
洗衣机:¥2000
冰箱:¥1500
空调:¥2500
共计:4

这显然不是我们想要的结果。我们需要¥来确定应该匹配哪些文本,我们只是不想让¥出现在最终的匹配结果里而已。

怎么办?其实不难。这正是向后查找可以大显身手的地方,如下所示:

正则表达式

/(?<=¥)[0-9.]+/g

结果
电视:¥3000
洗衣机:¥2000
冰箱:¥1500
空调:¥2500
共计:4

问题迎刃而解了。(?<=¥)只匹配¥,但不包含它;最终的匹配结果里只有价格数字(没有前缀的¥字符)。

我们来对比一下这个例子的第一个和最后一个表达式:¥[0-9.]+匹配一个¥字符和一个人民币金额;(?<=¥)[0-9.]+也匹配一个¥字符和一个人民币金额。这两个模式所查找的东西是一样的,它们之间的区别只体现在它们的匹配结果里。前一个模式的四配结果包含着¥,后一个模式的匹配结果不包含¥字符,虽然它必须通过匹配¥字符才能正确地找到那些价格数字。

警告
向前查找模式的长度是可变的,它们可以包含,和+之类的无字符,所以非常灵活。而向后查找模式只能是固定长度,这是一条几乎所有的正则表达式实现都遵守的限制。

四、把向前查找和向后查找结合起来

向前查找和向后查找可以组合在一起使用,就像下面这个例子所演示的那样(这个例子解决了我们在本意刚开始时提出的问题):

文本

<TITLE>My Homepage</TITLE>

正则表达式

/(?<=<title>).*(?=<\/title>)/i

结果

<TITLE><strong>My Homepage</strong></TITLE>

问题解决了。(?<=<title>)是一个向后查找操作,它匹配但不包含标题标签。最终匹配的结果仅包含标题文字。

提示
为减少歧义,在上面这个例子里,你应该对<(需要匹配的第一个字符)进行一下转义,也就是把(?<=<替换为(?<=\<。

五、对前后查找取非

到目前为止正如你看到的那样,向前查找和向后查找通常用来匹配文本,其目的是为了确定将被返回为四配结果的文本的位置(通过指定匹配结果的前后必须是哪些文本)。这种用法被称为正向前查找(positive lookahead)和正向后查找(positivelookbehind)。术语“正”指的是寻找匹配的事实。

前后查找还有一种不太常见的用法叫作负前后查找(negative lookaround)。负向前查找(negative lookahead)将向前查找不与给定模式相匹配的文本,负向后查找(negative lookbehind)将向后查找不与给定模式相匹配的文本。

大家都知道对字符集合进行取非处理的操作符^,但^不能用来对前后查找进行取非处理。这里必须使用另外一种语法:前后查找必须用!来取非(它将替换掉=)。下面列出了所有的前后查找操作符。

  • (?=) 正向前查找
  • (?!) 负向前查找
  • (?<=) 正向后查找
  • (?<!) 负向后查找

提示
一般来说,凡是支持向前查找的正则表达式实现都同时支持正向前查找和负向前查找。类似地,凡是支持向后查找的正则表达式实现都同时支持正向后查找和负向后查找。

为了演示正向后查找和负向后查找之间的区别,我们来看一个例子。下面是一段包含着一些数值的文本,其中既有价格又有数量。我们先来查找且只查找价格:

文本

I paid $30 for 100 apples,
50 oranges, and 60 pears.
I saved $5 on this order.

正则表达式

/(?<=\$)\d+/g

结果

I paid $30 for 100 apples,
50 oranges, and 60 pears.
I saved $5 on this order.

这与刚见过的例子非常相似。\d+匹配数值(一个或多个数字字符),(?<=\$)向后查找(但不消费)字符$(这个字符在模式里被转义为\$)。这个模式正确地匹配到了两个用来表示价格的数值,那些用来表示数量的数字没有出现在最终的匹配结果里。

接下来,我们再去查找且只查找数量:

正则表达式

/\b(2<!\S)\d+\b/g

结果

I paid $30 for 100 apples,
50 oranges, and 60 pears.
I saved $5 on this order.

\d+还是四配数值,但这次只匹配数量,不匹配价格。表达式(?<!\$)是一个负向后查找,它使得最终的匹配结果只包含那些不以$开头的数值。把操作符?<=改为操作符?<!使得整个模式从一个正向后查找变成了一个负向后查找。

细心的读者可能已经注意到了,在上面这个例子里,我们还在那个负向后查找模式里用\b元字符定义了两个单词边界。为什么要那么做呢?你看过下面这个没有使用单词边界的例子里就明白了。

正则表达式

/(?<!\$)\d+/g

结果

I paid $30 for 100 apples,
50 oranges, and 60 pears.
I saved $5 on this order.

请看,因为没有使用单词边界,$30里的0也出现在了最终的匹配结果里。这是因为那个0字符的前一个字符是3而不是$,它完全符合模式(<!\$)\d+的匹配要求。把这个模式用\b括起来从根本上解决了这个问题。

六、小结

有了向后查找,我们就可以对最终的匹配结果包含且只包含哪些内容做出更精确的控制。前后查找操作使我们可以利用子表达式来指定文本匹配操作的发生位置,并收到只匹配不消费的效果。正向前查找要用?=来定义,负向前查找要用?!来定义。有些正则表达式实现还支持正向后查找(相应的操作符是?<=)和负向后查找(相应的操作符是?<!)。