怎样匹配一段文本却不消费它?正则的前瞻与后顾
你的正则匹配上了,match[0] 里却少了你以为会有的那段?或者一条密码规则要同时满足好几个条件?根本上都是同一个念头:前瞻和后顾只检查文本、并不消费它。这篇把零宽断言到底怎么运作讲清楚,以及它顺手能做成的那几件事。
先看一个几乎人人第一次都会栽的小谜题。你想要 px 前面那个数字,于是写下 \d+(?=px),拿 12px 一跑,匹配到了 12。挺好。可你仔细看看结果——只有 12。你明明写进模式里的 px 压根没出现在结果里:没被消费、没被捕获,就是不见了。要是没料到这一手,你会觉得是正则悄悄把你自己写的模式给吃了一块。
它没有。(?=px) 是一个前瞻,而前瞻属于正则里一小类叫断言的东西——它们检查文本,却不拿走文本。就这一个念头,“只看不拿”,是全部诀窍。一旦它通了,前瞻和后顾就不再是你从别处抄来的别扭语法,而会变成手里最锋利的两样工具:把一个值提取出来、不带周围的标点;往一个精确的位置插入内容、又不打扰原文;以及一次性叠加好几条独立规则——这正是把多条密码要求塞进一个模式里的常见办法。
这是系列的第三篇。第一篇搭起了一台回溯引擎怎么在文本里行走的心智模型;第二篇用同一套模型解释了为什么有些模式会把 CPU 烧穿。这一篇继续靠着同一套模型,所以如果“引擎带着一个光标从左往右走”对你已经有意义,这篇会读得很快。
零宽:解开这一切的那一个念头
像第一篇那样想象引擎:一个光标坐在字符之间,一格一格往右挪。正则里几乎每一样东西都会消费——匹配一个或多个字符,把光标拖过它们。拿 \d+ 跑 12px,它匹配 1 再匹配 2,把光标停在 p 前面。这两个字符现在“花掉了”;模式接下来的部分,从它们结束的地方接着走。
断言做的是另一回事。它看一眼光标周围的文本,判个通过或不通过,然后把光标原封不动地留在原地。它匹配的是一个位置,不是字符——所以术语管它叫零宽。(?=px) 在光标处问一个是非题——“接下来两个字符是不是 px?”——答案为是时,它便告成功,却一格都没前进。那个 px 被查看了一眼,又原样交还;它从没被消费,所以永远不会落进 match[0]。那些幽灵般消失的字符从来就没真的丢过,只是从没被拿走而已。
让人安心的是:你几乎肯定早就在用零宽断言了,只是没这个词。^ 和 $ 匹配的不是字符——它们匹配的是“字符串开头/结尾那个位置”。\b 匹配的是“单词字符与非单词字符之间那道缝”。前瞻和后顾就是同一个把戏、只是门被敞开了:引擎自带的那几个固定位置,换成了你可以往条件里塞进任意子模式。(?=px) 不过是长大到能检查任何你想要之物的 \b。
四种断言
总共就四种,按两个维度分:往前看(光标之后的东西)还是往后看(光标之前的东西);断言那东西在(肯定)还是不在(否定)。
| 断言 | 名称 | 何时通过 |
|---|---|---|
(?=...) | 前瞻 | 紧接在后的内容匹配 ... |
(?!...) | 负前瞻 | 紧接在后的内容不匹配 ... |
(?<=...) | 后顾 | 紧挨在前的内容匹配 ... |
(?<!...) | 负后顾 | 紧挨在前的内容不匹配 ... |
四种各来一个例子,全是零宽——注意每一个里,被断言的那段都留在匹配之外:
\d+(?=px) "12px" → 匹配 12 (后面跟着 "px" 的一串数字)
foo(?!bar) "foobaz" → 匹配 foo (后面不是 "bar" 的 "foo";遇到 "foobar" 就失败)
(?<=\$)\d+ "$100" → 匹配 100 (前面是 "$" 的数字)
(?<!\$)\d+ "€100" → 匹配 100 (前面不是 "$" 的数字)
这否定的一对值得多读两遍,因为“那东西不在时才匹配”很容易判错。foo(?!bar) 不是“foo 后面跟着某个不是 bar 的东西”——它是“foo,而且在这个位置上 bar 并不开始”。遇到 foobar 它直接失败;而在字符串末尾遇到 foo 它反而成功,因为那儿根本没有东西能是 bar。“不在”也包括“什么都没有”。
想让这些不再抽象,把每一个都粘进正则测试工具看高亮。测试器只给真正被消费的那段上色——所以紧挨着的断言部分保持不高亮。这个反差——条件那段留着不上色,只有真正的匹配亮起来——就是你能得到的、关于“零宽”最清楚的一张图。
让零宽干活
有三件日常活儿直接从“只看不拿”里长出来——match、replace、split 各占一件——它们合起来,就是前瞻后顾值得学的理由。
提取一个值,不带它的分隔符
假设你要从 <a href="/products/12"> 里把链接目标提取出来。最直白的写法是一个捕获组:
href="([^"]*)"
能用,但整个匹配把 href=" 和结束的 " 都算进去了,你真正想要的值埋在第 1 组里——你得伸手去读 match[1]。前瞻后顾能让整个匹配恰好就是那个值:
(?<=href=")[^"]*(?=")
从左往右读:后顾断言“我前面紧挨着 href="”,前瞻断言“我后面紧挨着一个 "”,而中间唯一消费的部分 [^"]* 才抓走值本身。引号只是条件,不是内容,所以它们从不进入匹配。match[0] 回来就是 /products/12,干干净净,事后不用再切。(这正是第一篇拿来当靶子的同一段两条链接——那时我们用捕获组解决;前瞻后顾是那种“匹配本身就是答案”的版本。)
往一个位置插入,又不打扰它
这就是零宽近乎神奇的地方。经典任务:把 1234567 变成 1,234,567。你不想替换任何数字——你想往它们之间的缝里插入一个逗号。而一个零宽匹配,恰恰就是一道缝:
"1234567".replace(/\B(?=(\d{3})+(?!\d))/g, ",") // → "1,234,567"
这里没有任何东西消费掉一个字符。(?=(\d{3})+(?!\d)) 匹配的是每一个“右边正好是整数个三位数组、且后面再没有多余数字”的位置——也就是千分位逗号该待的地方。\B(一个非边界,\b 的反面)拦住逗号落到最开头。因为每个匹配都是空的,replace 什么也不删,只是往每道匹配到的缝里丢一个 ,。把这个模式粘进正则测试工具——由于零宽匹配没有东西可上色,证据不在高亮里、而在匹配列表里:每一处命中都列在它的位置上,长度标为 0——一个占着字符间隙、而不占任何字符的匹配。看着那个“长度 0”卡在两个数字之间,“匹配一个位置”就从一句话变成了你能指着看的东西。
切开,却不吃掉分隔符
split 通常会把它切的那个东西删掉——"a-b-c".split("-") 把破折号全扔了。但在一个零宽匹配上切,就没有东西可删,于是那个边界字符留在原地。这正是你怎么把 camelCase 拆成词、却一个字母都不丢:
"camelCase".split(/(?=[A-Z])/) // → ["camel", "Case"]
(?=[A-Z]) 匹配的是每个大写字母正前方那个空位置。split 在那儿切,但因为匹配什么都没消费,大写字母就跟着它后面那一块一起走。同一个零宽念头,换了第三个字符串方法——match 提取出一个值,replace 插入一个,split 在它们之间切开,三者谁都没碰不该碰的字符。
杀手锏:叠加前瞻做“与”
现在讲那个单凭它就值得学前瞻的用法。你要一条密码规则:至少八个字符,而且必须含一个数字、一个小写字母、一个大写字母——顺序不限。你试试用一个普通的、从左往右的模式去表达“某处有个数字且某处有个小写且某处有个大写,怎么排都行”,你会淹死,因为单趟扫描必须先认定某一种顺序。前瞻把这个难题化于无形:
^(?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{8,}$
这机制一旦看懂就很漂亮,而且是纯粹的零宽。^ 之后,光标停在位置 0。(?=.*\d) 往前扫——.* 一路跑到末尾,再回溯,直到在字符串某处找到一个数字——成功后它把光标重置回 0,因为它什么都没消费。然后 (?=.*[a-z]) 再跑一遍,还是从位置 0。再 (?=.*[A-Z]),又从 0。每个前瞻都是一道对整个字符串的独立是非题,而因为它们谁都不挪光标,便叠成一个逻辑与:“有一个数字,且有一个小写,且有一个大写”。三条全过之后,.{8,}$ 才终于真正消费——把长度卡住、一直跑到末尾。
由此落出两件让它无比实用的事。顺序无所谓——几个前瞻都从同一个位置起测,所以条件怎么排都行。而你增删一条规则,只需增删一个前瞻:用 (?!.*\s) 禁止空白,用 (?=.*[!@#$%^&*]) 要求一个符号。每一条都是关于整个字符串的、自成一体的断言,独立地一条条扣上去。
那这到底什么时候才划算?说实话,在应用代码里,三次分开的 .test() 通常读起来更清楚,你该用那个。叠加前瞻这种写法真正的用武之地,是你只被允许用一个模式、周围又没有代码可写的场合——一个 HTML 的 <input pattern="…"> 属性、一个 JSON Schema 的 pattern、一个配置文件里的校验规则,或者一个只收单个正则的框架。在那里,“一个表达式里塞好几条独立条件”就不是炫技——往往是唯一能把话说出来的办法。
有一个坑常咬人:. 默认不匹配换行,所以 .*\d 只看到第一个换行之前。如果你的输入可能跨行、而你想让规则管到整段,就加 s(dotall)标志,或者把 . 换成明确的 [\s\S]。(而 .{8,} 到底数的是什么,取决于标志:不加 u 时,每个 . 是一个 UTF-16 码元——由代理对拼成的 emoji 会算作两个;加了 u,. 匹配一个完整码点,那个 emoji 就算一个。两者都不是字素——也就是用户眼里的那一个字符——所以一个塞满 emoji 的密码,数出来可能和你预期的不一样。这正是第一篇讲过的那道 Unicode 皱褶。)
JavaScript 的特性与几个坑
前瞻在 JavaScript 里一直都有。后顾要年轻些——它到 ES2018 才登场,而且有一阵子是浏览器支持里最后一个到位的(Safari 姗姗来迟),所以在现代运行时里它随处可用,但要是你得伺候某个老古董,还是先瞄一眼。
JavaScript 格外慷慨的地方是变长后顾。有些引擎坚持后顾必须是定长的——你最可能撞上的是 Python 的 re——因为往回匹配时,知道确切要回看多远会简单得多。JavaScript 允许后顾是任意长度:(?<=\w+)、(?<=\d{1,3},)、(?<=<[a-z]+>) 全都合法。如果你在别的语言里撞过“后顾要求定长模式”的报错、还以为这是条通用规矩,JS 悄悄把它解除了。
不过有一处皱褶值得知道:JavaScript 匹配后顾是从右往左的,从光标往回走。多数时候你不会察觉,但后顾内部的一个量词或捕获组,会按那个反过来的方向去解——所以某个组捕到的东西,可能和你按镜像的前瞻、用从左往右的读法所预期的不一样。当一个带内部组的后顾让你意外时,原因通常就在这;去测它,别去猜。(这又回到第二篇的那句话:你跑在哪一类引擎上,决定了什么做得到。)
另外两件小事值得知道:
- 断言内部的捕获组照样捕获。
/(?<=(\d+))px/会把数字填进第 1 组,尽管后顾本身对match[0]一无贡献。有用,偶尔也让人意外。 - 零宽解锁了重叠匹配。 普通匹配会消费,所以两个匹配永远不能重叠。但把真正的活儿包进一个前瞻,每个匹配就都成了零宽——而因为全局迭代(
matchAll,或g标志)在遇到空匹配后会往前挪一格,前瞻里面的捕获组便能把每一个重叠的窗口逐一收下来:/(?=(\d{2}))/g跑1234,第 1 组依次给你12、23、34。想从一次matchAll里拿到重叠匹配,没有别的干净办法。
一个提醒:断言仍然是货真价实的正则
人容易把前瞻后顾当成一个轻飘飘的批注,但你塞进 (?=...) 里面的任何东西,照样跑在和别处一模一样的回溯规则下。断言本身不会额外带来指数级风险——密码检查里那几个 .* 前瞻各自都是一趟独立的线性扫描,所以那类模式一般没事。但你一旦把一个自重叠的重复嵌进一个断言,就等于把炸弹夹带进了一个条件里,它照样会像在明面上一样灾难。第二篇那套 ReDoS 规矩,不会停在前瞻的括号前。断言堆得多的模式,该像测别的模式一样去测——测试器的 ReDoS 检查也会看进它们里面。
给好奇的人一个精确的点。前瞻在求值的过程中,会像任何别的子模式一样内部回溯。但它一旦成功,引擎就当它落定了:如果模式后面某处失败了,引擎不会再退回这个前瞻里去换一种内部匹配,它里面某个组捕到的东西,也在成功那一刻被冻住。断言看一眼、拍板、往前走——这偶尔就是“你拿到的捕获”和“你以为会拿到的捕获”之间的差别。
什么时候该改用捕获组
前瞻后顾并不总是对的答案,一有需要就条件反射地上环视,本身就是个小毛病。
- 如果你只是要在代码里拿到那个值,捕获组通常更清楚。
href="([^"]*)"再读match[1],对接手的人来说比一个后顾夹前瞻的三明治更一目了然,而且它在每一个引擎里都能用,包括那些压根没有后顾的。把前瞻后顾留给你特别需要match[0]本身就是那个值的时候——一次用$&的replace、一个split,或者一个只把整段匹配交给你的工具或 API。 - 别拿这些去解析真实的 HTML。
href那个例子成立,是因为它是一小段受控的字符串。把正则对准任意标记——属性顺序随意、单双引号混用、零散空白、注释——它会不声不响地弄错。在浏览器里,去读 DOM(element.getAttribute("href"));在服务端,用一个真正的 HTML 解析器。正则是给你已经摸清的字符串的形状用的,不是给语法用的。
一份可以留着的速查
- 零宽就是全部念头。 断言测一个位置、把光标留在原地;它的内容只被查看、从不被消费,所以永远不出现在
match[0]里。 - 这四种:
(?=)/(?!)往前看;(?<=)/(?<!)往后看;带!的那两个在东西不在时通过——而“不在”也包括“那儿什么都没有”。 - 抠出一个值、不带分隔符:
(?<=开头)值(?=结尾),让整个匹配就是那个值本身。 - 在一个位置上动手、又不打扰它:
replace里一个零宽匹配只插入、不删除(千分位逗号);split里它切开、却不吃掉分隔符(拆camelCase)——因为什么都没消费,就什么都不丢。 - 叠加好几条独立规则: 把前瞻紧挨着
^摞起来;它们都从同一个位置起测、以“与”组合、顺序不限。 - JavaScript 允许后顾变长——别把你在别的引擎里知道的定长限制想当然。
- 断言里面的东西照样回溯——把 ReDoS 规矩记在心上,并去测它。
三篇文章,同一台机器。第一篇教的是那个会行走、会回溯的光标;第二篇讲的是它回溯得太多时会怎样;而前瞻后顾,说到底,不过是同一个光标被要求去看、却不迈步。锚点一直都在这么干。现在你可以把它对准任何东西了——而每一次的回报都是同一个安静的把戏:分清什么是条件、什么是内容,让匹配恰好、且仅仅,是你真正想要的那样东西。