断言
-
- 1、单词边界
- 2、行起始/结束位置
- 3、环视
- 4、补充
-
- 4.1、环视的价值
- 4.2、环视与分组编号
- 4.3、环视的支持程度
-
- [4.3.1、Ruby 1.8](#4.3.1、Ruby 1.8)
- [4.3.2、Python、Ruby 1.9](#4.3.2、Python、Ruby 1.9)
- 4.3.3、PHP
- 4.3.4、Java
- 4.3.5、Objective-C
- 4.3.6、.NET
- 4.3.7、JavaScript
- 4.3.8、Golang
- 4.4、环视的组合
- 4.5、断言和反向引用之间的关系
- 4.6、逆序环视的诡异之处
正则表达式中的大多数结构匹配的文本会出现在最终的匹配结果中(一般用group(0)可以得到),但是也有些结构并不真正匹配文本,而只负责判断在某个位置左/右侧的文本是否符合要求,这种结构被称为断言(assertion)。常见的断言有三类:单词边界、行起始/结束位置、环视。
1、单词边界
在文本处理中经常可能进行单词替换,比如把一段文本中的row都替换成line。一般想到的是调用字符串的替换方法,直接替换row。在不同语言中这些方法各不相同,但差别不大。

不过,这样替换也可能会造成意想不到的后果。

不仅所有单词row都被替换成了line,tomorrow和rowdy两个单词内部的row也被替换成了line,这显然不是我们想要的结果。
要解决这个问题,必须有办法确定单词row,而不是字符串row。为解决这类问题,正则表达式提供了专用的单词边界(word boundary),记为\b。它匹配的是"单词边界"位置,而不是字符。也就是说,\b能够匹配这样的位置:一边是单词字符,另一边不是单词字符,如图所示。

下表更详细地说明了row配合单词边界\b之后的匹配情况。

观察表格,可以发现两点:
- 第一,单词边界并不区分左右,在"单词边界"上,可能只有左侧是单词字符,也可能只有右侧是单词字符,总的来说,单词字符只能出现在一侧;
- 第二,单词字符要求"另一边不是单词字符",而不是"另一边的字符不是单词字符",也就是说,一边必须出现单词字符,另一边可以出现非单词字符,也可能没有任何字符。所以,如果字符串只包含单词word,用\bword\b应该是可以匹配的,虽然w之前和d之后都没有任何字符。
单词边界要求一侧必须出现单词字符,到底什么是单词字符呢?
一般情况下,"单词字符"的解释是\w能匹配的字符。在JavaScript、PHP、Python 2、Ruby中,\w只能匹配[0-9a-zA-Z_]。所以在这些语言中,\b\w+\b能准确匹配英文单词了。给定一段文本,就可以用\b\w+\b将所有的单词提取出来,如下例,制表符、换行符都不在话下。
单词边界的匹配

在Web开发中,经常需要对某些单词标记高亮,一般的做法是在单词的前后加上tag,比如<span class="hl">和</span>,这个功能也可以由单词边界配合正则表达式替换完成,代码见下例。
使用单词边界,准确给单词标记高亮

但是也有些单词,\b\w+\b是无能为力的,比如e-mail和M.I.T.。因为连字符-和点号.都不能由\w匹配,所以\b\w+\b无法匹配e-mail,也无法匹配M.I.T.。如果确实希望处理e-mail之类的"单词",也可以把表达式改为\b[-\w]+\b。
如果使用的是.NET或者Python 3,在默认情况下,\w不仅仅等价于[0-9a-zA-Z_],还能匹配各种语言中的"单词字符",包括中文字符,这时\b的使用就有很大不同,具体情况现在不展开。
与单词边界\b对应的还有非单词边界\B,两者的关系类似\s和\S、\w和\W、\d和\D:在同一种语言中,不管\b是如何规定的,\b能匹配的位置,\B就不能匹配;\B能匹配的位置,\b就不能匹配。但是在实际使用中,\B使用频率远远少于\b,所以暂不做详细介绍。
2、行起始/结束位置
单词边界匹配的是某个位置而不是文本,在正则表达式中,这类匹配位置的元素叫作锚点(anchor),它用来"定位"到某个位置。除了刚才介绍的\b,常用的锚点还有^和$。通常来说,它们分别匹配字符串的开始位置和结束位置,所以可以用来判断"整个字符串能否由表达式匹配"。
一般情况下,^匹配整个字符串的起始位置。

依靠^,就可以用正则表达式^Some准确验证字符串"是否以Some开头",因为^会把整个表达式的匹配"定位"在字符串的开始位置。这样,即便表达式的其他部分可以在字符串中其他位置找到匹配,整个表达式也无法匹配成功。
在某些情况下,^也可以匹配字符串内部的"行起始位置"。在讲解这种情况之前,我们先来看看行是怎么划分的。
在编辑文本时,敲回车键就输入行终止符(Lineterminal),结束当前行,新起一行。看起来,这很好理解,然而不同平台上的行终止符其实各不相同,下表列出了常见平台下的"行终止符"(这里不说"回车"而说"行 终止符"是为了避免混淆,因为\r字符就叫"回车(符)",而\n字符叫作"换行符","这里的回车是由回车符和换行符构成的"之类说法容易引起误解)。

也就是说,每一行的"起始位置",就是"行终止符"之后的那个位置,如果没有专门的符号,就要考虑各种"行终止符"。下面的例子看得更清楚,为了让换行符"可见",我们用NL表示。
它其实是下面这样,其中的NL可能是\n,也可能是\r\n。

如果把匹配模式设定为多行模式(Multiline Mode,这是一种影响元字符匹配的设定)下,^就既可以匹配整个字符串的起始位置,也可以匹配换行符之后的位置(设定多行模式最简单的办法是在正则表达式之前加上(?m),这里虽然出现了括号,但因为是专用于指定匹配模式,所以不会作为捕获分组)。

注意,如果字符串的末尾出现了行终止符,^也会匹配这个行终止符之后的位置。这样做是有意义的,比如要在每行开头加上特殊标注(最常见的处理是把纯文本格式转换为HTML格式),末尾的空行自然不应该漏掉。不过一般来说,^的主要用途是与其他子表达式配合,比如像下例那样,提取每行的第一个单词。
提取每行的第一个单词

有些时候,我们无论如何也不想定位到字符串内部的行起始位置,只关心整个字符串的起始位置,则可以使用\A,绝大多数工具中的正则表达式都支持这个锚点,它在任何情况下(包括多行模式下)都只匹配整个字符串的起始位置,如下例所示。
匹配整段文本的第一个单词

"行结束位置"的情况更复杂。除去"行终止符"可能由各种字符表示的情况之外,"行结束位置"可能没有任何字符,还是上面的字符串,你猜猜它有几个行终止符?可能是\n,也可能是\r\n。
如果要匹配字符串的最后一个单词,不但必须考虑所对应字符的多种可能,而且要兼顾NL是否出现,情况更加复杂。
针对这种问题,正则表达式提供了"通吃"行结束符的锚点$,它匹配的同样是位置。通常它匹配的是整个字符串的结尾位置---如果最后是行终止符,则匹配行终止符之前的位置;否则,匹配最后一个字符之后的位置。

这时候,无论是什么,是否存在,都可以用表达式\w+$匹配最后一个单词,代码见下例。
匹配整段文本的最后一个单词

如果指定了多行模式,$会匹配每个行终止符之前的位置。最后一行的情况有点特殊:如果最后一行没有行终止符,则匹配字符串的结尾位置;否则,匹配行终止符之前的位置。

如果指定了多行模式,就可以用\w+$匹配每一行的最后一个单词了,代码见下例。
匹配每行的最后一个单词

与$类似的还有两个特殊标记\Z和\z,它们不受多行模式的影响,在任何情况下都匹配整个字符串的结束位置。

回过头来说^和$,这两个锚点介绍过,从数据校验的例子可以看到,re.search(pattern,string)只表示pattern能否在string中找到匹配,但是如果pattern只匹配string中的一部分,也不会返回None;为了验证整个string能否由pattern匹配,通常的做法是在pattern两端加上^和$,就像下例那样。
借助^和$完成数据验证

最常用到数据验证的场合就是对用户提交的数据进行验证,比如在网页上,要求用户在输入框(比如密码、邮箱)中填入某些信息,加以验证。一般来说,输入框中都不能输入换行符,但如果用户使用程序来提交,接收到的值就可能包含换行符。在这种情况下,在正则表达式两端添加^和$是无法准确验证的,因为$可以匹配"结尾行终止符之前的位置",验证时就忽略了末尾的行终止符。使用\z替换$可以堵住这个漏洞(使用\z时,最好把^也替换成\A,这样更符合习惯,因为一般^是和$成对出现的)。下面用Python为例说明这一点(因为Python不支持\z,但是Python中的\Z等价于其他语言中的\z,所以下例使用\Z)。
借助\A和\Z完成更准确的数据验证

类似Python中的\Z,JavaScript中的$的匹配也比较特殊,JavaScript没有提供\A、\z、\Z,只有^和$,但是JavaScript中的$,只能匹配字符串/行的结束位置,即便字符串末尾有换行符,也是如此,所以在验证时,可以放心使用^和$。JavaScript代码见下例。
JavaScript中的验证

^和$的另一个特点是,进行正则表达式替换时并不会被替换。也就是说,在起始/结束位置进行替换,只会在起始/结束位置添加一些字符,位置本身仍然存在。使用这个特性,我们可以很方便地转换文本的格式。常见的应用是将纯文本转换为HTML,比如将纯文本的电子文档转换成ePub格式,就需要如此处理。这个问题最简单的思路是,使用多行模式将^替换为<p>,将$替换为</p>,如下例所示,注意其中使用了多行模式,这样可以找到字符串内部文本行的开始和结束位置。
^和$的替换

^和$的另一个常用功能是删去多余的空白,包括行首尾的空白和空行。因为种种原因,要处理的文本可能经常包含许多空白字符,有些出现在行首,有些出现在行尾,还可能有不少空行。比如下面这段文本(为方便识别,在行的首尾分别用「和」标识)。

如果要整理格式,需要删掉不必要的空白字符,但又不能把所有空白字符都删掉(单词与单词之间的空白字符应当保留)。所以要做的其实是删除行首和行尾的空白字符,我们先删除行首的空白字符,使用的正则表达式是(?m)^\s+。
这里必须使用多行模式,否则就只能删除整个字符串首尾的空白字符。另一方面,此处使用了量词+而不是*,因为^\s*可以不匹配任何字符,这样的"删除"没有意义。将(?m)^\s+匹配的文本替换为空字符串,就执行了删除操作(一般正则表达式应用中没有单独的"删除"操作,删除操作都是通过将文本替换为空字符串实现的)。下例展示了去除字符串行首空白字符的代码。
去除行首的空白字符

为方便观察,仍然用上面的方式显示字符串。

因为\s匹配的空白字符中包含换行符\n,所以完全是空白字符的第3行(连同换行符)也被删掉了。现在来删行尾的空格,使用表达式\s+$,同样要记得使用多行模式,如下例所示。
去除行尾的空白字符

仍然用上面的方式显示字符串。

能不能用多选结构(^\s+|\s+$)并列两个表达式,一步完成呢?答案是不能。

不但第三行被删除,第二行和第四行也合并成一行,中间的\t\n\n全部被删除了,第二行末尾没有了换行符;而真正的目的其实只是想将\t\n\n替换为\n。仔细看看正则表达式(^\s+|\s+$)就可以知道,在\s+$中,\s可以匹配\t和\n,所以\s+$可以匹配开始的\t\n,同样^\s+可以匹配结尾的\n,所以\t\n\n经过两步被彻底删除了。
这个例子所用的表达式很有意思,它提醒我们用多选结构合并多个表达式时,一定要小心未曾预期的后果;有时候,分几步进行反而能省去许多麻烦,这类例子在后面还要讲到。
下表总结了各种语言中^、$、\Z和\z的匹配情况。其中Ruby是例外,Ruby提供了多行模式,但这个"多行模式"其实等价于常说的"单行模式",它的作用只是让点号.能够匹配换行符,而不影响^和$的匹配;另一方面,Ruby默认模式已经是"多行模式",所以$可以匹配字符串内部的行结束位置。


3、环视
前面介绍过单词边界匹配的是这样的位置:一边是单词字符,另一边不是单词字符。从另一个角度来看,它能进行这样的判断:在某个位置向左/向右看,必须出现或不能出现某类字符。有时候,这种功能非常有用。
用表达式<[^/>][^>]*>匹配open tag,它保证了<之后不会出现/,这样就排除了</img>之类的closetag,但它也可以匹配self-closing tag,比如。如果将表达式改为<[^/][^>]*[^/]>,又会有一个问题,在<和>中的[^/][^>]*[^/],能匹配的文本至少包含两个字符,所以它无法匹配<u>。
用正则表达式<[^/]([^>]*[^/])?>解决了这个问题。但是仔细想想,也可以从另一个角度描述:在开始位置匹配<,同时要求这个<之后不能是/;然后匹配中间的文本,除非在属性(也就是引号字符串)中,否则不能出现>,且长度必须大于1(<>不是合法的tag);最后匹配>,同时要求这个>之前不能是/。
这样描述更加准确,逻辑也更清晰,可以用三个子表达式分别匹配这三个部分。单独来看,开头的<、中间的内容、结尾的>都不难匹配,但必须解决一个问题:匹配开头的<时,除去找到<字符,还必须向后(向右)看看,确认字符不能是/,同时又不能真正匹配这个字符,因为<和>中间的文本是有单独的子表达式匹配的;同样,结尾>的匹配也是如此。
针对这种要求,正则表达式专门提供了环视(look-around)用来"停在原地,四处张望"。环视类似单词边界,在它旁边的文本需要满足某种条件,而且本身不匹配任何字符。
比如正则表达式<(?!/),其中的(?!/)是一个环视结构,(?!...)是这个结构的标识,/才是真正的表达式,整个结构的意思是"当前位置之后(右侧),不允许出现/能匹配的文本"。看起来它和<[^/]类似,其实大不相同:如果<(?!/)匹配成功,正则表达式真正匹配完成的只有<,而不包括<之后的那个字符,这样,就能准确表示"匹配<,同时这个<之后不能是/"。
再来看表达式(?<!/)>,其中的(?<!/)也是一个环视结构,(?<!...)是这个结构的标识,/才是真正的表达式,整个结构的意思是"在当前位置之前(左侧),不允许出现/能匹配的文本"(它与上面的(?!/)类似,只是多了一个<,更加形象地指向左侧)。这样,就能准确地表示"匹配>,同时>之前不能是/"。
至于<和>之间的文本,在前面曾讲解过,可以用('[^']*'|"[^"]*"|[^'">])+准确匹配。
最后,把这三个部分结合起来,得到正则表达式<(?!/)('[^']*'|"[^"]*"|[^'">])+(?<!/)>,它可以准确匹配opentag,而且不会错误匹配self-closing tag,匹配情况如图所示,代码见下例。

使用环视结构,准确匹配open tag

在这个表达式中出现了两种环视:(?!...)和(?<!...),它们的名字分别是"否定顺序环视"和"否定逆序环视"。"否定"的意思是"如果正则表达式匹配成功,则在当前位置匹配失败",而"顺序"和"逆序"则表示正则表达式需要匹配的文本所在的位置。所以总的来说,环视一共分为4种:
- 肯定顺序环视(positive-lookahead)
- 否定顺序环视(negative-lookahead)
- 肯定逆序环视(positive-lookbehind)
- 否定逆序环视(negative-lookbehind)

这4个名字容易混淆,不妨这样记忆:
- 在当前位置,如果是朝右判断,则是顺序环视(lookahead)
- 如果是朝左判断,则是逆序环视(lookbehind);
- 如果要求子表达式能匹配的字符串必须出现,则为肯定环视(positive)
- 如果要求子表达式能匹配的字符串不能出现,则为否定环视(negative)。
下图说明,对于字符串12345,以\d{3}为表达式的四种环视能匹配的位置分别是:
- 右侧必须出现三个数字字符
- 右侧不能出现三个数字字符
- 左侧必须出现三个数字字符
- 左侧不能出现三个数字字符

环视的最大特点是"匹配完成之后还停在原地",之前已经看到<(?!/)匹配的其实只有一个<字符,(?<!/)>匹配的也只有一个>字符,虽然它们都需要测试/的匹配。有时候确实需要用到"原地"的判断,因为要寻找的确实只是位置,而不需要真正匹配任何字符,比如格式化数字字符串的格式,就是如此。
英文中的数字更习惯用逗号分隔以方便阅读,比如12345应该写作12,345。如果用正则表达式来完成任务,就是"把逗号添加到这样的位置:右侧的数字字符串的长度是3的倍数",看起来只需要使用肯定顺序环视就足够了,用正则表达式找到这样的位置(?=(\d{3})+),将它"替换"为逗号(其实就是在这里塞进一个逗号),代码见下例。
格式化数字字符串,第一次尝试

结果却不是想象的那样。因为"右侧数字字符串"严格说应该是"当前位置右侧,所有数字字符构成的字符串";但是(?=(\d{3})+)并不能表达这个意思,比如第一个字符1之前的位置,右侧数字字符串长度为5,但其中存在长度为3的子串,所以这个位置也可以匹配。同样,2、3之前的位置都是如此。
解决这个问题必须配合否定顺序环视,让(\d{3})+能匹配右侧的整个数字字符串,而不能只匹配其中的一个子串。也就是说,要一直匹配到"右侧不再有数字字符的位置"为止。所以,必须将表达式改写为(?=(\d{3})+(?!\d)),结果如下例所示。
格式化数字字符串,第二次尝试

似乎没问题了,但是从下例看,如果字符串的长度正好是3的倍数,还是有问题。
格式化数字字符串,意外的情况

字符串的开头多出了一个逗号,因为这个位置右侧的数字字符串长度为6。更严格地说,要加入逗号的位置其实是这样的:右侧的数字字符串的长度是3的倍数,且左侧也是数字字符。所以还需要加上肯定逆序环视,将正则表达式修改为(?<=\d)(?=(\d{3})+(?!\d)),如例下例所示。
格式化数字字符串,最后的尝试

仔细观察这个例子可以发现,表达式中其实出现了三个环视结构,其中(?!\是一种组合,除去这个例子中出现的嵌套和并列两种组合,环视结构还可以通过其他方式组合。
环视是非常有用的功能,日常要执行的许多操作都可能用到环视,下面再举一个例子。
我们经常会遇到中英文混排的文本,英文文本需要用空白字符来区分单词,中文文本中则很少出现空白字符。但是在转贴或格式转换时,经常会产生一些多余的空白字符:

为了整理格式,需要删除这些空白字符。正则表达式匹配空白字符很容易,直接用\s+即可。但如果直接删除\s+能匹配的所有文本,就成了下面这样:

所以真正要找的,其实是这样的\s+:从它向左看,不能出现英文字母;从它向右看,也不能出现英文字母。所以需要在\s+的两端分别添加否定逆序环视和否定顺序环视,得到(?<![a-zA-Z])\s+(?![a-zA-Z]),结果如下例所示。
去掉中英文混排文本中不必要的空白字符

你或许会想,这个表达式能不能改一改,比如左侧的否定逆序环视(?<![a-zA-Z]),能不能改为肯定环视,指定出现一个非英文字符(?<=[^a-zA-Z]),右侧的否定顺序环视也改为肯定顺序环视(?=[^a-zA-Z])?
初看起来这并没有问题,但这个问题其实涉及肯定环视和否定环视的一大根本不同:肯定环视要判断成功,字符串中必须有字符由环视结构中的表达式匹配;而否定环视要判断成功,却有两种情况:字符串中出现了字符,但这些字符不能由环视结构中的表达式匹配;或者字符串中不再有任何字符,也就是说,这个位置是字符串的起始位置或者结束位置。这两种环视的区别,可以通过下例展现。
使用不同的环视去掉空白字符

如果使用肯定环视,则无法去掉字符串首尾的空白。因为在字符串的开头,\s+虽然能匹配空白字符,但其左侧并没有任何字符,所以(?<=[^a-zA-Z])无法匹配成功;字符串末尾的(?=[^a-zA-Z])也是如此。
到现在为止,如果你觉得自己已经掌握了环视功能,来看一个更复杂的例子:在电子邮件地址中,更准确地进行主机名验证。
在上一章,我们给出了一个匹配E-mail地址的表达式,但它还不够完整,尤其是主机名部分(hostname)的匹配。根据规范,主机名以点号分隔为多个域名字段(label),每个域名字段可以包含大小写字母、数字字母、横线,但是横线不能出现在开头位置。关于长度,每个域名字段的长度最多为63个字符,整个主机名的长度最多为255个字符。通常用的表达式是([-a-zA-Z0-9]{1,63}\.)*[-a-zA-Z0-9]{1,63},这个表达式有两个问题:
- 第一,它允许域名字段的第一个字符是横线-;
- 第二,它没有限定整个主机名的长度最长为255个字符。为准确匹配主机名,就必须解决这两个问题。
为保证域名字段的第一个字符不能是横线,可行的办法之一是单独匹配第一个字符,将表达式[-a-zA-Z0-9]{1,63}\.改写为[a-zA-Z0-9][-a-zA-Z0-9]{0,62}\.;不过,在表达式开始加上否定顺序环视更加直接,也更加自然,也就是(?!-)[-a-zA-Z0-9] {1,63}\.。
为保证整个主机名字符串长度小于255个字符,主机名中全部可能出现的字符都用[-a-zA-Z0-9.]表示,所以对应的肯定顺序环视就是(?=[-a-zA-Z0-9.]{0,255}),但是并不能直接把它添加到匹配主机名的整个表达式的开头,因为这个表达式只要求匹配一个长度在255个字符以内的字符串,并不能保证"之后的整个字符串长度在255个字符以内"。如果是单独给出一个字符串,验证它是否是合法的主机名,那么可以在这个环视中的表达式末尾添加$;如果是要从一长段文本中提取出某个主机名,那么主机名之后还有其他字符,只是这些字符不能是[-a-zA-Z0-9.](可能是空白字可见,这个表达式确实可以更准确地验证主机名。
下例可见,这个表达式确实可以更准确地验证主机名。
准确匹配主机名的正则表达式


4、补充
4.1、环视的价值
环视有一个很重要的用途,就是避免编写正则表达式时"牵一发动全身"的尴尬---既可以集中关注某个部分,添加复杂的限制,又不会干扰其他部分的匹配。有些时候,为添加某些限制而真正匹配文本,反而会影响整个表达式的匹配。
回想匹配open tag的例子:open tag要求<之后不能出现/(否则就是close tag,比如</a>),而>之前不能出现/(否则就是self-closing tag,比如<img .../>);而<和>之间的内容一般使用\^\>+匹配。如果使用<[^/][^>]+[^/]>,则<和>之间必须出现至少三个字符,即便是<[^/][^>]*[^/]>,<和>之间也必须至少出现两个字符,都无法匹配<u>,看来很麻烦。
如果使用环视,则非常容易解决:在<之后加上环视(?!/),就只匹配<,同时保证匹配的<之后不是/;在>之前加上环视(?<!/),也是如此;剩下的内容仍然由[^>]+匹配。这样结果清晰了很多,三个部分互不干扰,所以整个表达式就是<(?!/)[^>]+(?<!/)>。
环视的另一点价值在于,提取数据时杜绝错误的匹配。比如匹配邮政编码,直接的想法是找到6位数字构成的字符串,但仅仅用\d{6}提取,很可能在手机号码13812345678、电话号码28812506等其他数据中找到6位数字构成的字符串。如果在表达式首尾添加环视,改为(?<!\d)\d{6}(?!\d),就可以保证准确匹配6位数字构成的字符串。一般来说,凡是从文本中提取"有长度特征的数据",都需要用到环视。
还有些时候,可以在匹配的同时以环视施加限制,达到"双管齐下"的效果。举一个例子,Java和.NET的正则表达式都提供了字符组运算的功能,比如匹配所有的辅音字母,最简单的思路是"从26个字母中减去5个元音字母",在.NET中可以写作[a-z-[aeiou]],在Java中可以写作[[a-z]&&[^aeiou]]。如果使用其他语言,则没有这种便利,只能写作[b-df-hj-np-tv-z],不但烦琐,而且不便理解。但是使用环视则非常简单,写作(?![aeiou])[a-z],a-z真正匹配的是一个小写字母,但环视(?![aeiou])同时要求这个字母不能由aeiou匹配,最终效果就是"从26个字母中减去5个辅音字母"。
4.2、环视与分组编号
环视结构也要用到括号,这种括号是否会影响到分组编号呢?
前面说过,分组的编号只与捕获型括号有关,而不受其他任何类型括号的影响。所以,环视结构中虽然必须用到括号字符,但这里的括号只是结构需要,并不影响捕获分组,参见下例。
单纯的环视结构并不影响引用分组

前面说过,括号有多种用途,比如表示多选结构。即便括号只表示多选结构,如果没有显式指定为非捕获型括号(?:...),也会被视为捕获型括号,这时候结果就大不一样了,参见下例。
环视结构中出现了捕获型括号,会影响分组

这一点在实际使用中往往容易被忽略,认为环视结构并不会影响"真正"的匹配,其中的表达式匹配的文本不能从匹配结果中得到,结果算错了真正需要的分组编号。
而且,环视结构中的捕获型括号一旦匹配完成,就不能回溯。在某些情况下,这可能带来非常奇怪的问题。比如表达式(\d+)\w+\1,它可以匹配123a12,其中\d+匹配12,\w+匹配3a,\1反向引用\d+匹配的12。但是,将这个表达式改为(?<=(\d+))\w+\1,却无法在123a12中找到匹配,因为一开始\d+就会匹配123,之后跳出环视结构,这时\d+保存的备用状态全部消失了,所以无法交还3给\w+匹配,导致整个表达式匹配失败。
不过,需要用到这种表达式的情况很罕见,而且环视中能出现的表达式往往会有限制,不见得允许出现括号。
4.3、环视的支持程度
常用的语言(除去Golang)大都支持环视,但语言不同,支持的程度也不同,下面详细讲解。
一般来说,所有语言都支持两种顺序环视,而且没有限制。也就是说,无论你使用肯定顺序环视,还是否定顺序环视,都可以在其中使用各种复杂的表达式。
逆序环视的情况则没有这么乐观,Ruby 1.8中的正则表达式并不支持逆序环视,其他语言虽然支持逆序环视,但对逆序环视中的表达式能匹配的文本长度有限制5:Python只支持匹配固定长度文本的表达式,而Java、PHP、Objective-C只支持匹配有限长度文本的表达式,.NET和JavaScript(ES2017,或者TC39)没有任何限制。下面我们依次讲解这几种情况以及应急的解决办法。
4.3.1、Ruby 1.8
Ruby 1.8不支持逆序环视。如果确实需要用到逆序环视,而且只用到肯定逆序环视,不妨采用分组来替代,比如把表达式(?<=dog)s改写为dog(s)。注意,我们给s添加了一个括号,如果使用(?<=dog)s,需要提取整个表达式匹配的文本,但使用dog(s),只需要提取编号为1的分组捕获的文本即可。当然也可以更进一步,对dog使用非捕获型括号,写成(?:dog)s,思路与dog(s)相同,却不需要改动原有的分组编号。
但是,这个办法只对肯定逆序环视有效,而不能用于否定逆序环视,因为逆序环视是"从右向左"判断的,其他结构都是"从左向右"判断的,如果逆序环视中表达式能匹配的字符串长度是不固定的,就很难确定"从左向右"判断时的起点。
即便否定逆序环视中表达式能匹配的字符串长度是固定的,也难以用其他结构解决。比如表达式(?<!dog)s,要求s左侧不能出现dog,或许你会想用表达式(?!dog)(?:.{3})s,但是即便字符串s或者is也无法匹配,因为这时候s左侧或者是空字符串,或者是只包含字母i的字符串,而表达式中s左侧的.{3}要求匹配三个字符。看来,表达式要改为(?!dog)(?:.{0,3})s,但是这时候,它又可以匹配dogs中的ogs或者gs......总的来说,没有什么好的解决办法。
4.3.2、Python、Ruby 1.9
Python规定在逆序环视中的表达式能匹配的文本长度必须是固定的。也就是说,(?<=dog)是合法的,(?<=(dog|cat))也是合法的,因为环视中的子表达式能匹配的文本都是固定的;而(?<=dogs?)和(?<=(dog|cats))则都不合法,因为环视中的子表达式能匹配的文本长度不确定。
要解决这个问题,可以用多选结构来改造表达式。比如,dogs?等价于(dog|dogs),所以(?<=dogs?)可以改写为((?<=dog)|(?<=dogs)),同样,(?<=(dog|cats))可以改写为((?<=dog)|(?<=cats));但是这种办法的应用场景有限,如果逆序环视中的表达式比较复杂,用多选结构列出就非常麻烦;而且一旦表达式中出现了*和+之类的量词,就根本不可能用多选结构列出了。
4.3.3、PHP
PHP对逆序环视中表达式的限制要宽松点,它能匹配文本的长度可以不必限定,但是长度必须是确定的数值:(?<=dog|cats)是没问题的,因为两个分支的长度都是固定的;而(?<!dogs?|cats?)则有问题,因为两个分支的长度都是不确定的。
要解决这个问题,可以将(?<!dogs?|cats?)改写为(?<!dog|dogs|cat|cats)。但是要注意,根据PHP中正则表达式的规定,长度固定但不相同的多选分支,只能出现在顶级的多选结构中。也就是说,(?<=ab(c|de))是不支持的,而(?<=abc|abde)则是支持的。
4.3.4、Java
Java对逆序环视中表达式的限制比PHP还要宽松,它能匹配的文本可以没有确定长度,但是必须有上限。所以(?<!dogs?|cats?)是没有问题的,甚至(?<!ab(c|de))也是没有问题的,但是(?<!(dogs?){3,})则不行,编译时会报告无法确定环视中表达式能匹配文本的最大长度。
4.3.5、Objective-C
Objective-C对逆序环视中表达式的限制与Java相同,能匹配的文本可以没有确定长度,但是必须有上限。所以(?<!dogs?|cats?)是没有问题的,甚至(?<!ab(c|de))也是没有问题的,但是(?<!(dogs?){3,})则不行,编译时会报告无法确定环视中表达式能匹配文本的最大长度。
4.3.6、.NET
.NET对逆序环视的支持是最为完备的。在.NET里,逆序环视中的表达式没有任何限制,这是.NET的正则表达式最为人称道的一点。
4.3.7、JavaScript
JavaScript对环视的支持比较复杂。"经典"(从1999年确定的ES3到2005年确定的ES6)的JavaScript只支持顺序环视,不支持逆序环视。但是,最新版本的ES(ES2017,也叫TC39)大幅改善了对正则表达式的支持,JavaScript中的正则表达式也可以使用逆序环视了。考虑到ES2017是2017年定稿发布的,你读到本书的时候,流行的浏览器都已经支持ES2017了,所以大多数情况下应当是可以放心使用的。
尤其值得称道的是,ES2017在制订时充分调研了业界流行的支持,没有遵循"通用"规则,约束环视结构中的子表达式能匹配的文本必须有确定长度,而是选择了与.NET相同的标准,不对环视结构中的子表达式做任何限制。
4.3.8、Golang
很遗憾,到目前(Version 1.9.2)为止,Golang内置的Regexp不能支持任何环视功能。如果一定要使用环视,可以考虑之前的变通办法,或者采用其他第三方的正则库。因为Golang的历史不长,各种特性仍然在不断增加和变化,我们期待未来Regexp可以直接支持环视。
总的来看,语言不同,对逆序环视的限制也不相同。逆序环视之所以麻烦,是因为其机制与正常的匹配机制完全不同:它从当前位置开始,由右向左"倒过来"查找可能的匹配。实际的操作过程更像每次从右向左截取一段文本,再判断它能不能由表达式匹配,不行再尝试......这样的过程可能要重复尝试很多次。如果表达式能匹配的文本长度确定,处理的代价就很小,否则代价可能很大。所以,比较好的做法是尽量避免在逆序环视中使用复杂的表达式。
4.4、环视的组合
环视匹配的并不是字符,而是位置。在正则表达式匹配时,环视结构匹配成功,并不会更改"当前位置",所以多个环视可以组合在一起,实现在同一个位置的多重判断。
最常见的组合是环视中包含环视 (逻辑如图所示),比如之前在匹配主机名时,我们限定主机名的长度不能超过255个字符,使用表达式(?=[-a-zA-Z0-9.]{0,255}(?![-a-zA-Z0-9.]))。其中(?![-a-zA-Z0-9.])是包含在外层的环视中的,它要求在这个位置(也就是主机名字符串之后)不能再出现属于主机名字符串的字符,也就是保证之前的表达式匹配整个主机名字符串,而不是"可能的主机名字符串的一部分";综合起来,(?=[-a-zA-Z0-9.]{0,255}(?![-a-zA-Z0-9.]))保证的是"整个主机名字符串的长度在255个字符以内"。

另一种组合是并列多个环视 (逻辑如图所示),它要求在当前位置,所有环视的判断都必须成功。比如要找到这样的位置:它之后是一个数字字符串,但不能是999开头的数字。这时候,就必须并列两个环视。表示数字字符串的表达式是\d+,对应的环视结构是(?=\d+);表示"不是999开头"的表达式的环视结构是(?!999)。现在要做的是把两个环视并列起来,得到(?=\d+)(?!999)。因为环视结构不会更改当前位置,所以先后顺序无所谓,无论是(?=\d+)(?!999)还是(?!999)(?=\d+),效果是相同的,都要求同时满足下面两个条件:在当前位置,之后必须出现数字字符串;在当前位置,之后不能出现999。最终的结果都是对两个环视做"与(and)"运算,也就是说,两个条件必须同时满足才算匹配成功,否则宣告当前位置匹配失败。

最后一种组合是将若干个环视作为多选分支排列在多选结构中(逻辑如图所示)。比如要找到这样的位置:它之后要么不是数字字符,要么是一个数字字符和一个非数字字符(比如1a)。"不是数字字符"对应的环视是(?!\d);而"一个数字字符和一个非数字字符"对应的环视是(?=\d\D),所以总的环视就是((?!\d)|(?=\d\D))。虽然上一段也提过并列多个环视的组合,但在多选结构中列出多个环视结构的意义大不一样。使用多选结构时,列出的多个环视只要有一个成立,整个判断就成功;不使用多选结构时,所有列出的环视都必须成立,整个判断才成功,具体的例子可见下例。
环视作为多选分支



4.5、断言和反向引用之间的关系
断言不匹配任何字符,只匹配位置;而反向引用只引用之前的捕获分组匹配的文本,之前捕获分组中锚点表示的位置信息,在反向引用时并不会保留下来。
举例来说,如果表达式是(\bcat\b)\s+\1,\1所匹配的,就不只有单独出现的cat,还包括单词内部的cat(比如cate中的cat),如果要验证单词cat是否在字符串中出现了两次,正确的做法是在反向引用两端加上单词边界\b,变成(\bcat\b).*?\b\1\b,代码见下例。
反向引用时不会保留断言的判断

有些正则表达式的资料中提到用反向引用匹配重复单词的例子,但是往往只使用(\b\w+\b)\s+\1来匹配"重复单词",这其实是不对的,而应该使用(\b\w+\b)\s+\b\1\b。反向引用时,之前捕获分组中的断言都会被忽略,这一点请务必注意。
4.6、逆序环视的诡异之处
环视的概念确实比较难理解,所以值得多说几句。因为环视结构中的正则表达式既要尝试匹配,又不会"推进"当前的匹配位置,(通常)也不会把它匹配的文本包含在整个正则表达式的匹配结果里。但是这还不是最麻烦的,如果你使用逆序环视,还可能遇到更诡异的现象。
出现这种现象的主要原因在于,逆序环视是"向左看"的。也就是说,它匹配的是"从当前位置向左数,最右侧的文本"。但是,通常正则表达式匹配的都是"从当前位置向右数,最左侧的文本",所以在使用逆序环视时,环视结构中的正则表达式开始匹配的位置不同,可能决定了表达式的匹配成功与否。
如果表达式是(?<=ab+)cd,而字符串是abbcd。匹配结果似乎很容易理解,分两边来看,在右侧,子表达式cd匹配字符串cd;在左侧,字符串abb确实可以由子表达式ab+匹配,所以整个表达式应该是能够正确匹配的。但是如果我们调整一下字符串中开始匹配的位置,把它向右边推一个字符,会怎么样?仍然分两边来看,在右侧,子表达式cd匹配字符串cd;在左侧,字符串bb不能由子表达式ab+匹配,所以整个表达式是不能够正确匹配的。
那么,到底哪种结果是对的?根据我的测试,无论在JavaScript还是在.NET中,(?<=ab+)cd都可以匹配abbcd。看起来,正则引擎的处理办法是:遇到环视,要尽可能找到这样的位置---在它的右侧,最左侧的文本能由环视结构中的正则表达式匹配。虽然这么说有点绕,但是理解了它就能理解下面的现象:许多语言中的环视功能要求环视结构中的正则表达式匹配的文本长度必须固定,或者有明确的上限。否则,不是结果难以解释,就是实现成本高昂。