回顾后发先行断言:正则表达式中最受欢迎的功能?

8
最近许多正则表达式问题中都包含一些看起来对匹配成功不是必需的环视元素。是否有某些教学资源在推广这些元素?我正在尝试弄清楚什么情况下使用正向/负向预查更好。我能想到的主要应用是当尝试“不”匹配一个元素时。但是,例如,最近一个问题中的这个查询有一个简单的解决方案来捕获.*,但为什么要使用后顾呢?
(?<=<td><a href="\/xxx\.html\?n=[0-9]{0, 5}">).*(?=<\/a><span

还有这个来自另一个问题:

$url = "www.example.com/id/1234";
preg_match("/\d+(?<=id\/[\d])/",$url,$matches);

什么情况下使用正向预查更好? 你能举些例子吗?

我知道这似乎是一个基于个人观点的问题,但我认为答案会非常有启发性。正则表达式已经够复杂了,不要让事情变得更加复杂...我已经阅读了此页面,更感兴趣的是一些简单的指导方针,以便在何时使用它们,而不是它们如何工作。


感谢所有的回复。除了下面列出的,我建议在这里查看m.buettner的精彩答案


一个思路清晰、措辞得当的问题,加一分! - Ricardo Saporta
1
可能重复?https://dev59.com/rWQn5IYBdhLWcg3wGD1s - Martin Ender
感谢@m.buettner。那是一个类似而且有趣的问题,您的答案很好,我之前没有找到。 - beroe
1
@beroe 还有一个用例我在之前的回答中没有提到,这里也没有提到。即使在不支持原子组占有量词的正则表达式中,环视通常是原子的。因此,您可以使用前瞻和反向引用来模拟原子组,因此(?>...)变成了(?=(...))\1。我不确定匹配两次并使用捕获是否值得收益,但有时原子分组也可以极大地简化模式。 - Martin Ender
再次感谢。有时候那些反向引用真的很有用... - beroe
显示剩余3条评论
8个回答

8
  1. 您可以捕获重叠的匹配项,并找到可能位于其他匹配项的环视中的匹配项。
  2. 您可以表达有关匹配的复杂逻辑断言(因为许多引擎让您使用多个前后断言,所有这些断言都必须匹配才能使匹配成功)。
  3. 环视是一种自然的表达常见约束“如果X后面跟随/前面接Y则匹配X”的方式。添加必须通过后处理抛弃的额外“匹配”部分(可以说)不太自然。

当然,负向前后断言更加有用。与#2结合使用,它们可以让您执行一些非常神奇的技巧,甚至可能难以用通常的程序逻辑表达。


以下是示例:

  • Overlapping matches: suppose you want to find all candidate genes in a given genetic sequence. Genes generally start with ATG, and end with TAG, TAA or TGA. But, candidates could overlap: false starts may exist. So, you can use a regex like this:

    ATG(?=((?:...)*(?:TAG|TAA|TGA)))
    

    This simple regex looks for the ATG start-codon, followed by some number of codons, followed by a stop codon. It pulls out everything that looks like a gene (sans start codon), and properly outputs genes even if they overlap.

  • Zero-width matching: suppose you want to find every tr with a specific class in a computer-generated HTML page. You might do something like this:

    <tr class="TableRow">.*?</tr>(?=<tr class="TableRow">|</table>)
    

    This deals with the case in which a bare </tr> appears inside the row. (Of course, in general, an HTML parser is a better choice, but sometimes you just need something quick and dirty).

  • Multiple constraints: suppose you have a file with data like id:tag1,tag2,tag3,tag4, with tags in any order, and you want to find all rows with tags "green" and "egg". This can be done easily with two lookaheads:

    (.*):(?=.*\bgreen\b)(?=.*\begg\b)
    

我们可以将您所说的应用到PHP吗? - pablofiumara
1
@pablofiumara:PHP具有广义的环视断言,所以答案是肯定的。 (在这里查看一些更复杂的示例链接)。 - nneonneo
非常感谢!你非常友善和乐于助人。 - pablofiumara
感谢@nneonneo的扩展。我仍然不理解你关于起始密码子的例子。这是为了找到具有框内终止密码子的ORF还是...?它与ATG(...)*(TAG|TAA|TGA)有何不同?是因为它会给出多个结果(在其他阅读框中的其他匹配项)吗? - beroe
@beroe:是的,确切地说。其他阅读框架和重叠基因。 - nneonneo

4

关于环视表达式有两个重要的特点:

  • 它们是零宽断言,即需要匹配但不会消耗输入字符串。这允许描述不包含在匹配结果中的字符串部分。通过在环视表达式中使用捕获组,它们是捕获输入多次的唯一方法。
  • 它们简化了很多事情。虽然它们不能扩展正则语言,但它们可以轻松地将(交集)多个表达式组合以匹配字符串的相同部分。

零宽度断言部分,在我看来非常关键。 - Ricardo Saporta

1
我尝试回答你的问题:
  • 查询中的某种环视元素对匹配成功似乎并不是必要的

    当然,它们对于匹配是必要的。一旦环视断言失败,就没有匹配。它们可以用来确保模式周围的条件也为真。整个正则表达式只有在以下情况下才匹配:

    1. 模式适合并且

    2. 环视断言为真。

    ==> 但返回的匹配结果只有模式。

  • 何时使用正向环视更好?

    简单答案:当你想让东西存在,但不想匹配它!

    正如Bergi在他的回答中提到的那样,它们是零宽度断言,这意味着它们不匹配字符序列,只是确保其存在。因此,在环视表达式内部的字符不会被“消耗”,正则表达式引擎在最后一个“消耗”字符之后继续执行。

  • 关于你的第一个例子:

    (?<=<td><a href="\/xxx\.html\?n=[0-9]{0, 5}">).*(?=<\/a><span
    

    我认为你有一个误解,当你写“有一个简单的解决方案来捕获.*”时。 .*不是“捕获”的内容,它是唯一与表达式匹配的内容。但只有那些在之前有“<td><a href="\/xxx\.html\?n=[0-9]{0, 5}">”并且在之后有“<\/a><span”的字符被匹配(这两个不是匹配的一部分!)。

    “捕获”的是由捕获组匹配的内容。

  • 第二个例子

    \d+(?<=id\/[\d])
    

    很有趣。它匹配数字序列(\d+),然后向后环视断言检查是否有一个数字前面带有"id/"。如果有多个数字或数字前缀的文本“id/”缺失,则会失败。这意味着当有适合的文本时,此正则表达式仅匹配一个数字。

  • 教学资源


非常感谢...在“第一个问题”中,OP想要捕获.*,他们的第一次尝试是我引用的正则表达式。这就是让我想知道为什么有那么多人似乎随意地把它们扔进去的原因。这两个例子都来自其他SO问题,试图弄清楚为什么它们不起作用... - beroe

1

我之前打过这个,但因为忙碌(现在还是),所以可能需要一段时间才能回复。我没有发布它,如果您仍然愿意得到答案...


有没有任何教学资源在推广它们?

我觉得没有,我相信这只是巧合。

But, for example, this query from a recent question has a simple solution to capturing the .*, but why would you use a look behind?

(?<=<td><a href="\/xxx\.html\?n=[0-9]{0, 5}">).*(?=<\/a><span
这很可能是一个C#正则表达式,因为许多正则表达式引擎都不支持可变宽度回溯。嗯,可以肯定地避免使用回溯,因为我认为对于这个问题来说,使用捕获组会更简单(并且在这里将.*设置为懒惰模式):
(<td><a href="\/xxx\.html\?n=[0-9]{0,5}">).*?(<\/a><span)

如果是用于替换,或者

<td><a href="\/xxx\.html\?n=[0-9]{0,5}">(.*?)<\/a><span

对于匹配而言,尽管使用HTML解析器更加明智,但是正则表达式也可以实现。

在这种情况下,我认为使用回顾后发现速度较慢。请参见regex101 demo,其中捕获组的匹配需要64个步骤,而回顾后发现需要94+19 = 1-3个步骤。

什么时候真正适合使用正向回顾后发现? 你能举些例子吗?

好吧,回顾后发现具有零宽度断言的属性,这意味着它们并不真正对匹配做出贡献,而是对决定要匹配什么以及允许重叠匹配做出贡献。

稍微思考一下,我认为负回顾后发现的使用频率更高,但这并不意味着正向回顾后发现没有用处!

以下是我在浏览我的一些旧回答时找到的一些“漏洞”(下面的链接将是来自regex101的演示)。如果您看到不熟悉的内容,我可能不会在这里解释,因为问题集中在正向先行断言上,但您可以随时查看我提供的演示链接,在那里有关于正则表达式的描述,如果您仍然需要一些解释,请告诉我,我会尽力解释。

获取特定字符之间的匹配项:

在某些匹配中,正向先行断言使事情更容易,其中一个先行断言也可以做到同样的效果,或者当使用没有先行断言不太实际时:

Dog sighed. "I'm no super dog, nor special dog," said Dog, "I'm an ordinary dog, now leave me alone!" Dog pushed him away and made his way to the other dog.

我们想要获取所有引号外的dog(不区分大小写)。通过使用正向先行断言,我们可以做到 this:

\bdog\b(?=(?:[^"]*"[^"]*")*[^"]*$)

为了确保引号数目是偶数,可以使用负向先行断言,代码如下this:
\bdog\b(?!(?:[^"]*"[^"]*")*[^"]*"[^"]*$)

为了确保前面没有奇数个引号,请使用类似于this的内容,如果您不想要先行断言,但是您需要提取第1组匹配项:

(?:"[^"]+"[^"]+?)?(\bdog\b)

好的,现在假设我们想要相反的情况;在引号内查找“dog”。具有环视的正则表达式只需要将符号取反,firstsecond
\bdog\b(?!(?:[^"]*"[^"]*")*[^"]*$)

\bdog\b(?=(?:[^"]*"[^"]*")*[^"]*"[^"]*$)

但是如果没有向前查看,这是不可能的。你能接近的最好的方法可能是this

"[^"]*(\bdog\b)[^"]*"

但这并不能获取所有的匹配项,或者你可以使用this
"[^"]*?(\bdog\b)[^"]*?(?:(\bdog\b)[^"]*?)?"

但是对于更多出现的dog,并且您得到的结果是具有递增数字的变量...这就是使用先行断言更容易的原因,因为它们是零宽度断言,您不必担心lookaround内部的表达式是否匹配dog或者正则表达式是否能够在引号中获取所有dog的出现次数。
当然,现在这个逻辑可以扩展到字符组,例如获取特定模式的单词之间的内容,例如startend之间的内容。
重叠匹配
如果您有一个字符串,例如:
abcdefghijkl

如果你想提取其中所有可能的连续三个字符,可以使用 this:

(?=(...))

如果你有类似这样的东西:

1A Line1 Detail1 Detail2 Detail3 2A Line2 Detail 3A Line3 Detail Detail

我希望您能提取这些内容,知道每行开头是#A Line#(其中#是数字):
1A Line1 Detail1 Detail2 Detail3
2A Line2 Detail
3A Line3 Detail Detail

你可以尝试使用this,但由于贪婪性而失败...
[0-9]+A Line[0-9]+(?: \w+)+

或者this,但是当它变得懒惰时就不再起作用了...

[0-9]+A Line[0-9]+(?: \w+)+?

但是使用正向预查,你可以得到this:

[0-9]+A Line[0-9]+(?: \w+)+?(?= [0-9]+A Line[0-9]+|$)

我希望你能适当地提取所需内容。

另一个可能的情况是这样的:

#ff00fffirstword#445533secondword##008877thi#rdword#

你想转换为三组变量(每组的第一个是带#和一些十六进制值(6)以及后面的任何字符):
#ff00ff and firstword
#445533 and secondword#
#008877 and thi#rdword#

如果“单词”中没有哈希标记,那么使用(#[0-9a-f]{6})([^#]+)就足够了,但不幸的是,情况并非如此,你必须使用.*?代替[^#]+,这还不能完全解决杂散哈希的问题。然而,正向预查使这成为可能
(#[0-9a-f]{6})(.+?)(?=#[0-9a-f]{6}|$)


验证和格式化

虽然不建议使用,但您可以使用正向先行断言进行快速验证。例如,以下正则表达式允许输入包含至少1个数字和1个小写字母的字符串。

^(?=[^0-9]*[0-9])(?=[^a-z]*[a-z])

这在检查具有不同长度模式的字符串字符长度时非常有用,例如,一个4个字符长的字符串,其中有效格式为#表示数字,连字符/破折号/减号-必须位于中间。
##-#
#-##

一个像 this 这样的正则表达式就能做到:
^(?=.{4}$)\d+-\d+

如果没有其他方式,你会使用^(?:[0-9]{2}-[0-9]|[0-9]-[0-9]{2})$,现在假设最大长度为15,则需要进行的更改次数将增加。

如果您想要一种快速而简单的方法来重新排列一些日期,使其格式更加统一,例如mmm-yyyyyyyy-mm,可以使用this

(?=.*(\b\w{3}\b))(?=.*(\b\d{4}\b)).*

输入:

Oct-2013
2013-Oct

输出:

Oct-2013
Oct-2013

另一种选择是使用正则表达式(普通匹配),并单独处理所有不符合格式的内容。

我在SO上遇到的另一件事是印度货币格式,它是##,##,###.###(小数点左侧有3位数字,其他数字以一对为一组)。如果您输入122123123456.764244,则期望得到1,22,12,31,23,456.764244,如果要使用正则表达式,这个可以实现:

\G\d{1,2}\K\B(?=(?:\d{2})*\d{3}(?!\d))

链接中的(?:\G|^)仅用于\G仅在字符串开头和匹配后匹配,我认为这不可能在没有正向先行断言的情况下工作,因为它可以向前查看而不移动替换点。

修剪

假设你有:

   this    is  a   sentence    

我想用一个正则表达式来去除所有空格,你可能会尝试在空格上进行全局替换:

\s+

但这会产生thisisasentence。那么,也许用一个空格替换?现在它输出 " this is a sentence "(双引号用于因反引号而吃掉空格)。你可以做的一件事是this
^\s*|\s$|\s+(?=\s)

Which makes sure to leave one space behind so that you can replace with nothing and get "this is a sentence".

分割

好的,正向先行断言可能有用的另一个地方是,当你有一个字符串ABC12DE3456FGHI789并想要将字母和数字分开时,也就是你想要得到ABC12DE3456FGHI789。你可以轻松使用正则表达式:

(?<=[0-9])(?=[A-Z])

如果您使用([A-Z]+[0-9]+)(即捕获组被放置在结果列表/数组等中),您将得到空元素。

请注意,这也可以使用匹配来完成,使用[A-Z]+[0-9]+


如果我要提到负向先行断言,这篇文章会更长 :)

这太棒了,Jerry。我会认真阅读它。很抱歉你错过了赏金期,但我很高兴你最终还是发布了它。 - beroe

1
我假设你理解了正则表达式中回顾前瞻的好处,并询问为什么它们没有明显的用途。
我认为人们使用正则表达式主要有四个类别:
验证 通常在整个文本上进行验证。像你描述的回顾前瞻是不可能的。
匹配 提取文本的一部分。回顾前瞻主要用于开发者的懒惰:避免捕获。 例如,如果我们在设置文件中有一行Index=5,我们可以匹配/^Index=(\d+)/并获取第一组,或匹配/(?<=^Index=)\d+/并获取所有内容。 正如其他答案所说,有时需要匹配重叠,但这种情况相对较少。

替换
这与匹配类似,唯一的区别是整个匹配被删除,并被新字符串(和一些捕获组)替换。
例如:我们想要突出显示"Hi, my name is Bob!"中的名字。
我们可以使用/(name is )(\w+)/替换为$1<b>$2</b>
但更好的方法是用<b>$&</b>替换/(?<=name is )\w+/ - 完全不需要捕获。

拆分
split将文本分解为令牌数组,以模式作为分隔符。操作如下:

  • 查找一个match。此匹配之前的所有内容都是一个令牌。
    • 匹配内容被丢弃,但:
    • 在大多数情况下,匹配中每个捕获组也是一个令牌(特别是Java除外)。
  • 当没有更多匹配时,剩余的文本是最后一个令牌。
在这里,环视是至关重要的。匹配一个字符意味着将其从结果中删除,或者至少将其与其标记分离。
例如:我们有一个由逗号分隔的带引号字符串列表:"Hello",“Hi, I'm Jim."
通过逗号/,/进行拆分是错误的:{"Hello""HiI'm Jim."}
我们不能添加引号标记,/",/:{"Hello"Hi, I'm Jim."}
唯一的好选择是向后查找,/(?<="),/:{"Hello""Hi, I'm Jim."}

个人而言,我更喜欢匹配标记而不是按分隔符进行拆分,只要可能。

结论

回答主要问题-使用这些环视的原因是:

  • 有时您无法匹配所需的文本。
  • 开发人员懒惰。

1
Lookaround assertions可以用于减少正则表达式中的回溯(backtracking),这可能是导致正则表达式性能不佳的主要原因。

例如:正则表达式^[0-9A-Z]([-.\w]*[0-9A-Z])*@(1)也可以写成^[0-9A-Z][-.\w]*(?<=[0-9A-Z])@(2),使用正向先行断言来简单验证电子邮件地址中的用户名。正则表达式(1)可能会导致大量回溯,因为[0-9A-Z][-.\w]和嵌套量词的子集。正则表达式(2)减少了过多的回溯,更多信息请参见Backtracking,部分内容为控制回溯 > 向后断言
有关回溯(backtracking)的更多信息,请参见。

2
嗯……这个例子有点勉强,不是吗?(1) 应该改为 ^[0-9A-Z][-.\w]*[0-9A-Z]@,这样才能公平比较。 - Kobi
是的,我知道,这只是为了说明目的。这是从 MSDN 的一个示例,还有其他示例在答案中的链接后面。 - polkduran

1

一个简单的情况,这些元字符很方便的地方是当你将模式锚定到行的开头或结尾,并且只想确保某个东西在你匹配的模式的前面或后面。


0

请记住,正/负向前查找对于正则表达式引擎来说是相同的。查找的目的是在“正则表达式”中的某个位置执行检查。

其中一个主要的兴趣是捕获某些内容而不使用捕获括号(捕获整个模式),例如:

字符串:aaabbbccc

正则表达式:(?<=aaa)bbb(?=ccc)

(您可以通过整个模式获得结果)

而不是:aaa(bbb)ccc

(您可以通过捕获组获得结果)


2
我认为你的例子正是我想知道的那种类型。如果你想捕获整个组,那么只写(aaa(bbb)ccc)不是更简单吗? - beroe
@beroe:在我的例子中,我不想捕获整个组,我只想捕获bbb - Casimir et Hippolyte

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