指南内含 1 场可玩狩猎

正则表达式的先行断言与后行断言:用可交互棋盘讲清楚

环视让正则可以检查某个位置前后的内容,而不把它们纳入匹配。本文讲解四种环视如何工作、它们擅长什么,以及什么时候普通正则反而更短。

Regex Hunter 标志

· 阅读约 4 分钟

/q(?!u)/

抓住

  • iraq
  • qatar
  • qi

放过

  • queen
  • quiet
  • quote

正则表达式的大部分内容都会消耗文本。cat 吃掉三个字符,\d+ 吃掉它能吃的所有数字,引擎随后越过它匹配到的内容继续前进。

环视不会。它们停在字符串的某个位置,向前或向后看一眼,然后回答“是”或“否”。它们匹配零个字符。凭这一个想法,你就能表达普通正则表达不了的东西,比如“后面不跟 u 的 q”,或者“紧跟在美元符号后面的数字,但不包括美元符号”。

下面每个示例都可以交互:修改正则,各行结果会随之更新;也可以点击试试在沙盒中打开它。

01四种环视

语法 名称 成立条件…
(?=X) 正向先行断言 X 紧随其后
(?!X) 负向先行断言 X 不紧随其后
(?<=X) 正向后行断言 X 紧挨在前面
(?<!X) 负向后行断言 X 不紧挨在前面

名称告诉你方向:先行检查当前位置之后的文本,后行检查它之前的文本。= 表示“必须存在”,! 表示“必须不存在”。

02负向先行断言:后面没有 u 的 q

在英语中,q 后面几乎总是跟着 u。用负向先行断言,一行就能抓住例外:

匹配6/6 符合预期
/q(?!u)/

应当匹配3/3

  • iraq
  • qatar
  • qi

不应匹配3/3

  • queen
  • quiet
  • quote
在沙盒里试试
(?!u) 检查 q 后面的字符,但不匹配它。“iraq”能通过,是因为它的 q 后面什么都没有。

注意 iraq。一个诱人的替代写法是 q[^u],即“q 后面跟着除 u 以外的任意字符”。但 [^u] 必须匹配一个字符,而 iraq 里的 q 是最后一个字母。先行断言只要求下一个位置不是 u,而字符串末尾当然不是 u。

03正向先行断言:匹配一部分,检查下一部分

正向先行断言让你可以要求某种上下文,却不把它包含进匹配结果。这里我们想要数字,但只要表示美元金额的那个:

匹配3/3 符合预期
/\d+(?= dollars)/

应当匹配1/1

  • it costs 40 dollars

不应匹配2/2

  • 40 euros
  • dollars 40
在沙盒里试试
第一行只匹配了“40”。“ dollars”只被检查,没有被消耗。

只要你在替换或提取文本,这一点就很重要。用 \d+(?= dollars) 做查找替换,改掉的是数字,“dollars”这个词则原封不动。

04叠加先行断言:同时满足多条规则

因为先行断言不会移动位置,你可以在同一个位置放好几个。每一个都在整个字符串上检查一条不同的规则。这就是经典的密码检查:至少一个数字、至少一个大写字母、八个或更多字符:

匹配5/5 符合预期
/^(?=.*\d)(?=.*[A-Z]).{8,}$/

应当匹配2/2

  • Hunter2024
  • Regex1Hunter

不应匹配3/3

  • hunter2024
  • SHORT1
  • NoDigitsHere
在沙盒里试试
开头的两个先行断言各自扫描整个字符串,检查一项要求。然后 .{8,} 检查长度。

从左往右读:从开头起,前方某处有一个数字;从同一个开头起,前方某处有一个大写字母;然后匹配 8 个或更多字符直到结尾。顺序无关紧要,这正是先行断言胜过把每种排列都写出来的原因。

05后行断言:检查前面是什么

后行断言是镜像。(?<=\$)\d+ 匹配紧跟在美元符号后面的数字,但不包括 $ 本身:

匹配3/3 符合预期
/(?<=\$)\d+/

应当匹配2/2

  • $42
  • price: $7

不应匹配1/1

  • 42 apples
在沙盒里试试
匹配到的只有数字。$ 是必需的,但它不在结果里。

而否定形式可以找出不是价格的数字:

匹配4/4 符合预期
/(?<!\$)\b\d+\b/

应当匹配2/2

  • 42 apples
  • year 2026

不应匹配2/2

  • costs $42
  • $100
在沙盒里试试
(?<!\$) 会拒绝任何紧跟在 $ 后面的数字。两边的 \b 防止它匹配到“$100”里面的“00”。

最后那条说明里藏着一个真正的陷阱。如果没有单词边界,(?<!\$)\d+ 会毫不犹豫地匹配 $100 里的 00,因为那两个 0 并不紧跟在 $ 后面。后行断言只检查匹配开始的位置,所以你常常需要 \b 或锚点来把这个位置钉住。

06环视在高尔夫中什么时候占优,什么时候不占优

在正则高尔夫里,每个字符都算数,所以值得知道环视什么时候能回本。

对于任意顺序出现的两个字母,普通的分支更短:a.*e|e.*a 是 9 个字符,而 (?=.*a).*e 是 10 个。但排列的数量增长得很快(两个字母 2 种,三个 6 种,四个 24 种),而每多一个先行断言只固定多花七个字符。下面这个棋盘里,每个怪物都以任意顺序包含一个 a、一个 e 和一个 i:

对比缩短 64%

棋盘certain · machine · relation · dialect · patience vs garden · pencil · plastic · money · violin

  1. 所有顺序能解出47
    /a.*e.*i|a.*i.*e|e.*a.*i|e.*i.*a|i.*a.*e|i.*e.*a/
  2. 先行断言能解出17
    /(?=.*a)(?=.*e).*i/
试试短的那个
把六种顺序都写出来要 47 个字符。两个先行断言加上最后的 .*i 只要 17 个。

先行断言版本之所以可行,是因为正则搜索会依次尝试每个位置。在第一个位置上,两个先行断言都会扫描整个单词,所以 a 和 e 在哪里都无所谓。

07轮到你了

同样的思路,新的棋盘。每个怪物都至少有一个数字和至少一个大写字母,顺序不限:

可玩狩猎标准杆 15

抓住同时含有数字和大写字母的单词,顺序不限。

目标 · 抓住 0/5

  • Hunt3r目标
  • R2d2目标
  • x9Y目标
  • Agent47目标
  • 7Seas目标

无辜词 · 误抓 0

  • hunter无辜
  • r2d2无辜
  • AGENT无辜
  • 1999无辜
  • x9y无辜

输入正则——棋盘会实时更新。

在沙盒里试试
喜欢吗?每天都有新的狩猎。玩今天的狩猎

08使用前需要知道的事

  • 环视是零宽度的。 单独的 (?=\d) 匹配的是数字前面的一个空字符串。这在插入文本时很方便(比如千位分隔符),但如果你本想匹配数字本身,就会让人困惑。
  • 支持程度不一。 JavaScript(所有现代浏览器和 Node)、Python、Java、.NET 和 PCRE 都支持先行断言和后行断言。Python 内置的 re 模块只允许固定宽度的后行断言,所以 (?<=\$\d*) 在那里会报错,而 JavaScript 接受它。Go 的标准 regexp 包完全不支持环视。
  • 普通正则清晰时,优先用普通正则。 q[^u]|q$ 同样可行,有些读者觉得它更好理解。当环视能让意图一目了然,或者能省掉真正的复杂度时,再用它。

09继续练习

  • 正则指南有一份速查,附带更多可交互示例。
  • 环视常出现在较难的每日棋盘上。今天的狩猎说不定就需要一个。