公司动态

正则指引——断言

📅 2026/8/10 9:15:16
正则指引——断言
断言1、单词边界2、行起始/结束位置3、环视4、补充4.1、环视的价值4.2、环视与分组编号4.3、环视的支持程度4.3.1、Ruby 1.84.3.2、Python、Ruby 1.94.3.3、PHP4.3.4、Java4.3.5、Objective-C4.3.6、.NET4.3.7、JavaScript4.3.8、Golang4.4、环视的组合4.5、断言和反向引用之间的关系4.6、逆序环视的诡异之处正则表达式中的大多数结构匹配的文本会出现在最终的匹配结果中一般用group(0)可以得到​但是也有些结构并不真正匹配文本而只负责判断在某个位置左/右侧的文本是否符合要求这种结构被称为断言assertion​。常见的断言有三类单词边界、行起始/结束位置、环视。1、单词边界在文本处理中经常可能进行单词替换比如把一段文本中的row都替换成line。一般想到的是调用字符串的替换方法直接替换row。在不同语言中这些方法各不相同但差别不大。不过这样替换也可能会造成意想不到的后果。不仅所有单词row都被替换成了linetomorrow和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 classhl和/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中的\ZJavaScript中的$的匹配也比较特殊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和JavaScriptES2017或者TC39没有任何限制。下面我们依次讲解这几种情况以及应急的解决办法。4.3.1、Ruby 1.8Ruby 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.9Python规定在逆序环视中的表达式能匹配的文本长度必须是固定的。也就是说(?dog)是合法的(?(dog|cat))也是合法的因为环视中的子表达式能匹配的文本都是固定的而(?dogs?)和(?(dog|cats))则都不合法因为环视中的子表达式能匹配的文本长度不确定。要解决这个问题可以用多选结构来改造表达式。比如dogs?等价于(dog|dogs)所以(?dogs?)可以改写为((?dog)|(?dogs))同样(?(dog|cats))可以改写为((?dog)|(?cats))但是这种办法的应用场景有限如果逆序环视中的表达式比较复杂用多选结构列出就非常麻烦而且一旦表达式中出现了*和之类的量词就根本不可能用多选结构列出了。4.3.3、PHPPHP对逆序环视中表达式的限制要宽松点它能匹配文本的长度可以不必限定但是长度必须是确定的数值(?dog|cats)是没问题的因为两个分支的长度都是固定的而(?!dogs?|cats?)则有问题因为两个分支的长度都是不确定的。要解决这个问题可以将(?!dogs?|cats?)改写为(?!dog|dogs|cat|cats)。但是要注意根据PHP中正则表达式的规定长度固定但不相同的多选分支只能出现在顶级的多选结构中。也就是说(?ab(c|de))是不支持的而(?abc|abde)则是支持的。4.3.4、JavaJava对逆序环视中表达式的限制比PHP还要宽松它能匹配的文本可以没有确定长度但是必须有上限。所以(?!dogs?|cats?)是没有问题的甚至(?!ab(c|de))也是没有问题的但是(?!(dogs?){3,})则不行编译时会报告无法确定环视中表达式能匹配文本的最大长度。4.3.5、Objective-CObjective-C对逆序环视中表达式的限制与Java相同能匹配的文本可以没有确定长度但是必须有上限。所以(?!dogs?|cats?)是没有问题的甚至(?!ab(c|de))也是没有问题的但是(?!(dogs?){3,})则不行编译时会报告无法确定环视中表达式能匹配文本的最大长度。4.3.6、.NET.NET对逆序环视的支持是最为完备的。在.NET里逆序环视中的表达式没有任何限制这是.NET的正则表达式最为人称道的一点。4.3.7、JavaScriptJavaScript对环视的支持比较复杂。​“经典”​从1999年确定的ES3到2005年确定的ES6的JavaScript只支持顺序环视不支持逆序环视。但是最新版本的ESES2017也叫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。看起来正则引擎的处理办法是遇到环视要尽可能找到这样的位置—在它的右侧最左侧的文本能由环视结构中的正则表达式匹配。虽然这么说有点绕但是理解了它就能理解下面的现象许多语言中的环视功能要求环视结构中的正则表达式匹配的文本长度必须固定或者有明确的上限。否则不是结果难以解释就是实现成本高昂。