我们需要前瞻/后顾零宽断言来做什么?

11

我刚刚更深入地了解了这两个概念。我一直很擅长正则表达式,但似乎从未看到过这两个零宽断言的用处。

我相信我是错的,但我不明白为什么需要这些构造。考虑以下示例:

Match a 'q' which is not followed by a 'u'.

输入将包括2个字符串:

Iraq
quit
使用否定环视,正则表达式如下:
q(?!u)

没有它,它看起来像这样:

q[^u]
对于给定的输入,这两个正则表达式都会得到相同的结果(即匹配Iraq但不匹配quit)(使用perl进行测试)。对于回顾断言也是一样的道理。
我是否漏掉了一个关键特性,使这些断言比传统语法更有价值?

2
如果我想检查foo后面没有bar,可以使用foo[^b][^a][^r](并将其消耗掉),但使用foo(?!bar)会更加简单易读。在if/else正则表达式语句中也可能很方便。 - HamZa
1
你使用 <>\b 单词边界匹配吗?它们是先行断言和后行断言的简写:< == (?<!\w)(?=\w)> == (?<=\w)(?!\w)\b == (?:(?<!\w)(?=\w)|(?<=\w)(?!\w))。虽然我猜测它们被优化用于特定的功能。 - Adrian Pronk
3
有点离题:这个问题现在在谷歌搜索“伊拉克退出比赛”中排名第二。 - mishik
我每天平均写五个(Perl)正则表达式,用于使用类似自定义编写的 ack 工具(但更快)的 grep 工具,在一个 400Mb 的源代码库中编写一行命令,并且如果没有前后查找,我无法生存。 - Adrian Pronk
1
请参见在数字字符串中插入逗号 - Adrian Pronk
显示剩余4条评论
4个回答

20

为什么你的测试可能起作用(以及为什么它不应该)

你之所以能够在测试中匹配到Iraq,可能是因为你的字符串末尾包含一个\n(例如,如果你是从shell中读取)。如果你有一个以q结尾的字符串,那么q[^u]不能像其他人说的那样将其匹配,因为[^u]匹配一个非u字符 - 但关键是必须要有一个字符。

我们实际上需要什么样的先行断言?

显然,在上面的情况下,前瞻并不是至关重要的。你可以通过使用q(?:[^u]|$)来解决这个问题。这样,我们只匹配q后面跟着一个非u字符或字符串的末尾。
虽然这种方式很好,但使用前瞻会更加高效。

下面是一些需要使用前瞻的重要的标准情况概述。

首先,让我们看一下引用字符串。通常的匹配方法是使用类似于"[^"]*"(而不是".*?")的模式。在开头的"后,我们只需重复尽可能多的非引号字符,然后匹配结束引号。同样,否定字符类是完全可以的。但是,在某些情况下,否定字符类无法胜任:

多字符分隔符

现在,如果我们没有双引号来限定我们感兴趣的子字符串,而是使用多字符分隔符。例如,我们正在寻找---sometext---,其中单个和多个-sometext中都是允许的。这时,你不能只使用[^-]*,因为这会禁止使用单个-。标准技术是在每个位置使用负向先行断言,并且只有在它不是---的开头时才使用下一个字符。像这样:

---(?:(?!---).)*---

如果你以前没有看过这个,它可能会看起来有点复杂,但比其他方法更好(通常也更高效)。

不同的分隔符

你可能遇到类似的情况,其中分隔符只是一个字符,但可以是两个(或更多)不同的字符之一。例如,假设在我们的初始示例中,我们想要允许单引号和双引号字符串。当然,你可以使用'[^']*'|"[^"]*",但最好不要使用这种替代方案。用一个回溯引用可以轻松处理周围的引号:(['"])[^'"]*\1。这确保了匹配以相同的字符结束,就像开始时那样。但现在我们太严格了——我们想允许单引号字串中的"和双引号字串中的'。类似于[^\1]的东西行不通,因为回溯引用通常包含不止一个字符。所以我们使用与上面相同的技术:

(['"])(?:(?!\1).)*\1

在开头引号后,消耗每个字符之前,我们确保它不与开头字符相同。我们持续做这件事,直到匹配到开头字符。

重叠匹配

这是一个完全不同的问题,通常无法在没有环视的情况下解决。如果你想要全局地搜索匹配(或者想要在正则表达式中进行全局替换),你可能已经注意到匹配永远不会重叠。也就是说,如果你在 abcdefghi 中搜索 ... ,你会得到 abcdefghi,而不是 bcdcde 等。这可能会成为一个问题,如果你想要确保你的匹配前面(或周围)有其他东西。

假设你有一个 CSV 文件,类似于:

aaa,111,bbb,222,333,ccc

而您想要提取的字段全部为数字。 为简单起见,我假设没有任何前导或尾随空格。 没有先行断言的情况下,我们可以使用捕获并尝试:

(?:^|,)(\d+)(?:,|$)

因此,我们确保有一个字段的开始(字符串的开头或,),然后是只有数字,最后是字段的结束(,或字符串的结尾)。在此之间,我们将这些数字捕获到组1中。不幸的是,这在上面的例子中无法给出333,因为它前面的,已经是匹配项,222,的一部分了,而且匹配项不能重叠。正则表达式中的先行断言解决了这个问题:

(?<=^|,)\d+(?=,|$)

如果您更喜欢使用双重否定而不是交替,那么这等同于

(?<![^,])\d+(?![^,])

除了能够获取所有匹配项之外,我们还摆脱了通常会降低性能的捕获过程。(感谢Adrian Pronk提供的这个示例。)

多个独立条件

另一个非常经典的使用前后查找(特别是向前查找)的例子是:当我们想要同时检查输入中的多个条件时。假设我们想要编写一个正则表达式,确保输入包含数字、小写字母、大写字母、既不是这些字符也不是空格的符号,并且没有空白(例如,用于密码安全)。如果没有前后查找,您将不得不考虑数字、大小写字母和符号的所有排列组合。例如:

\S*\d\S*[a-z]\S*[A-Z]\S*[^0-9a-zA_Z]\S*|\S*\d\S*[A-Z]\S*[a-z]\S*[^0-9a-zA_Z]\S*|...

这只是24个必要排列中的两个。如果您还想在同一正则表达式中确保最小字符串长度,则必须将它们分布在\S*的所有可能组合中-这在单个正则表达式中变得不可能。

展望未来!我们可以在字符串开头使用几个先行断言来检查所有这些条件:

^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[^0-9a-zA-Z])(?!.*\s)
由于前瞻不会实际消耗任何内容,在检查每个条件后,引擎会将其重置为字符串的开头,并可以开始查看下一个条件。如果我们想要添加最小字符串长度(比如8),我们只需添加(?=.{8})。更简单,更易读,更易维护。
重要提示:这不是在任何实际情况下检查这些条件的最佳通用方法。如果您正在编写程序检查,通常最好为每个条件设置一个正则表达式,并分别检查它们 - 这样可以返回更有用的错误消息。但是,如果您有一些固定的框架仅通过提供单个正则表达式来进行验证,则以上内容有时是必要的。此外,值得知道通用技术,如果您有独立的字符串匹配的标准。
希望这些示例能让您更好地了解为什么人们喜欢使用前瞻。还有很多其他应用(另一个经典应用是在数字中插入逗号),但重要的是要意识到(?!u)[^u]之间存在差异,并且存在负面字符类根本不够强大的情况。

1
重叠匹配:提取CSV行中的字段。例如,所有数字字段:@numbers ='aaa,111,bbb,222,ccc'=〜/(?<! [^,])([^,]*)(?! [^,])/ g - Adrian Pronk
@AdrianPronk 谢谢!我稍微修改了一下,然后使用了它。 - Martin Ender
@0xCAFEBABE 提到了另一个非常重要的使用场景,这是我完全忘记了的。 - Martin Ender

5

q[^u] 不会匹配 "Iraq" ,因为它会寻找另一个符号。

q(?!u),然而,将匹配"Iraq":

regex = /q[^u]/
/q[^u]/
regex.test("Iraq")
false
regex.test("Iraqf")
true
regex = /q(?!u)/
/q(?!u)/
regex.test("Iraq")
true

4

除了其他人提到的负向先行断言之外,您还可以匹配连续字符(例如,您可以否定ui,而使用[^...],您无法否定ui,而是ui,如果您尝试[^ui]{2},您也将否定uuiiiu


3
整个关键点在于不“消费”下一个字符,以便它可以被后来的表达式捕获。如果它们是正则表达式中的最后一个表达式,则你所展示的是等效的。但是例如 q(?!u)([a-z]) 将允许非u字符成为下一个组的一部分。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接