算法与数据结构 / regex · Unicode 字符模型与一个自研引擎 / 零宽匹配:正则站在字符之间 待审核 30 / 36
位置 · 零宽匹配 · 锚点与 lookaround

零宽匹配:正则站在字符之间

"good".match(/o*/g) 的结果是 ["", "oo", "", ""]——明明只有一段 oo,却凭空多出三个空串。要解释它得先放下「正则在匹配字符」这个直觉:引擎真正站立的地方是字符之间的位置。长度为 n 的字符串有 n+1 个位置;引擎从某个位置出发尝试匹配,有的写法会消耗字符往前走,有的只校验当前位置、一个字符都不吃。

1 · 空串从哪来

* 的含义是「前面的元素出现零次或多次」,所以 o* 既能匹配 oo,也能匹配零个 o,也就是空串。把 "good" 摊成位置来看:位置 0 到 4 共五个,字符夹在它们之间。带 g 的全局匹配从位置 0 一路扫到末尾,在位置 1 一口气吃下 oo;而在位置 0、位置 3 与位置 4 都匹配不到 o,于是退而求其次匹配零个 o。四处各产出一项,正好凑成那个四元素数组。把 o* 换成 o+,要求至少一个,不允许空匹配,结果就只剩 ["oo"]

图 1-1 · 一次全局扫描的单步回放:位置标尺上标出当前扫描点、零宽命中与被消耗的字符,右侧逐项累积出最终数组。可改 pattern 与输入,或自动播放。

注 · 零宽命中后扫描位置若原地不动,就会在同一处反复命中空串、陷入死循环。规范的破法是命中空串后把位置强制加一。这套推进规则以及它引出的 lastIndex 状态问题,是 有状态的全局匹配 的主题;本页只关心哪些位置会产出命中、命中消不消耗字符。

2 · 三类写法

把常见写法放到同一个样本上跑全局匹配,按会不会吃字符分成三类。锚点 ^ $ \b \B 与环视是纯粹的位置断言,永远零宽,命中的全是空串;\d[a-z]+ 这类一定消耗至少一个字符;而带 *? 的量词下限为 0,所以可能零宽——能匹配字符时消耗,匹配不到时退化成空串。o* 的那些空串,正是第三种情形。

图 2-1 · 九种写法在同一样本上的全局命中,按消耗字符、可零宽与纯位置三类分组。可改样本。

3 · 在位置上插入

零宽不是只会制造意外。给数字加千分位逗号的经典写法 str.replace(/\B(?=(\d{3})+(?!\d))/g, ",") 全靠它:\B 锁定「两个数字之间」的位置,环视进一步要求从这个位置往右数、剩下的数字个数恰是 3 的整数倍。所有命中都是空串,replace 不替换任何原字符,只是在这些位置插入逗号。

图 3-1 · 千分位插入的命中位置与结果,标红处即零宽命中。可改数字观察哪些位置会命中。

建议 · 正则匹配的从来不是「一个字符」,而是「从某个位置出发能否找到符合模式的内容」,而符合的内容允许是零长度。记住这点,o* 的空串、\b 切出的空匹配、环视不吃字符,就都是同一件事的不同表现。这些空匹配在全局模式下如何推进 lastIndex,见 有状态的全局匹配

4 · 参考文献

  1. Ecma International. ECMA-262: RegExpBuiltinExec. 全局匹配的推进规则与空匹配的处理,lastIndex 加一的出处。tc39.es
  2. MDN. Lookahead assertion. 环视的语义与零宽含义;同目录下另有输入边界与词边界两条。developer.mozilla.org
  3. MDN. String.prototype.match(). 带 g 与不带 g 时返回值的差别。developer.mozilla.org