量词下的分组:为什么只捕获到最后一次?
一个看着奇怪、几乎每种语言都一样的现象:(\d)+ 匹配 "123",整体能匹配上,
可拿 $1 / groups[1] 一看,只有 "3"——
那 1 和 2 去哪了?命名捕获与反向引用页说过命名捕获只是给组起别名,这页要说的是另一件事:
一个捕获组只有一个槽,量词每重复一次,就把上一次的值覆盖掉。整串确实匹配了,
但你只能拿到最后那一次。
现象 · 几乎所有语言一致
> "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
(?{}) 打印过程,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 的右到左捕获)。
这恰好说明:这种边角语义高度依赖引擎实现,规范怎么定、引擎怎么写,结果可能不同。
引擎内部见 自研引擎那页。