regex 总览待审核
分组 + 量词 · 捕获槽只剩一个

量词下的分组:为什么只捕获到最后一次?

一个看着奇怪、几乎每种语言都一样的现象:(\d)+ 匹配 "123",整体能匹配上, 可拿 $1 / groups[1] 一看,只有 "3"—— 那 12 去哪了?命名捕获与反向引用页说过命名捕获只是给组起别名,这页要说的是另一件事: 一个捕获组只有一个槽,量词每重复一次,就把上一次的值覆盖掉。整串确实匹配了, 但你只能拿到最后那一次。

现象 · 几乎所有语言一致
> "123".replace(/(\d)+/, "$1")   // JS
'3'     // 不是 '123', 也不是 '1'

现场:量词在组 vs 在组

一字之差,结果完全不同:(\d)+ 是「重复这个组」——组每次只吃一个数字,被重复了 3 次,槽里留下最后一次 "3"; (\d+) 是「组里装了个 \d+」——组只匹配一次,一口气吃下 "123"。 下面用本仓库引擎实跑,看 groups[]:

命中高亮

单步观察那个槽被逐次覆盖

(单元)+ 的重复过程拆成一步步:每点一次「下一步」,组就再匹配一个单元, 捕获槽立刻被新值覆盖,上一次的就丢了。右边那串是「假如能看到每一次」的轨迹 (类似下文 Perl 用 (?{…}) 打印的那样)——但匹配结束后,语言只把最后一个(高亮那个)交给你。

挑一个 ↓

输入串(深色 = 本次迭代吃掉的 · 浅绿 = 已匹配)

组 1 捕获槽 ($1)
每次迭代的捕获(只有最后一个会留下)

别的语言怎么处理这件事

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

语言 / 引擎量词下的分组拿到什么
JS · Python · Java · PCRE只有最后一次($1 = "3")
.NET默认值是最后一次,但 Group.Captures 留着每一次([1,2,3])
Perl默认最后一次;可用 (?{ … }) 内嵌代码观察匹配过程
Raku (Perl 6)直接把多次匹配收进一个 list(caps 返回列表)
Perl · 用 (?{ … }) 打印引擎每一步的 $1
$ perl -le '"123456789" =~ /(?:(\d)(?{print "分组匹配了:", $1}))+/'
分组匹配了:1
分组匹配了:2
分组匹配了:3
…
分组匹配了:9   // 引擎匹配了 9 次, 但常规语法下你只拿得到最后的 9
所以「只捕获最后一个」不是它没匹配,而是语言只保留了一个槽。引擎内部一次次匹配、一次次覆盖, Perl 可用 (?{}) 打印过程,Raku 干脆把每次都收进 list,.NET 则用 Captures 集合留底—— 同一个现象,不同语言给了你不同的取舍

思考题:把它放进 lookbehind

既然「留下的是最后一次」,那把同样的结构塞进后行断言 (?<=…) 会怎样? (这是原作者七年前在 esdiscuss 提的问题。)先猜,再揭晓:

猜猜输出什么?
"123".match(/(?<=(.){3})/);
console.log(RegExp.$1)   // → ?
👆 点这里揭晓 —— 答案是 "1"(不是 "3"!)。
关键在方向:后行断言 (?<=…) 在 JS 里是从右往左匹配的。 光标在 "123" 末尾(位置 3),向左吞 3 个字符,(.) 的迭代顺序是 "3" → "2" → "1"。同样是「留下最后一次」,但这里最后一次迭代捕获的是最左"1"
正向匹配里「最后一次」= 最右;后行断言里「最后一次」= 最左 —— 镜像关系。
实现差异说明:本仓库的 @vega/parsing/regex 在这题上会给 "3" 而非 "1"—— 它的 lookbehind 是「枚举起点、再从左往右跑一遍」的朴素实现(够当 oracle,但没还原 JS 的右到左捕获)。 这恰好说明:这种边角语义高度依赖引擎实现,规范怎么定、引擎怎么写,结果可能不同。 引擎内部见 自研引擎那页