如何写出并测试一个正则表达式(而不把自己逼疯)

理解正则表达式的第一件事是:它不是搜索。你在搜索框里打字,引擎找的是那个词。而你写正则时,你描述的是目标文本的形状,引擎找出所有符合这个形状的东西。

这个区别听着像学究气,其实不是。几乎所有的正则 bug,都来自“写了一个能匹配你心里那些例子的模式,同时也匹配了一堆你从没想过的字符串”。

贪婪陷阱

这是最常见的一个正则 bug,而修复只需要一个字符。

.* 是贪婪的——它尽可能多匹配,只在为了让模式剩余部分成立时才吐回来。.*? 是惰性的——它尽可能少匹配。

给定字符串 <b>bold</b> and <i>italic</i>:

  • <.*> 匹配 <b>bold</b> and <i>italic</i> —— 整串,因为贪婪会取走最后一个可用的 >。
  • <.*?> 匹配 <b> —— 惰性在第一个 > 就停下。

两者都不是“对的”。重点是你必须做决定,而默认是贪婪,不管你本意是不是。如果你曾提取一个 HTML 标签却拿回三个标签,原因就在这。

字符类,以及人们常搞错的转义

. 不匹配任何字符。 它匹配除换行以外的任何字符。依赖 . 跨行的模式会静默失败,解法是 s 标志(dotAll),它让 . 也匹配换行。

\d 比你想象的宽。 在带 u 标志的 JavaScript 里、以及多数现代引擎里,\d 匹配任何 Unicode 十进制数字——包括阿拉伯-印度数字和全角数字。如果你要的严格是 0-9,就写 [0-9]。

在字符类里,位置会改变含义。 在 [a-z] 里连字符表示范围。要匹配字面连字符,把它放在开头或结尾:[-a-z]。同理,] 必须转义,或者放在字符类最前面。

^ 有两个完全不同的意思。 在字符类里它表示取反:[^0-9] 是“任何不是数字的东西”。在类外,它锚定字符串开头。同一个符号,相反的职责。

锚点,以及多行模式带来的意外

^ 和 $ 默认锚定整个字符串的开头和结尾,而不是每一行。这就是为什么 ^\d{5}$ 这样的校验模式能正确拒绝 12345\n67890——校验单个值很好,扫描整篇文档就错了。

加上 m(multiline)标志,它们才开始按行边界匹配。这是困惑的一个真实来源,因为同一个模式会因为你容易忘记的一个标志而行为不同。

一个相关的坑:在很多引擎里,$ 匹配的是末尾换行符之前的位置,所以 ^\d{5}$ 可能接受 12345\n。如果这个差别重要,就把它写明确。

分组:捕获、命名、还是跳过

圆括号干三件不同的事,选对哪一个同时影响可读性和性能。

  • (...) 是捕获组。它记录匹配到的内容,并出现在匹配结果里。
  • (?:...) 是非捕获组。只分组、不记录——当你要对多个记号做选择或加量词、但不需要那个值时用它。
  • (?<name>...) 是命名组。它仍是捕获组,但按名字取值而不是按下标。

任何要长期维护的东西都优先用命名组。match.groups.year 能在模式前面插入一个新组后依然有效;match[3] 不能。

性能陷阱:灾难性回溯

这个不会给你错误答案,它会给你一个卡死的进程。

某些模式,作用在几乎匹配的输入上时,会让引擎尝试指数级的组合。经典形状是对重叠分支套嵌套量词:

(a+)+b

对着一个长长的 a 串、末尾没有 b,耗时与 2ⁿ 成正比。25 个字符足以卡死一个进程;40 个字符足以卡死很多年。

三道防线:

避免嵌套量词。 (a+)+ 几乎总能改写成 a+。

把匹配目标写具体。 用 \d+ 而不是 .*,搜索空间会大幅缩小。

用不匹配的输入测。 这正是测试工具存在的意义。一个在你本来想匹配的文本上瞬间跑完的模式,可能在你没想的文本上永远跑不完。

什么时候不该用正则

正则描述的是正则语言。有些东西不是正则的,硬塞进模式只会得到既不可维护、又其实是错的东西。

HTML。 嵌套标签无法用正则匹配,因为嵌套深度是无界的。用 HTML 解析器。Stack Overflow 上那个著名的回答之所以著名,是有原因的。

邮箱地址。 完整规范允许带引号的字符串、注释和 IP 字面量域名。你见过的每一个“完整”邮箱正则,要么是错的,要么没法读。宽松地校验形状,然后发一封确认邮件——那是唯一真正的验证。

JSON 和其他结构化数据。 和 HTML 同样的问题:嵌套。用解析器。

经验法则:如果你要匹配的东西能包含任意层数、嵌套的自身副本,正则就是错的工具。

正确地测试

测试工具不是用来确认你的模式能工作的,是用来找出它在哪儿不工作。

  1. 打开正则测试工具。
  2. 粘贴你的模式,选上你打算在生产里用的标志——测试时不带 g、上线时带着,是个常见错误。
  3. 用不该匹配的输入测。 这一步是大家跳过的,也正是能找出 bug 的那一步。
  4. 检查捕获组,不只是看有没有匹配。一个模式可以匹配正确、却捕获了错误的子串。
  5. 任何要进热路径的模式,拿一个长长的、不匹配的字符串测一遍,排除回溯问题。

所有计算都在你的浏览器里完成。这一点比听上去更重要:生产代码里的正则,经常包含内部主机名、参数名,或者专有格式的片段。

常见问题

为什么我的正则匹配得太多了? 几乎总是贪婪造成的。在量词后加 ?——.* 变成 .*?——让它一有机会就停,而不是等到最后。

. 和 [\s\S] 有什么区别? . 排除换行,除非设了 s 标志。[\s\S] 匹配包括换行在内的一切,是传统上不依赖标志表达这件事的写法。

什么是灾难性回溯? 嵌套量词导致的指数级搜索行为。模式是对的,但运行时间在“几乎匹配”的输入上爆炸。解法是去掉嵌套量词、把字符类写具体。

这是哪个正则流派? JavaScript(ECMAScript),因为匹配在浏览器里跑。大部分语法和 PCRE、Python 一致;支持后行断言和命名组。

能用正则解析 HTML 吗? 不能。嵌套结构的深度是无界的,超出了正则能描述的范围。用解析器。

怎么校验邮箱地址? 宽松地校验——看有没有 @、有没有点、长度是否合理——然后发一封确认邮件。一个完整实现邮箱规范的正则既没法读,也仍然不能证明那个地址存在。

本文提到的工具

← 全部指南