算法与数据结构 / regex · Unicode 字符模型与一个自研引擎 / 量词下的分组:只剩最后一次 待审核 33 / 36
分组 + 量词 · 捕获槽只剩一个

量词下的分组:只剩最后一次

"123".replace(/(\d)+/, "$1") 得到的是 '3',既不是 '123' 也不是 '1'命名捕获与反向引用 说过命名捕获只是给组起别名,这页要说的是另一件事:一个捕获组只有一个槽,量词每重复一次就把上一次的值覆盖掉。整串确实匹配了,但只能拿到最后那一次。

1 · 量词在组外与在组内

一字之差,结果完全不同。(\d)+ 是「重复这个组」——组每次只吃一个数字,被重复三次,槽里留下最后一次;(\d+) 是「组里装了个 \d+」——组只匹配一次,一口气吃下整串。

图 1-1 · 用自研引擎实跑,给出整体命中与 groups 数组。可改 pattern 与输入,或点例子对照两种写法。

2 · 单步看槽被覆盖

把量词的每一次重复拆成一步,就能看清那个槽的一生:每迭代一次写入一个新值,上一次随即丢失,最后停下时槽里剩的就是 $1

图 2-1 · 量词逐次迭代的单步回放:纸带标出本次吃掉的字符,槽里显示当前值,下方是历次捕获的覆盖链。可换样例或自动播放。

3 · 别的语言的取舍

「只留最后一次」是 JS、Python、Java 与 PCRE 的共同选择,但不是唯一可能:那些被覆盖掉的值,有的语言留着。

表 3-1 · 同一个 (\d)+ 在几种引擎里能拿到什么。
语言或引擎 量词下的分组拿到什么
JS、Python、Java、PCRE 只有最后一次
.NET 默认值是最后一次,但 Group.Captures 留着每一次
Perl 默认最后一次;可用 (?{ … }) 内嵌代码观察匹配过程
Raku 直接把多次匹配收进一个 list

所以「只捕获最后一个」不是它没匹配,而是语言只保留了一个槽。引擎内部一次次匹配、一次次覆盖,Perl 可以打印过程,Raku 干脆把每次都收进列表,.NET 用 Captures 集合留底——同一个现象,不同语言给了不同的取舍。

4 · 放进后行断言

既然留下的是最后一次,把同样的结构塞进后行断言会怎样?"123".match(/(?<=(.){3})/)$1"1",不是 "3"。因为后行断言是从右往左匹配的:引擎站在位置 3,往左依次吃 321,最后一次迭代吃到的正是最左边那个字符。规则没变——留下的仍是最后一次——只是「最后一次」在方向反转后落到了串首。

注 · 这个现象与 零宽匹配 是一对:那页讲匹配可以不消耗字符,这页讲一次匹配里重复的捕获只留得下一个值。两者合起来解释了大多数「结果与直觉不符」的捕获问题。

5 · 参考文献

  1. Ecma International. ECMA-262: RepeatMatcher. 量词重复时捕获组被重置与写入的规范步骤。tc39.es
  2. Microsoft. Group.Captures. .NET 保留每次捕获的接口,本页对照表里那一行的出处。learn.microsoft.com
  3. MDN. Lookbehind assertion. 后行断言从右往左匹配的语义,第 4 节那道题的依据。developer.mozilla.org