为什么在lookbehind中这个反向引用无法起作用?

23

使用反向引用在正则表达式中匹配重复字符非常简单:

(.)\1

在这里测试。

然而,我想匹配成对字符后面的字符,因此我认为可以在后顾中简单地放置它:

(?<=(.)\1).

不幸的是,这并没有匹配到任何内容。

为什么会这样?在其他正则表达式引擎中,我不会感到惊讶,因为对回顾后发限制有着强烈的限制,但.NET通常支持在回顾后发内部使用任意复杂的模式。


我已经在标题为“如何同时读取包含前瞻、后顾、捕获组和反向引用的.NET正则表达式”的参考链接中添加了这个问题 - nhahtdh
1
这个问题和相同的解释也适用于JavaScript REs。 - CertainPerformance
1个回答

32
短版说明:回顾断言是从右到左匹配的。这意味着当正则表达式引擎遇到\1时,它尚未将任何内容捕获到该组中,因此正则表达式始终失败。解决方案非常简单:
(?<=\1(.)).

在这里测试。

不幸的是,一旦你开始使用更复杂的模式,完整的故事就会变得更加微妙。因此,这里是...

一个关于在.NET中阅读正则表达式的指南

首先,要感谢一些重要的人。教我正则表达式中反向引用是从右到左匹配的人,并通过大量实验自己弄清楚这个问题的Kobi in this answer。不幸的是,我当时提出的问题非常复杂,对于这个简单的问题来说并不是一个很好的参考。因此,我们认为为了将来的参考和适当的重复目标,应该发表一个新的、更加规范的帖子。但请考虑给Kobi点赞,因为他找出了.NET的正则表达式引擎中一个非常重要但几乎没有文献记载的方面(据我所知,MSDN在一个不明显的页面on a non-obvious page中只提到了一句话)。

请注意rexegg.com解释了.NET的回顾后发生的事情,它是通过反转字符串、正则表达式和任何潜在的捕获来解释的。虽然这不会对匹配结果产生影响,但我发现这种方法更难理解,并且从代码看,很明显这不是实际实现的方式。
那么,第一个问题是,为什么实际上比上面加粗的句子更微妙。让我们尝试使用本地大小写不敏感修饰符匹配由aA前导的字符。考虑到从右到左的匹配行为,人们可能会期望这样做可以起作用:
(?<=a(?i)).

然而,正如您在这里看到的,这似乎根本没有使用修饰符。实际上,如果我们将修饰符放在前面:
(?<=(?i)a).

它能正常工作

另一个例子,可能会让人感到惊讶的是右到左匹配的情况下:

(?<=\2(.)(.)).

\2是指左边还是右边的捕获组?它指的是右边的捕获组,正如这个例子所示

最后一个例子:当匹配字符串abc时,会捕获b还是ab

(?<=(b|a.))c

它捕获了b(您可以在“表格”选项卡上看到捕获结果。)再次强调,“lookbehinds从右向左应用”并不是全部内容。

因此,本文试图成为.NET正则表达式方向性的全面参考资料,因为我不知道有任何这样的资源。阅读.NET中复杂正则表达式的诀窍是进行三到四次解析。除了最后一次解析外,其余所有解析都是从左到右进行的,无论是否存在lookbehinds或RegexOptions.RightToLeft。我认为这是因为.NET在解析和编译正则表达式时处理这些内容。

第一遍:内联修饰符

这基本上就是上面的示例所展示的内容。如果您的正则表达式中的任何位置都有以下代码片段:

...a(b(?i)c)d...

无论模式中的位置在哪里,或者您是否使用RTL选项,c都是不区分大小写的,而abd则不是(前提是它们没有受到其他先行或全局修饰符的影响)。这可能是最简单的规则。
第二步:组号[未命名组]
对于这一步,您应该完全忽略模式中任何命名组,即那些形如(?<a>...)的组。请注意,这不包括具有显式数字的组,例如(?<2>...)(这是.NET中的一个东西)。
捕获组从左到右编号。无论您的正则表达式有多么复杂,无论您是否使用RTL选项,或者您是否嵌套了几十个lookbehinds和lookaheads,当您仅使用未命名的捕获组时,它们会根据其开括号的位置从左到右编号。例如:
(a)(?<=(b)(?=(.)).((c).(d)))(e)
└1┘    └2┘   └3┘  │└5┘ └6┘│ └7┘
                  └───4───┘

当混合未标记组和显式编号组时,情况变得有些棘手。仍然应该从左到右阅读所有这些内容,但规则会更加复杂。您可以按以下方式确定组的编号:
  • 如果该组具有显式编号,则其编号显然是该数字(仅为该数字)。请注意,这可能会向已经存在的组编号添加附加捕获,也可能会创建新的组编号。还要注意,当您提供显式组编号时,它们不必连续。(?<1>.)(?<5>.)是一种具有组号24未使用的完全有效的正则表达式。
  • 如果该组未标记,则它采用第一个未使用的数字。由于我刚提到的间隙,这可能比已经使用的最大数字小。

这里有一个示例(为简单起见没有嵌套;当它们嵌套时,请记得按其开放括号对它们进行排序):

(a)(?<1>b)(?<2>c)(d)(e)(?<6>f)(g)(h)
└1┘└──1──┘└──2──┘└3┘└4┘└──6──┘└5┘└7┘

注意,显式组6创建了一个间隔,然后捕获组g获取了组46之间未使用的间隔,而捕获组h获取了7,因为6已经被使用。请记住,命名组可能出现在这些组之间,但我们现在完全忽略它们。
如果你想知道像这个例子中的组1重复出现的目的是什么,你可能想读一下平衡组
第三步:组号[命名组]
当然,如果正则表达式中没有命名组,你可以完全跳过这一步。
.NET中一个鲜为人知的特性是,命名组也有(隐式)组号,可以在Regex.Replace的反向引用和替换模式中使用。这些组号在所有未命名组处理完毕后单独进行。给它们编号的规则如下:
  • 当一个名称第一次出现时,该组获得第一个未使用的数字。如果正则表达式使用显式数字,则这可能是已使用数字中的间隙,或者它可能比迄今为止最大的组号码大1。这将永久地将此新数字与当前名称关联。
  • 因此,当一个名称再次出现在正则表达式中时,该组将具有与上次使用该名称时使用的相同数字。

一个更完整的例子,显示所有三种类型的组,并明确显示第二和第三步:

         (?<a>.)(.)(.)(?<b>.)(?<a>.)(?<5>.)(.)(?<c>.)
Pass 2:  │     │└1┘└2┘│     ││     │└──5──┘└3┘│     │
Pass 3:  └──4──┘      └──6──┘└──4──┘          └──7──┘

最终步骤:跟随正则表达式引擎

现在我们知道了哪些修改器适用于哪些标记以及哪些组具有哪些编号,我们终于进入了实际对应于正则表达式引擎的执行部分,并开始来回移动。

.NET的正则表达式引擎可以处理正则表达式和字符串两个方向:通常的从左到右模式(LTR)和其独特的从右到左模式(RTL)。您可以通过使用RegexOptions.RightToLeft为整个正则表达式激活RTL模式。在这种情况下,引擎将从字符串的末尾开始尝试查找匹配项,并从左到右遍历正则表达式和字符串。例如,简单的正则表达式:

a.*b

将匹配 b,然后尝试匹配 .* 左侧的内容(必要时回溯),以使其左侧某处存在一个 a。当然,在这个简单的例子中,从左到右模式和从右到左模式的结果是相同的,但刻意跟随引擎的回溯过程对于像非贪婪修饰符这样的简单情况也很重要。考虑正则表达式

a.*?b

相反,我们正在尝试匹配axxbxxb。在LTR模式下,您会得到预期的匹配axxb,因为不贪婪量词满足于xx。然而,在RTL模式下,实际上您将匹配整个字符串,因为第一个b被找到在字符串末尾,但是然后.*?需要匹配xxbxx的所有内容才能匹配a

显然,这对于反向引用也有影响,就像问题和本答案顶部的示例所示。在LTR模式下,我们使用(.)\1来匹配重复字符,在RTL模式下,我们使用\1(.),因为我们需要确保正则表达式引擎在尝试引用之前遇到捕获。

有了这个想法,我们可以以新的方式看待环视。当正则表达式引擎遇到回顾时,它按以下方式处理:

  • 它记住了目标字符串中当前位置x以及当前处理方向。
  • 现在,它无论处于什么模式下,都会强制RTL模式。
  • 然后,从当前位置x开始,从右到左匹配回顾内容。
  • 一旦回顾完成并通过测试,正则表达式引擎的位置将重置为位置x,并恢复原始处理方向。

虽然前瞻看起来更加无害(因为我们几乎从不遇到像问题中那样的问题),但它的行为实际上几乎相同,只是它强制执行LTR模式。当然,在大多数仅限LTR的模式中,这是不会被注意到的。但是,如果正则表达式本身在RTL模式下匹配,或者我们正在做一些像在回顾中放置前瞻这样疯狂的事情,那么前瞻将像回顾一样改变处理方向。

那么,如何真正“阅读”一个执行有趣操作的正则表达式呢?第一步是将其拆分为单独的组件,通常是带有相关量词的单个标记。然后根据正则表达式是从左到右(LTR)还是从右到左(RTL),分别从上到下或从下到上开始。在处理过程中遇到环视时,请检查其面向的方向并跳转到正确的结束位置,并从那里读取环视。完成环视后,请继续处理周围的模式。
当然还有一个问题...当你遇到一个分支(..|..|..)时,即使在RTL匹配期间,也总是从左到右尝试每个选项。当然,在每个选项中,引擎是从右到左进行处理的。
这里有一个有些牵强的例子来说明这一点:
.+(?=.(?<=a.+).).(?<=.(?<=b.|c.)..(?=d.|.+(?<=ab*?))).

以下是我们如何拆分它。左侧的数字显示正则表达式在LTR模式下的阅读顺序。右侧的数字显示RTL模式下的阅读顺序:

LTR             RTL

 1  .+          18
    (?=
 2    .         14
      (?<=
 4      a       16
 3      .+      17
      )
 5    .         13
    )
 6  .           13
    (?<=
17    .         12
      (?<=
14      b        9
13      .        8
      |
16      c       11
15      .       10
      )
12    ..         7
      (?=
 7      d        2
 8      .        3
      |
 9      .+       4
        (?<=
11        a      6
10        b*?    5
        )
      )
    )
18  .            1

我真诚地希望你永远不要在生产代码中使用像这样疯狂的东西,但也许有一天,一个友好的同事在被解雇之前会在公司的代码库中留下一些疯狂的只写正则表达式,在那一天,我希望这个指南能帮助你弄清楚到底发生了什么。
高级部分:平衡组
为了完整起见,本节将解释平衡组如何受到正则表达式引擎方向性的影响。如果您不知道什么是平衡组,可以安全地忽略它。如果您想知道什么是平衡组,我在这里写过, 这一部分假定您至少知道这些内容。
有三种与平衡组相关的组语法。
  1. 明确命名或编号组,比如(?<a>...)(?<2>...)(甚至是隐含编号的组),我们已经在上面处理过了。
  2. 从其中一个捕获堆栈中弹出的组,如(?<-a>...)(?<-2>...)。它们的行为符合您的预期。当它们被遇到时(按照上述正确的处理顺序),它们将仅从相应的捕获堆栈中弹出。值得注意的是,这些组没有隐式编号。
  3. "适当的"平衡组(?<b-a>...)通常用于捕获自b最后一个字符以来的字符串。当与从右到左模式混合使用时,它们的行为会变得奇怪,这就是本节讨论的内容。

结论是,(?<b-a>...)功能在从右到左模式下实际上无法使用。然而,在进行了大量实验之后,这种(奇怪的)行为实际上似乎遵循某些规则,我在这里进行了概述。

首先,让我们看一个例子来说明为什么环视会使情况复杂化。我们正在匹配字符串abcde...wvxyz。考虑以下正则表达式:
(?<a>fgh).{8}(?<=(?<b-a>.{3}).{2})

按照我上面给出的顺序阅读正则表达式,我们可以看到:

  1. 正则表达式将fgh捕获到组a中。
  2. 引擎然后向右移动8个字符。
  3. 回溯模式切换为RTL模式。
  4. .{2}向左移动两个字符。
  5. 最后,(?<b-a>.{3})是平衡组,它从组a弹出捕获,将something推入组b。在这种情况下,该组匹配lmn,我们将ijk按预期推入组b

但是,从这个例子中可以清楚地看出,通过更改数字参数,我们可以更改两个组匹配的子字符串的相对位置。我们甚至可以通过使3更小或更大来使这些子字符串相交,或者其中一个完全包含在另一个中。在这种情况下,不再清楚将两个匹配的子字符串之间的所有内容推入意味着什么。

事实证明,有三种情况需要区分。

案例1:(?<a>...)匹配到(?<b-a>...)左侧

这是正常情况。顶部捕获从a中弹出,两个组匹配的子字符串之间的所有内容都被推送到b中。考虑以下两个组的子字符串:

abcdefghijklmnopqrstuvwxyz
   └──<a>──┘  └──<b-a>──┘

你可以通过正则表达式获得它

(?<a>d.{8}).+$(?<=(?<b-a>.{11}).)

那么mn将被推入b

情况2:(?<a>...)(?<b-a>...)相交

这包括两个子字符串接触但不包含任何公共字符的情况(只有字符之间的公共边界)。如果其中一个组在查找中而另一个组不在或在不同的查找中,则可能会发生这种情况。在这种情况下,两个子字符串的交集将被推入b。即使一个子字符串完全包含在另一个子字符串中,这仍然是正确的。

以下是几个示例:

        Example:              Pushes onto <b>:    Possible regex:

abcdefghijklmnopqrstuvwxyz    ""                  (?<a>d.{8}).+$(?<=(?<b-a>.{11})...)
   └──<a>──┘└──<b-a>──┘

abcdefghijklmnopqrstuvwxyz    "jkl"               (?<a>d.{8}).+$(?<=(?<b-a>.{11}).{6})
   └──<a>┼─┘       │
         └──<b-a>──┘

abcdefghijklmnopqrstuvwxyz    "klmnopq"           (?<a>k.{8})(?<=(?<b-a>.{11})..)
      │   └──<a>┼─┘
      └──<b-a>──┘

abcdefghijklmnopqrstuvwxyz    ""                  (?<=(?<b-a>.{7})(?<a>.{4}o))
   └<b-a>┘└<a>┘

abcdefghijklmnopqrstuvwxyz    "fghijklmn"         (?<a>d.{12})(?<=(?<b-a>.{9})..)
   └─┼──<a>──┼─┘
     └─<b-a>─┘

abcdefghijklmnopqrstuvwxyz    "cdefg"             (?<a>c.{4})..(?<=(?<b-a>.{9}))
│ └<a>┘ │
└─<b-a>─┘

情况三:(?<b-a>...)匹配(?<a>...)的右侧

我并不真正理解这种情况,认为它是一个错误:当由(?<b-a>...)匹配的子字符串恰好位于由(?<a>...)匹配的子字符串的左侧(它们之间至少有一个字符,以使它们不共享公共边界)时,没有将任何内容推送到b。我的意思是什么都没有,甚至没有空字符串——捕获堆栈本身仍然为空。但是,匹配该组仍然成功,并且相应的捕获从a组中弹出。

这个问题特别让人烦恼的是,这种情况可能比情况2更常见,因为这是您尝试以平凡的从右到左的正则表达式使用平衡组的方式,但这样会发生什么。

第三个案例的更新:经过Kobi进行了更多测试后,发现在堆栈b上发生了某些事情。似乎没有推送任何东西,因为m.Groups["b"].Success将为Falsem.Groups["b"].Captures.Count将为0。然而,在正则表达式中,条件(?(b)true|false)现在将使用true分支。此外,在.NET中似乎可以在之后执行(?<-b>)(之后访问m.Groups["b"]将引发异常),而Mono在匹配正则表达式时会立即抛出异常。确实是一个错误。


1
@Quantic 当你提问时,有一个复选框“回答自己的问题 - 分享你的知识,问答式”,它会为你提供第二个区域来与问题一起编写答案。 - Martin Ender
.NET后顾之机制也在rexegg.com上揭示:.NET引擎有一种更高效的处理可变宽度后顾的方式。它不是从字符串越来越远的点开始尝试多个固定宽度的模式,而是反转字符串和后顾中的模式,然后尝试在反转的字符串上匹配单个模式。 - Wiktor Stribiżew
1
@WiktorStribiżew:从性能的角度来看,反转字符串是一件愚蠢的事情,而你只需要反转当前指针前进的方向即可。源代码确切地揭示了这一点:https://github.com/dotnet/corefx/blob/master/src/System.Text.RegularExpressions/src/System/Text/RegularExpressions/RegexInterpreter.cs - nhahtdh
1
只是好奇 - 为什么你把替代项称为“另一个捕获”,而不是作为正则表达式从左到右解析,然后根据全局标志和是否在前瞻/后瞻中以LTR / RTL方式运行的一般思想的一部分? - Rawling
1
@Rawling,虽然你说的是根本原因,但人们可能会认为运行RTL交替也会尝试从右到左的备选项,所以我觉得值得特别注意。 - Martin Ender
显示剩余2条评论

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