正则这玩意儿,属于那种“看着像乱码,用起来真香”的技能。我刚工作那会儿,看到别人写一长串 ^([a-z0-9_\.-]+)@([\da-z\.-]+)\.([a-z\.]{2,6})$ 去校验邮箱,第一反应是“这什么东西”,第二反应是“背下来再说”。结果真到自己上手处理日志、写爬虫、做表单校验的时候才发现,正则不是靠背的,是靠理解它的匹配逻辑的。这篇文章我尽量把正则拆开揉碎,从最底层的匹配原理讲到实战里的坑,再给几个直接能抄的模板,目标是让看完的人能自己写出、也能自己读懂正则,而不是到处复制粘贴然后祈祷它能跑。
这篇内容适合谁?刚接触正则、被各种符号劝退的新人;写过一阵子正则但老是匹配不到想要内容的初级开发者;以及想系统梳理一遍、顺带看看不同语言里正则有啥坑的人。
1. 从零理解正则的匹配逻辑:它就是一个“扫描器”
正则表达式本质上是一套描述文本模式的规则,正则引擎拿着这套规则去目标字符串里扫描,看哪些位置能“对上号”。很多人学不会正则,是因为一上来就背元字符表,背完就忘。我先带你把引擎的工作方式搞清楚。
1.1 正则引擎按字符逐个尝试,不是整体匹配
想象一下你在一段文章里找“cat”这个词,你不会直接跳到某个位置说“这里有”,而是一个字符一个字符地看:先看当前位置是不是 c,如果是再看下一个是不是 a,再看下一个是不是 t,全对了才算匹配成功。正则引擎就是这么干的。
比如文本是 "I have a cat.",正则 cat 的匹配过程是:
- 引擎从文本位置 0 开始,先比较
c和I,不匹配,位置前进到 1。 - 继续比较
c和空格,不匹配,位置前进到 2。 - 一直到位置 7(字符 c),
c对上了,接着看下一个字符 a,对上了,再看下一个字符 t,对上了,匹配成功。
这个“逐字符推进、失败就跳到下一个位置重试”的机制,是理解后面一切高级写法的地基。你后面遇到的贪婪匹配、回溯、性能问题,全都是从这个机制里长出来的。
注意:正则默认是“只要在某个位置匹配上就返回结果”,所以如果你用
cat去匹配"The cat and the catalog",它返回的是第一个出现的 cat,而不是你脑子里觉得“应该匹配整个单词”的那个 cat。这就是为什么后面要学\b这种边界符。
1.2 元字符是怎么“偷懒”的:. ^ $ 的本质
正则里真正强大的是元字符,因为它们能让一个字符位对上“多种可能”。但很多新手会在这里犯迷糊,比如以为 . 能匹配“任意字符”就包括换行。实际上在大多数正则引擎里,默认情况下 . 匹配的是“除换行符以外的任意字符”。
.:匹配除换行外的任意单个字符。a.c能匹配abc、a1c、a c,但不能匹配ac(中间必须有字符),也不能匹配跨行的a\nc。^:匹配字符串的开始位置。^abc只在字符串开头是 abc 时匹配。$:匹配字符串的结束位置。abc$只在字符串结尾是 abc 时匹配。
有个常见的坑是,很多人以为 ^ 和 $ 是“匹配字符”,其实它们是“匹配位置”。位置不算字符,所以 ^ 匹配到的结果是长度为 0 的空白。这个概念在后面学断言(lookahead/lookbehind)的时候会再次用到。
^ 和 $ 在换行模式下(多行模式)行为会变,比如在 Python 的 re.MULTILINE 模式下,^ 还能匹配每一行的开头,而不仅仅是整个字符串的开头。这个差异在不同语言里都存在,写跨平台脚本的时候要特别小心。
1.3 字符类 []:让一个位置匹配一组字符中的任意一个
[abc] 表示这个位置可以是 a、b、c 中的任意一个。[a-z] 表示可以是任意小写字母。[0-9] 表示任意数字。这里面有几个容易踩的坑:
第一,[] 里的 ^ 表示“取反”。[^0-9] 匹配任意不是数字的字符。注意这个取反是“一个字符位”的取反,不是说“整段不能是数字”。
第二,[] 里的特殊字符大多不需要转义。比如 [.] 就是匹配一个字面上的点号,不用写成 [\.]。但在 [] 外面,. 就是通配符,要匹配字面点号必须写成 \.。这个规则经常让刚从别的语言转过来的人懵。
第三,[] 里的 - 有两种含义。在 [a-z] 这种位置它就是范围连接符;如果放在 [] 开头或结尾,比如 [-abc] 或 [abc-],它就是一个普通字符,表示匹配 -、a、b、c 中的任意一个。想匹配字面 - 的建议放在开头或结尾,省得转义出问题。
字符类是对“一个位置”的描述,所以 [abc][0-9] 表示的是“先一个字母,再一个数字”,总共占两个字符位,而不是“一个由字母和数字组成的东西”。这个理解偏差是很多新手写正则匹配不到内容的根源。
1.4 预定义字符类:\d \w \s 的快捷方式与隐藏问题
\d 是 [0-9] 的简写,\w 是 [A-Za-z0-9_] 的简写(不同语言可能有差异,比如 Python 3 的 \w 默认还匹配 Unicode 汉字),\s 是空白符的简写,包括空格、制表符、换行等。
这几个简写看起来很省事,但坑也在“简写”两个字上。比如你写 \d+ 想匹配数字,在 JavaScript 里没问题,但在 Python 里默认也能匹配 Unicode 数字(比如阿拉伯文的数字字符),这可能导致意外匹配。反过来,有些老系统用 \w 想匹配“字母数字下划线”,结果它把汉字也算进去了,日志过滤的时候就会出错。
如果你要写一个跨语言、跨平台都稳定的正则,最稳妥的做法是:明确指定字符范围。比如只想匹配 ASCII 数字,就写 [0-9],不要写 \d;只想匹配 ASCII 字母,就写 [A-Za-z],不要写 \w。当然,如果你明确要匹配 Unicode 字符,那 \w 反而方便。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 量词:控制“重复次数”的核心,以及贪婪匹配带来的麻烦
单个字符类解决了“这个位置能放什么”的问题,但实际场景里你经常需要“若干个数字”“连续 4 到 8 个字母”这种需求,这就是量词出场的时候。
2.1 量词语法与常见误区:* + ? {n,m}
a*:a 出现 0 次或多次,也就是“有没有都行,有就尽量多匹配”。a+:a 出现 1 次或多次,至少得有一个。a?:a 出现 0 次或 1 次,也就是“可选”。a{3}:正好 3 次。a{3,}:至少 3 次。a{3,5}:3 到 5 次。
这里最容易出 bug 的是 * 和 ? 的“0 次也能匹配”特性。比如你想匹配 "color" 和 "colour" 两种写法,写 colou?r 是对的。但如果你不小心写了 colo*ur,那它匹配的是 col 后面跟任意个 o,最后必须是 ur,比如 colur、coloor 都能匹配上,这就不是你想要的了。
很多新手把 * 理解为“任意字符”,然后写 .* 来匹配“任意一段内容”。.* 确实能匹配任意一段(除换行外)内容,但它的行为是贪婪的,这个下面展开说。
2.2 贪婪与懒惰:为什么 .* 总是匹配到你不想娶的那个
默认情况下,量词都是贪婪的,也就是引擎会“尽量多吃”字符。拿经典例子来说,文本是 <b>加粗</b>和<i>斜体</i>,你用正则 <.*> 去匹配,结果不是两个标签,而是从第一个 < 一直吃到最后一个 >,即 <b>加粗</b>和<i>斜体</i> 这一整段。
原因很简单:* 贪婪地吞掉了所有字符,直到文本末尾,发现末尾不是 >,然后开始一个个往回吐字符(这个过程叫回溯),每吐一个检查一下当前位置是不是 >,结果它回溯到最后一个 > 的位置才停下。所以最终匹配到的是“最长”的那一段。
解决方法是把贪婪改成懒惰:在量词后面加一个 ?,写成 <.*?>。懒惰模式下,引擎每吃一个字符就会尝试一下是否能结束匹配。于是它匹配到第一个 > 就停了,结果是 <b>,然后继续匹配后面的 <i> 等。
这里我强烈建议你用实际工具跑一下这个例子,亲眼看看差异。我见过很多在工作中写了三五年正则的人,遇到 HTML 提取还会栽在贪婪匹配上。
2.3 回溯机制:贪婪带来的不只是匹配结果问题,还有性能问题
回溯是正则引擎的核心机制,但它也是性能灾难的源头。拿 (a+)+b 去匹配 aaaaaaaac 来说,(a+)+ 这个嵌套量词会让引擎反复尝试各种分组方式:先让外层吃 1 个 a、再让内层吃 8 个 a;然后外层吃 2 个 a、内层吃 7 个 a……当最终发现后面没有 b 的时候,所有组合方式都要试一遍,字符串越长,尝试次数呈指数级增长。
这个问题在业内叫“灾难性回溯”(catastrophic backtracking)。很多正则拒绝服务攻击(ReDoS)利用的就是这个漏洞。后面我会专门用一节来讲怎么识别和避免这种写法。
3. 分组、断言与反向引用:从“能匹配”走向“会提取”
到这里,你已经能用正则判断“字符串长什么样”了。但实际开发里,你不只要判断,还要从匹配结果里“抠”出具体数据——比如从日志里提取 IP、从文本里抽出电话号码。这就需要用分组和断言了。
3.1 分组 ():捕获组、非捕获组与嵌套结构
() 有两个作用:一是把多个字符打包成一个整体,方便用量词控制;二是把匹配到的内容单独存下来,供后续提取或引用。
比如 (ab)+ 可以匹配 ab、abab、ababab,但 ab+ 只能匹配 a 后面跟一堆 b。这个区别很多人一开始会忽略。
用 Python 举个例子:
python复制import re
m = re.search(r"(\d{4})-(\d{2})-(\d{2})", "今天是2025-03-15")
print(m.group(1)) # 2025
print(m.group(2)) # 03
print(m.group(3)) # 15
这里的 (\d{4})、(\d{2}) 就是捕获组,分别对应年月日。捕获组是按左括号出现的顺序编号的,从 1 开始,group(0) 是整个匹配。
有些时候你只是想把字符打包来控制重复次数,并不需要单独提取,这时候再用捕获组就有点浪费,而且还会把编号搞乱。更好的选择是用非捕获组 (?:):
python复制re.search(r"(?:\d{3}-)?\d{8}", "电话010-12345678")
这里 (?:\d{3}-)? 表示“开头的三位区号和连字符整体可选”,但我并不需要单独拿到区号,所以用非捕获组。用非捕获组还有一个好处是性能略好,因为引擎不需要额外记录捕获内容。
3.2 断言:为什么说它是“看位置而不吃字符”的高级能力
断言是一个位置匹配条件,它不像普通字符那样“吃掉”字符,而是要求“当前位置的左边或右边满足某个条件”。最常用的四个:
(?=...)正向前瞻:当前位置后面必须能匹配...。\d(?=元)匹配“后面跟着‘元’字的那个数字”。(?!...)负向前瞻:当前位置后面不能匹配...。\d(?!元)匹配“后面不是‘元’字的那个数字”。(?<=...)正向后顾:当前位置前面必须能匹配...。(?<=¥)\d+匹配“¥符号后面的数字串”。(?<!...)负向后顾:当前位置前面不能匹配...。(?<!¥)\d+匹配“前面不是¥符号的数字串”。
断言好用在“只看不改”。比如你要从一堆价格文本里找到数字,但不要那些跟在“元/斤”后面的数字,用断言就能精准控制匹配位置,而不会把多余字符包含进匹配结果。
需要注意,JavaScript 在某些老版本里不支持后顾断言(lookbehind)。我在 Node.js 上遇到过这种兼容性坑——本地开发环境一切正常,部署到老版本 Node 就报语法错误。如果你负责的项目要跑在旧环境,写断言之前最好查一下兼容性。
3.3 反向引用:把“之前匹配到的内容”再引用一次
反向引用用 \1、\2 这种形式,表示“这里必须和前面第 n 个捕获组匹配到的内容完全相同”。
一个很经典的场景是匹配连续重复的词,比如把 "the the" 这种错误揪出来。正则 \b(\w+)\s+\1\b 会匹配一个单词,然后空白,然后同一个单词再次出现。这里 \1 就是前面 (\w+) 捕获到的那个具体单词。
反向引用在字符串去重、数据清洗里也很有用。比如把 "aaa bbb ccc" 里三个相同字母的单词改成单个字母,可以这样:
python复制import re
text = "aaa bbb ccc ddd"
result = re.sub(r"\b(\w)\1{2}\b", r"\1", text)
print(result) # a b c d
这个正则的思路是:先捕获一个字符 (\w),然后 \1{2} 表示这个字符再重复两次,也就是三个相同的字符,最后用 \1 把这个单词替换成单个字符。
提示:反向引用在替换操作里也经常用。Python 的
re.sub的替换串里写\1、\2引用分组,很多人一开始容易搞混匹配串和替换串的转义规则,Python 里建议替换串用\g<1>这种写法,可读性更好。
3.4 原子组与占有量词:有些引擎里的性能救星
部分正则引擎(如 PCRE、Java、.NET)支持原子组 (?>...) 和占有量词 *+、++、?+。它们的作用是:一旦分组内匹配完成,就“锁死”这部分,不再回溯到里面去。
占有量词 a*+ 的意思跟 a* 一样,是贪婪匹配,但匹配到最长之后,引擎不会往回吐字符。这在你知道“吃进去的就不用吐出来”的场景下能大幅提升性能,也能避免灾难性回溯。
Python 的 re 模块不支持原子组(需要用 regex 第三方库),Java 默认支持。C# 的 Regex 也支持。写跨语言正则的时候,用到这些特性前一定要确认目标语言支持。
4. 不同开发环境下的正则差异:Python、Java、grep、Delphi 逐个说
知乎上常有人问“为什么同一个正则在我电脑上能跑,同事那儿就不行”,答案往往是你们用的语言或工具版本不一样。“正则表达式”听起来像一门通用语言,但实现细节千差万别。这里结合热搜词提到的几个环境具体说说。
4.1 Python 的 re 模块:建议永远用原始字符串
Python 里写正则,我第一条建议就是:字符串前面加 r。
python复制pattern = r"\d+"
不加 r 的话,\d 在普通字符串里会被 Python 解释成普通的 d(因为 \d 不是合法转义序列,Python 会保留原样但发出警告),而 \n 这种会被真的转义成换行符。等到你把正则放进字符串里,这些转义就全乱了。
Python re 模块里有几个常用函数:
re.search(pattern, string):在字符串里查找第一个匹配的位置,返回 Match 对象或 None。re.match(pattern, string):从字符串开头尝试匹配,注意是“开头”,不是“整个字符串”。re.fullmatch(pattern, string):要求整个字符串完全匹配,做校验类业务时最常用。re.findall(pattern, string):返回所有匹配结果。re.sub(pattern, repl, string):替换。re.split(pattern, string):按匹配位置切分字符串。
一个经典坑是 re.match 和 re.fullmatch 的区别。很多刚从其他语言转 Python 容易误以为 match 就是“整个匹配”,实际它只从开头匹配,匹配完前面一段就算成功。比如 re.match(r"\d+", "123abc") 是能匹配到 123 的,但如果你要校验整个字符串是不是纯数字,必须用 fullmatch(r"\d+", "123abc") 才能得到“不匹配”的正确答案。
4.2 Java 的 Pattern 与 Matcher:matches() 是整体匹配,find() 是扫描匹配
Java 里写正则要用 java.util.regex 包。核心是三个类:Pattern(编译后的正则)、Matcher(执行匹配的操作对象)、PatternSyntaxException(语法错误异常)。
java复制import java.util.regex.Pattern;
import java.util.regex.Matcher;
Pattern pattern = Pattern.compile("\\d+");
Matcher matcher = pattern.matcher("123abc456");
// matches() 要求整个字符串整体匹配,所以返回 false
System.out.println(matcher.matches());
// find() 扫描子串,能依次找到 123 和 456
while (matcher.find()) {
System.out.println(matcher.group());
}
Java 的坑是字符串转义。在 Java 字符串里写一个正则 \d 必须写成 "\\d",也就是说你在正则里看到的 \d,写进 Java 源码要加一层反斜杠转义。这会困扰很多新手,但只要记住“Java 字符串转义一层,正则解析一层”就明白了。
4.3 grep 命令:基础正则与扩展正则,别搞混了
在终端里用正则最频繁的应该是 grep。grep 默认使用“基础正则表达式”(BRE),基础正则里 +、?、(、)、{、} 这些字符不当作元字符,而是字面字符。也就是说 grep "a+" file.txt 找的不是“一个或多个 a”,而是文本里真的有字符 a+。
想要用更熟悉的写法,要加上 -E 参数启用“扩展正则表达式”(ERE):
bash复制grep -E "^[0-9]+$" file.txt # 匹配纯数字行
除此之外 grep 还支持 -P 参数启用 PCRE(Perl 兼容正则),PCRE 更接近你在编程语言里写的正则,支持断言等高级特性。但注意,不是所有版本的 grep 都编译了 PCRE 支持,有些嵌入式 Linux 环境里 -P 不可用。我刚做运维那会儿在旧 CentOS 上跑脚本,grep -P 直接报错,后来都改成 -E 加变通写法。
4.4 Delphi 与 C#:老平台也别乱写,看引擎再定边界
热搜词里出现了 Delphi,这是不少老项目还在用的语言。Delphi 的正则支持主要靠第三方库,最常见的是 TPerlRegEx(基于 PCRE),也有用 RegExp 组件的。
Delphi 转义规则跟 Java 类似,字符串字面量里对反斜杠有自己的处理,写正则要多加一层留意。比如匹配数字,正则本身是 \d+,但在 Delphi 字符串常量里可能写成 '\\d+'。
C# 的正则使用 System.Text.RegularExpressions.Regex 类,默认大小写敏感,可以用 RegexOptions.IgnoreCase 改。C# 还有一个很有用的特性是Regex.Match 返回的 Match 对象里有 Success、Value、Groups 等属性,提取数据非常直观。写 C# 正则时,@ 前缀字符串可以避免转义地狱:
csharp复制Regex regex = new Regex(@"\d+");
Match match = regex.Match("abc123");
if (match.Success) {
Console.WriteLine(match.Value); // 123
}
这里 @"..." 是 C# 的逐字字符串,里面的 \d 不会被 C# 转义,直接传给正则引擎,写法上跟 Python 的 r 字符串类似。
4.5 一份快速对照表
| 环境 | 匹配数字写法 | 整体匹配判断 | 大小写不敏感标志 | 注意点 |
|---|---|---|---|---|
| Python | r"\d+" |
fullmatch |
re.IGNORECASE |
建议用原始字符串 |
| Java | "\\d+" |
matches() |
Pattern.CASE_INSENSITIVE |
字符串要多一层转义 |
| C# | @"\d+" |
Regex.IsMatch 默认全匹配可配合 \A\z |
RegexOptions.IgnoreCase |
用逐字字符串省心 |
| grep | grep -E "[0-9]+" |
行首 ^ 行尾 $ |
grep -i |
记得加 -E 用扩展正则 |
| Delphi | '\d+' |
各库不同,建议看 TPerlRegEx 文档 |
各库不同 | 转义规则取决于字符串类型 |
5. 实战模板:IP 校验、纯数字校验、邮箱匹配等经典场景拆解
这一节我从热搜词里挑几个最常被搜索的场景:判断字符串是否 IP 地址、校验纯数字、以及几个日常高频的匹配需求,把正则写出来,并且拆解为什么这么写。
5.1 IPv4 地址校验:最经典的“分段匹配 + 边界控制”
IPv4 地址由四段组成,每段是 0 到 255 的数字,段与段之间用点号分隔。很多人一看到“校验 IP”就开始写 \d+\.\d+\.\d+\.\d+,这个写完只对了四分之一,因为它允许 999.999.999.999 这种非法地址通过。
正确的做法是把“0-255”这个范围用正则精确表达:
code复制25[0-5] # 250-255
2[0-4][0-9] # 200-249
1[0-9][0-9] # 100-199
[1-9]?[0-9] # 0-99(包含个位和十位)
把上面四个分支用 | 组合,加上单词边界 \b,再重复四次:
code复制^((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])$
这个正则的匹配逻辑是:前三段是 (分支)\.,表示“一个 0-255 的数字后面跟一个点”,重复三次;最后一段是“一个 0-255 的数字”,不再跟点。用 ^ 和 $ 锁死整串,避免 192.168.1.1.999 这种多一段的也通过。
实际用的时候,我建议把它封装成函数,并且先做一次简单的长度判断(IP 最短 7 位 0.0.0.0,最长 15 位 255.255.255.255),长度不在范围内的直接返回 false,能节省不少正则匹配时间。
5.2 纯数字校验:全是数字就够了吗?要看需求
热搜词里“java 校验纯数字 正则表达式”说明很多人被这个需求坑过。纯数字校验关键看你对“数字”的定义:
- 只允许非负整数:
^[0-9]+$。 - 可能有前导正负号:
^[+-]?[0-9]+$。 - 允许小数:
^[+-]?[0-9]+(\.[0-9]+)?$。 - 允许科学计数法:
^[+-]?[0-9]+(\.[0-9]+)?[eE][+-]?[0-9]+$。
一个很多人忽略的细节是前导零。^[0-9]+$ 会让 007 通过校验。如果业务上不允许前导零(比如身份证号、编号类字段),要专门写 ^(0|[1-9][0-9]*)$,意思是“要么是 0,要么是 1-9 开头后面跟任意数字”。
用 Java 写的时候,样例代码如下:
java复制boolean isPureNumber(String s) {
return s != null && s.matches("^[0-9]+$");
}
Java 里 String.matches() 内部调用就是 Pattern.matches(),要求整个字符串完全匹配,所以不需要额外写 ^ 和 $。但加了也没坏处,可读性更好。
5.3 手机号、邮箱、URL:网上抄的正则为什么老是出错
手机号和邮箱正则在网上有一堆版本,但你直接抄下来用,往往会发现“该拦的拦不住,该放的又拦了”。
以手机号为例,中国大陆手机号目前的规则大致是 1[3-9]\d{9}。但如果你在系统里写死这个规则,几年后虚拟运营商新号段出来就可能误伤。我见过一个项目因为正则把 19 开头的新号段拦了,客服电话被打爆。
邮箱正则更是重灾区。一个常见版本是 ^[\w\.-]+@[\w\.-]+\.\w+$,它能拦住大多数非法输入,但处理不了 user+tag@example.com 这种带加号的合法地址,也处理不了国际化域名。
我的建议是:不要追求一个完美到覆盖所有边界的正则。对于邮箱,先用一个基础正则做格式初筛,真正的有效性交给“发送验证邮件”这种业务逻辑去判断。正则擅长的是“快速拦截明显错误”,不是“判断邮件真的能收到”。
5.4 日志数据提取:分组捕获的实际用法
处理服务器日志是我日常用到正则最多的场景。典型的 Nginx 访问日志一行长这样:
code复制192.168.1.10 - - [15/Mar/2025:10:30:22 +0800] "GET /api/users HTTP/1.1" 200 1234
我想把 IP、时间、请求路径、状态码分别提取出来,可以这样写(以 Python 为例):
python复制import re
log_line = '192.168.1.10 - - [15/Mar/2025:10:30:22 +0800] "GET /api/users HTTP/1.1" 200 1234'
pattern = r'(\d+\.\d+\.\d+\.\d+) .*?\[(.*?)\] "(.*?)" (\d+) (\d+)'
m = re.search(pattern, log_line)
if m:
print("IP:", m.group(1))
print("时间:", m.group(2))
print("请求:", m.group(3))
print("状态码:", m.group(4))
print("字节数:", m.group(5))
这里用到了三个关键写法:(\d+\.\d+\.\d+\.\d+) 提取 IP;.*? 懒惰匹配跳过中间内容;\[(.*?)\] 提取方括号里的时间。分组让“匹配”和“提取”一次完成,是处理结构化文本的最高效方式。
提示:日志解析这类场景,建议先用
re.findall拿全部匹配,再一条条处理,比re.search循环效率高。遇到超大日志文件,可以配合迭代器逐行读取,避免一次性把文件塞进内存。
6. 性能陷阱与回溯灾难:正式接管正则前必须知道的事
正则写出来容易,跑起来慢也是真的。我见过不少生产事故是“一个正则拖垮了整个服务”,症状是 CPU 飙到 100%,请求全部卡住。这一节聊聊怎么提前发现和规避。
6.1 灾难性回溯:嵌套量词是头号元凶
前面提到 (a+)+b 这种嵌套量词会导致指数级回溯。这里我展开说说原理。
当引擎尝试匹配 aaaaaaaaaa...ac(一个很长的 a 串,最后是 c),(a+)+ 会尝试把 a 串切分成各种组合:(1个a)重复10次、(2个a)重复5次、(1个a)+(2个a)+...,以此类推。每切分一次,都要再检查后面是不是 b。最终所有切分都失败,才能得出“不匹配”的结论。切分方式的数量随 a 的个数呈指数增长。
这种问题在现实里常见于:
(.*)*这种“匹配任意内容的任意重复”(a|aa)+这种有歧义的交替和量词嵌套(.+)+配合长输入
6.2 怎么识别和避免性能陷阱
给你三个实用的检查点:
第一,看有没有“一个量词里面套量词”的写法。(a+)+、(.*)*、(.+)* 都是高危模式,能改写就改写,比如 (a+)+ 很多时候直接写成 a+ 就够了。
第二,警惕交替分支之间的重叠。(a|aa)+ 里 a 和 aa 有重叠,引擎会尝试各种组合。如果不需要分别捕获,尽量把重叠分支合并,或者用非捕获组降低复杂度。
第三,给正则加上锚定。能用 ^ 和 $ 就用,让引擎快速失败。比如校验用户输入只能用 ^[a-z]+$,比 [a-z]+ 在这种场景下更高效,因为前者一旦在开头发现非法字符就能立刻返回 false。
还有一类实用技巧是“提前过滤”。如果只是判断字符串长度超过 100 就非法,先做长度判断再跑正则,能省掉绝大多数无用匹配。
6.3 正则不是万能的:什么情况下别用正则
正则擅长的是“匹配有规律、结构相对简单的文本”,但有两个场景我强烈建议你别用正则:
第一,解析 HTML/XML。HTML 的嵌套结构、属性顺序、注释内容会让正则写得极其复杂,而且永远有边界测试过不了。Python 里解析 HTML 用 BeautifulSoup 或 lxml,Java 里用 Jsoup,都比正则靠谱一个数量级。
第二,解析 JSON。JSON 的嵌套结构同样不适合正则处理,直接 json.loads 把整体加载成对象再操作,简单且安全。
遇到这两个场景还硬写正则的人,不是技术不够,是没吃过亏。我早年用正则从 HTML 里抓数据,改了一整天边界条件,第二天需求变了,全白写。后来换成解析器,十分钟搞定,还更稳。
6.4 在线工具推荐:写正则的时候别靠脑子硬算
写复杂正则,我不建议全靠脑子推算,熟练工也难免出错。推荐几个我常用的工具:
- Regex101:支持 Python、Java、JavaScript、PCRE 等多种引擎,左侧实时显示匹配结果和高亮,还有解释面板告诉你每一段正则的含义。我最常用的是它的“Regex Debugger”功能,能看到逐步回溯过程,排查性能问题利器。
- RegExr:界面清爽,适合快速测试 JavaScript 风格的正则,有常用表达式库可以直接借鉴。
- 本地跑:如果你在命令行环境,
grep -P配合快速文件测试也很方便,比如echo "test123" | grep -P "\d+"一秒验证结果。
用在线工具时有一点要注意:选对语言引擎。同一个正则,在 Python 的 re 模块和 JavaScript 里可能行为不同,在 PCRE 里支持的语法在 POSIX 环境里可能完全不支持。工具的默认引擎尽量和你实际运行环境保持一致。
7. 一路踩坑过来的经验:送给新手的五个建议
文章最后,我不打算做什么总结了,分享几个这几年写正则的真实体会,对新手可能比“看语法清单”更有用。
第一,先从“测试”倒推着学。找几个真实需求(校验手机号、提取日志、替换脏词),打开 Regex101 一步步试,比从第一个元字符背到最后一个的学习效率高得多。任何语法,只要想不起,随手查一下就行,不需要背。
第二,写正则的时候,第一版一律写得“明确、啰嗦”,之后再去缩。比如先写 [0-9],跑通了再决定要不要换成 \d;先写 [A-Za-z],确定 Unicode 字符不会产生副作用了再考虑用 \w。明确写法的好处是出了 bug 容易定位。
第三,正则里加注释。像 Perl 和 Python 的 re.VERBOSE 模式都允许在正则里写注释和空白,这在维护长正则时帮助极大。我见过一个 200 字的 IP 校验正则,没有注释,三个月后我自己都看不懂为什么这么写,后来统一养成了分段加注释的习惯。
第四,写完测试用例再上线。正则的边界情况极多,至少要准备这几类测试:正常通过、完全非法、半合法(比如 IP 第三段超范围)、超长输入、空字符串、Unicode 字符。跑完这些再部署到生产,省得用户帮你测试。
第五,能不用正则就不用正则。字符串对象自带的 startswith、endswith、contains 方法很多场景比正则快得多,而且可读性更好。比如判断一个字符串是不是 http:// 开头,直接 startswith("http://") 就够了,写 ^http:// 属于杀鸡用牛刀。正则的性能开销和可维护性成本都比字符串方法高,只有模式匹配确实复杂时才值得动用。
正则这个东西,入门的门槛其实不在符号记忆,而在“理解引擎是怎么一个字符一个字符去尝试的”。一旦理解了匹配、回溯、贪婪、分组这几件事,剩下的语法都是查表就能解决的问题。希望这篇能帮你把最核心的几块骨头啃下来,剩下的,靠多写多练自然就熟了。
