Perl与JavaScript正则表达式比较

6
为什么以下正则表达式在Javascript中通过捕获组可以捕获字符串“abc”,但在PCRE中不能(尽管它仍然会匹配)? (.*)*

你一定对regex101.com突出显示捕获组的方式感到困惑,对吧? - Wiktor Stribiżew
@WiktorStribiżew:我认为不是这个问题。比较一下JavaScript版本PCRE版本的捕获组内容。前者显示“abc”,后者则为空。(除非您指的是regex101中的错误。尽管结果不同,但解释似乎是相同的。) - T.J. Crowder
事实上,规范案例是(.*)*。在PCRE中,将Kleene星号放在末尾会阻止捕获组起作用。至少根据regex101的说法是这样的。我是否在这个特定的正则表达式测试器中漏掉了什么? - Tim Angus
另外,如果我将*更改为+,即(.+)+,它会按照我的预期工作。 - Tim Angus
3
好的,https://ideone.com/ALeMqh 和 https://jsfiddle.net/wvudw7yv/ 显示相同。可能与 JS 和 PCRE 中的空匹配处理有关。是的,将 * 改为 + 可以使模式至少匹配 1 个字符。这个问题和许多其他问题一样,其根本原因在于 JS 正则表达式处理空匹配的方式。请参见 Some regex pattern is breaking the javascript regex engine - Wiktor Stribiżew
我理解这个正则表达式可以匹配空字符串的意思,但我不明白的是,对于一个非空字符串,Perl会匹配整个字符串,但不进行捕获。 - Tim Angus
1个回答

11
这是关于PCRE中捕获组为空的原因:
  • Initial state

    (.*)*     abc
     ^        ^
    
  • First, the (.*) part is matched against abc, and the input position is advanced to the end. The capture group contains abc at this point.

    (.*)*     abc
        ^        ^
    
  • Now, the input position is after the c character, the remaining input is the empty string. The Kleene star initiates a second attempt at matching the (.*) group:

    (.*)*     abc
     ^           ^
    
  • The (.*) group matches the empty string after abc. Since it matched, the previously captured string is overwritten.

  • Since the input position hasn't advanced, the * ends iterating there and the match succeeds.

JS和PCRE之间的行为差异是由于正则表达式引擎的规定方式不同。PCRE的行为与Perl一致:
PCRE:
$ pcretest
PCRE version 8.39 2016-06-14

  re> /(.*)*/
data> abc
 0: abc
 1:

Perl:
$ perl -e '"abc" =~ /(.*)*/; print "<$&> <$1>\n";'
<abc> <>

让我们将其与.NET进行比较, 它具有相同的行为,但支持多个捕获:

.NET regex

当捕获组第二次匹配时,.NET 将捕获的值添加到“捕获堆栈”中。Perl 和 PCRE 则会简单地覆盖它。

关于JavaScript:

以下是ECMA-262 §21.2.2.5.1运行时语义:RepeatMatcher抽象操作:

抽象操作 RepeatMatcher 接收八个参数:匹配器 m、整数 min、整数(或 ∞)max、布尔值 greedy、状态 x、继续项 c、整数 parenIndex 和整数 parenCount,执行以下步骤:
1. 如果 max 为零,则返回 c(x)。
2. 创建一个内部继续项闭包 d,该闭包接收一个状态参数 y 并在评估时执行以下步骤: * a. 如果 min 为零且 y 的 endIndex 等于 x 的 endIndex,则返回 failure。 * b. 如果 min 为零,则让 min2 为零;否则让 min2 为 min-1。 * c. 如果 max 是 ∞,则让 max2 为 ∞;否则让 max2 为 max-1。 * d. 调用 RepeatMatcher(m, min2, max2, greedy, y, c, parenIndex, parenCount) 并返回其结果。
3. 让 cap 成为 x 的捕获列表的新副本。
4. 对于满足 parenIndex < k 且 k ≤ parenIndex+parenCount 的每个整数 k,将 cap[k] 设置为 undefined。
5. 让 e 成为 x 的 endIndex。
6. 让 xr 成为状态 (e, cap)。
7. 如果 min 不为零,则返回 m(xr, d)。
8. 如果 greedy 为 false,则 * a. 调用 c(x) 并让 z 成为其结果。 * b. 如果 z 不是 failure,则返回 z。 * c. 调用 m(xr, d) 并返回其结果。
9. 调用 m(xr, d) 并让 z 成为其结果。
10. 如果 z 不是 failure,则返回 z。
11. 调用 c(x) 并返回其结果。
这基本上是当量词被评估时应该发生的定义。 RepeatMatcher 是处理匹配内部操作 m 的操作。
您还需要了解什么是状态(§21.2.2.1,我强调):

状态是有序对(endIndexcaptures),其中endIndex是整数,而捕获则是NcapturingParens值的列表。状态用于表示正则表达式匹配算法中的部分匹配状态。 endIndex是到目前为止模式匹配的最后一个输入字符的索引加一,而captures保存捕获括号的结果。 captures的第n个元素是List,表示第n个捕获括号集合获得的值,或者如果尚未到达第n个捕获括号集,则为undefined。由于回溯,许多状态在匹配过程中任何时间都可能在使用。

对于你的例子,RepeatMatcher 的参数如下:

  • m: 负责处理子模式(.*)匹配器(Matcher)
  • min: 0(Kleene星号量词的最小匹配次数)
  • max: ∞(Kleene星号量词的最大匹配次数)
  • greedy: true(使用贪婪模式的Kleene星号量词)
  • x: (0, [undefined])(请参见上述状态定义)
  • c: 在此处,它是:一个继续函数,它总是将其状态参数作为成功的MatchResult返回,同时折叠父规则
  • parenIndex: 0(根据§21.2.2.5,这是整个正则表达式中在此产生物左侧出现的左括号数量
  • parenCount: 1(同一规范段落,这是此产生物的Atom扩展中左捕获括号的数量 - 我不会在这里粘贴完整的规范,但这基本上意味着m定义了一个捕获组)

规范中的正则表达式匹配算法是以传递续集风格为基础定义的。基本上,这意味着c操作表示接下来会发生什么

让我们展开这个算法。

第一次迭代

在第一次迭代中,x1状态为(0,[undefined])

  1. max 不为零
  2. 我们在这个时候创建了延续闭包 d1,它将在第二次遍历中使用,因此稍后我们会回到这个闭包。
  3. 复制捕获列表 cap1
  4. cap1 中的捕获重置为 undefined,这在第一遍遍历中是一个无操作
  5. e1 = 0
  6. xr1 = (e1, cap1)
  7. min 为零,跳过此步骤
  8. greedy 为真,跳过此步骤
  9. z1 = m(xr, d1) - 这将评估子模式 (.*)
现在让我们稍微退后一步 - m将使用(.*)匹配abc,然后调用我们的d1闭包,所以让我们展开它。 d1使用状态y1 =(3, ["abc"])进行评估:
  • min为0,但y1endIndex为3且x1endIndex为0,因此不返回failure
  • min2 = min = 0,因为min = 0
  • max2 = max = ∞,因为max = ∞
  • 调用RepeatMatcher(m, min2, max2, greedy, y, c, parenIndex, parenCount)并返回其结果。即:RepeatMatcher(m,0,∞,false,y1,c,0,1)

第二次迭代

现在我们要进行第二次迭代,其中 x2= y1=(3,["abc"])

  1. max 不为零
  2. 在此处创建续延闭包 d2
  3. 复制捕获列表 ["abc"],得到 cap2
  4. cap2 中的捕获重置为undefined,得到 cap2 = [undefined]
  5. e2 = 3
  6. xr2 = (e2, cap2)
  7. min 为零,跳过此步骤
  8. greedy 为真,跳过此步骤
  9. z2 = m(xr2, d2) - 这会计算子模式 (.*)

    这次 m 匹配了 abc 后面的空字符串,并调用我们的 d2 闭包。让我们评估 d2 的行为。它的参数是 y2 = (3, [""])

    min 仍然为零,但 y2endIndex 是 3,x2endIndex 也是 3(请记住这次 x 是上一次迭代中的 y 状态),闭包简单地返回 failure

  10. z2failure,跳过此步骤
  11. 返回 c(x2),在本次迭代中为 c((3, ["abc"]))

c在这里仅返回一个有效的MatchResult,因为我们已经到达模式的末尾。这意味着d1返回此结果,并且第一次迭代从步骤10传递它。

基本上,正如您所看到的,导致JS行为与PCRE不同的规范行是以下行:

a. 如果min为零且yendIndex等于xendIndex,则返回failure

与以下内容组合时:

  1. 调用c(x)并返回其结果。

如果迭代失败,则返回先前捕获的值。


啊哈!这有道理。然而,我不确定我理解JS的行为是什么。大概当它的主题为空时,外部不会第二次匹配?当然,如果我在JS中使用(.*){2}强制进行第二次匹配,那么行为就与Perl相匹配。 - Tim Angus
@TimAngus 我需要深入研究JS规范才能找出答案,但我怀疑JS只要到达字符串的末尾就会停止。我没有将其包含在答案中,因为我对此并不100%确定,我会尝试找出并稍后添加它。 - Lucas Trzesniewski
至于Perl,它不会给出无尽的空字符串序列,因为Perl会丢弃与前一个匹配具有相同起始位置和相同长度的匹配。 - ikegami
1
@Lucas Trzesniewski,您错了。如果最后一次匹配是空字符串,Perl不会添加一个字符。例如,perl -E'say "\@$-[0]: $&" while "abc" =~ /.*?/g'演示了在同一位置匹配空字符串和非空字符串。正如我上面所说的,当匹配与前一个匹配的位置和长度相同时,Perl会添加一个字符。 - ikegami
哎呀,我说错了,Perl在匹配与上次匹配相同位置和长度的时候会强制回溯。只有当在当前位置找不到匹配项时,它才会将起始位置加一。 - ikegami
显示剩余6条评论

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