接手一段别人写的正则,比如 (?<=\$)\d+(?=\.\d{2})(?!\d),光盯着这堆括号和符号,根本不知道它要干嘛。正则之所以被叫”天书”,很大程度上是因为断言(assertion)——那些 (?= (?! (?<= (?<! 看起来像分组,行为却完全不同。这篇用 Regex Pro 的 AST 解释面板,把四种断言彻底拆开。
四种断言,两个维度就不混
别死记符号,记方向 + 正负两个维度:
| 写法 | 名称 | 含义 | 例子 |
|---|---|---|---|
(?=X) | 正向先行 | 右边必须是 X | \d+(?=元) 取”元”前的数字 |
(?!X) | 负向先行 | 右边不能是 X | \d+(?!元) 取后面不是”元”的数字 |
(?<=X) | 正向后行 | 左边必须是 X | (?<=\$)\d+ 取”$“后的数字 |
(?<!X) | 负向后行 | 左边不能是 X | (?<!\$)\d+ 取前面不是”$“的数字 |
助记:尖括号 < 指向左 = 看后方(lookbehind),没尖括号 = 看前方(lookahead);感叹号 ! = 否定。把任一条贴进 Regex Pro 开「解释」面板,AST 直接用中文写出”其后须为 / 其前不可为”,不用背。
回头看开头那条 (?<=\$)\d+(?=\.\d{2})(?!\d),解释面板会拆成:
(?<=\$) 其前须为:$ 符号
\d+ 一个或多个数字(整段匹配就是这部分)
(?=\.\d{2}) 其后须为:. 加两位数字
(?!\d) 其后不可为:数字
翻译过来:取”前面是 $、后面跟着 .两位小数、且这串数字到此为止”的整数部分——也就是从 $199.00 里干净地抠出 199。
关键本质:断言”不消耗字符”
普通匹配会”吃掉”字符、光标向前推进;断言只检查当前位置满不满足条件,查完光标原地不动——这就是”零宽(zero-width)”。
它带来一个普通分组做不到的能力:提取却不吞掉边界。
要从 价格$199.00 里取 199:
\$(\d+) 整段匹配 = $199(含美元号),还要再取捕获组
(?<=\$)\d+ 整段匹配 = 199($ 只被"看了一眼",没吃进结果)
再看千分位插入的经典写法:
Pattern: (?<=\d)(?=(\d{3})+$)
Replace: ,
它全靠断言定位”每三位之间的缝隙”——不消耗任何字符,所以能在数字中间反复命中、插入逗号。当你要”以某物为界、但不要这个界”时,断言是唯一干净的解法。
「解释 + 美化 + 匹配列表」三件套
读陌生正则的最快路径:
- 解释面板按缩进自上而下翻中文——先看层级深浅(超三层警惕回溯),重点认断言节点(它们决定”在哪能匹配”而非”匹配到什么”)。
- 🪞 美化把长正则摊成多行带注释,每个分组/断言各占一块。
- 匹配列表用真实样本验证——解释说”应该这样”,列表显示”实际这样”,两边对不上就是你某个节点理解错了。尤其验断言的零宽:作为边界的字符有没有被吃进整段匹配。
用断言的四个坑
- 变长后行:
(?<=ab)定长没问题,(?<=a+)变长后行很多引擎不支持(JS 现代引擎、.NET 支持,旧 Java、部分语言受限)。 - 断言里的捕获组:
(?=(\d+))里的组能捕获值,常被用来做重叠匹配技巧,读时要意识到。 - 负向断言的范围:
\d+(?!\d)的(?!\d)只管紧贴右边那一个位置,不是”整个后面都没数字”。 - 先行断言堆叠 = AND 条件:
(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}用多个先行断言表达”同时满足多条件”,每个(?=.*X)都从头扫一遍,条件多了有性能成本——可以用 ⏱ Bench 实测。
导出:断言是跨引擎差异最大的特性
搬到别的语言前,重点确认三件事:
- 后行可用性与变长:Python 标准
re只支持定长后行(变长要换第三方regex库);Go 的regexp(RE2)完全不支持先行/后行断言。 - 命名组语法:断言里含命名组时,Python 用
(?P<name>)/(?P=name),与 JS 的(?<name>)/\k<name>不同;⎘ Py 按钮改写定义,但断言逻辑你得自己核对。 - 整体降级:目标是 Go/RE2 时断言基本要拆掉重写——用”捕获组 + 代码切片”替代。先在 Regex Pro 里用断言把逻辑理清、确认匹配对,再到目标引擎复验。
原则:JS 里能跑的断言,到 Python / Go 不一定能跑。导出按钮负责语法转写,但”目标引擎支不支持这个特性”必须你自己确认。
断言是正则从”能用”到”会读会写”的分水岭。看懂它”不消耗字符、只验边界”的本质,再借 AST 解释面板把符号翻成中文,那些劝退人的”天书正则”就只是一串清楚的条件而已。