前阵子帮某开发者排查一个数据抽取脚本的 bug,正则表达式写得有模有样,可换了一批生产数据之后,结果总是把最后一个字段吃掉。我盯着末尾的$看了两秒,回头问他:“你这段文本结尾是不是带有换行符?”他跑了个repr()一看,果然。这种“正则看起来没问题”的瞬间,我这十年里见过太多次了。
正则表达式这东西,教程看三遍都觉得自己会了,真正写业务代码的时候才知道,坑全藏在细节里。所以我这篇不打算从头讲语法,只做“拾遗”——把我这些年用 Pythonre模块时踩过的、补过的、后来拿出来反复用的零碎知识点,挑最值钱的整理一遍。不管你是刚入门想少踩坑,还是写了两三年回头查漏补缺,这篇应该都能给你一些东西。
1. 锚点与字符类:看着认识,用起来全是坑
1.1$的迷惑行为:它真的只匹配“末尾”吗?
很多人对锚点的理解停留在“^开头、$结尾”这个层面,但 Pythonre模块里$的行为比你想的要多一个“例外”。
$默认匹配两个位置:字符串的末尾,以及末尾换行符之前的位置。也就是说,"hello world\n"这个字符串,用re.search(r'world$', text)是能匹配上的,因为world后面紧跟的\n恰好落在$允许的位置。如果你要的是“绝对末尾”,就得用\Z。
import re text = "hello world\n" print(re.search(r'world$', text)) # <re.Match object; span=(6, 11), match='world'> print(re.search(r'world\Z', text)) # None我第一次撞上这个坑,是在做日志清洗的时候。线上日志每行末尾都带着\r\n,我用re.findall(r'(\w+)$', line)提取最后一列,怎么测都对,一上真实数据就各种空匹配。后来才意识到,借助$的特殊行为,我其实一直匹配的是“换行符前的内容”,在某些日志里最后一列恰好是空字符串,提取结果自然就错了。
再补充一个容易混淆的点:re.MULTILINE(re.M)模式下,^和$会变成“每行的开头/结尾”,但\A和\Z始终只认整个字符串的绝对开头/绝对结尾。如果你的校验逻辑要求“整段文本必须严格匹配”,用\A...\Z永远比^...$更稳。
1.2 字符类里的转义规则,和外面完全不一样
正则表达式在字符类[...]内部有一套独立的转义规则,很多人直接从字符类外边的习惯搬过来,结果写出来的正则既丑陋又容易出 bug。
关键规则有几条:
.、*、+、?、(、)在字符类内部都是普通字面量,不需要转义。]需要转义,或者放在字符类的开头。-在字符类中间表示范围;如果你要匹配字面量连字符,把它放在开头或结尾。^在字符类开头表示取反,其他位置是普通字符。\b在字符类内部不是单词边界,而是退格符。
看几个实际例子:
re.findall(r'[.]', 'a.b') # ['.'] re.findall(r'[.]{2}', 'a.b') # [] re.findall(r'[a-]', 'a-b') # ['a', '-'] re.findall(r'[a\-b]', 'a-b') # ['a', '-', 'b']有个经典需求是匹配“数字、点、下划线组成的文件名”,有人会写成[\w\.]+,这在功能上没错,但那个\.其实是多余的。写成[\w.]+就够了。
更隐蔽的坑在范围表达式上。[a-z]没问题,但[a-z-]就分成两种情况:如果连字符在末尾,它是字面量;如果在中间,它会被当成范围符号。而[a-_]这种写法在某些实现里会直接报错,Python 会抛出 `` 的错误提示。所以我的习惯是:连字符一律放开头或末尾。
1.3\w不等于[a-zA-Z0-9_]:中文文本的意外匹配
如果你是从 Python 2 或者其他语言转过来的,很容易带着“\w只匹配英文字母、数字、下划线”的刻板印象。但 Python 3 的re模块默认开启 Unicode 匹配,\w、\d、\s、\b这些转义符都是Unicode 感知的。
直接说结论:\w+会匹配中文、日文、韩文、阿拉伯文等大量 Unicode 单词字符。
re.findall(r'\w+', 'hello世界') # ['hello世界']这在处理国际化文本时会带来非常隐晦的问题。比如你在做英文 tokenizer,用\w+提取单词,结果中文语料里跑出一大堆“单词”。解决办法有两个:一是写正则时用re.ASCII标志位,二是明确写[a-zA-Z0-9_]这类 ASCII 字符类。
re.findall(r'\w+', 'hello世界', flags=re.ASCII) # ['hello']还有一个连带坑:\b单词边界同样是 Unicode 感知的。在中文文本里,每个汉字都被认为是一个“单词字符”,所以\b的行为会跟你预期的不一样。要处理“英文单词边界”又不想被中文干扰,最稳妥的办法是配合re.ASCII,或者干脆自己用字符类定义“单词”的构成。
2. 匹配入口与取值方式:API 差异里藏着不少坑
2.1 match、search、fullmatch:只有 fullmatch 是“完全匹配”
这三个函数的关系,是我在带新人时几乎必讲的话题。很多人以为re.match是“完全匹配”,这个误解害人不浅。
re.match(pattern, string):从字符串开头尝试匹配,但匹配成功后不要求后续内容耗尽。"abc123"用re.match(r'\d+', ...)是None,因为开头是字母;但re.match(r'abc', "abc123")能匹配到"abc",它压根不管后面还有"123"。re.search(pattern, string):扫描整个字符串,返回第一个匹配位置。re.fullmatch(pattern, string):要求整个字符串从头到尾都匹配,这才是真正意义上的“完全匹配”。
re.match(r'\d+', 'abc123') # None re.search(r'\d+', 'abc123') # <re.Match object; span=(3, 6), match='123'> re.fullmatch(r'\d+', 'abc123') # None re.fullmatch(r'\d+', '123') # <re.Match object; span=(0, 3), match='123'>实际业务里校验手机号、邮箱、版本号这种“整字符串必须合法”的需求,请直接用fullmatch,或者写成\A...\Z。用search而不加锚点,相当于在玩“只要包含数字就通过”的游戏,线上迟早出事。
需要额外留意的是match与^的关系。re.match与re.search(r'^...')行为类似,但在MULTILINE模式下^会匹配每行开头,而match始终只看整个字符串的开头。所以“从开头开始匹配”和“匹配行首”是两回事,别混了。
2.2 findall 与 finditer:一个悄悄改返回类型,一个省内存
re.findall是很多人最常用的“找所有”工具,但它的返回结果类型有一个容易踩的坑:只要正则里出现捕获分组,返回的就不再是字符串列表,而是元组列表。
re.findall(r'\w+@\w+', 'a@b c@d') # ['a@b', 'c@d'] re.findall(r'(\w+)@(\w+)', 'a@b c@d') # [('a', 'b'), ('c', 'd')] re.findall(r'(\w+)@\w+', 'a@b c@d') # ['a', 'c'] ?不,是 ['a', 'c'...]第三个例子很多人一看就懵:我只写了一个捕获组,结果返回列表里存储的是第一个分组(\w+)的内容,也就是'a'和'c',整个匹配的"a@b"、"c@d"反而不见了。这是因为findall在有捕获组时优先返回捕获组的内容,整个匹配被忽略。
如果你只是想把整个匹配结果都拿出来,又不想被分组干扰,有两个选择:
- 把不需要的分组改成非捕获组
(?:...) - 用
finditer,它会返回一个迭代器,每个元素都是 Match 对象,可以随时取完整的匹配文本、位置、分组
for m in re.finditer(r'(\w+)@(\w+)', 'a@b c@d'): print(m.group(0)) # 整个匹配 print(m.group(1), m.group(2)) # 分组finditer的另一个优势是省内存。处理几百 MB 的日志文本时,findall会把所有结果一次性堆进内存,finditer是惰性的,边扫描边产出,配合for循环非常舒服。我在做日志脱敏、超大文本 token 化时,基本只用finditer。
2.3 命名分组与 groupdict():让正则从“裸匹配”走向“结构化”
正则写多了你会发现,依赖group(1)、group(2)这种数字索引去取值,过一个月再看代码根本想不起来group(3)到底对应什么字段。命名分组(?P<name>...)就是来治这个的。
m = re.search(r'(?P<year>\d{4})-(?P<month>\d{2})-(?P<day>\d{2})', '2025-06-18') print(m.groupdict()) # {'year': '2025', 'month': '06', 'day': '18'}groupdict()一次性返回所有命名分组组成的字典,配合**可以非常优雅地填充结构化对象。这在解析日志、命令行参数、URL query 时都是利器。
但有几个细节值得单独记住。
groupdict()只包含命名分组,普通捕获组不会出现在字典里。- 如果某个命名分组没有参与匹配,它的值会是
None,而不是抛异常。 groups()返回所有分组(含普通分组、含None),group(0)永远是整个匹配。- 后向引用命名分组用
(?P=name),在替换文本里用\g<name>,而不是\1这种容易歧义的写法。
re.sub(r'(?P<word>\w+)', r'[\g<word>]', 'hello world') # '[hello] [world]'还有一个容易被忽略的场景:(?P<name>...)与(?:...)的区别。命名分组本质是捕获组,会占用分组编号;非捕获组纯粹为了分组逻辑,不产生捕获。能用(?:...)就别用普通(...),能减少内存,也少踩findall返回元组的坑。
3. 回溯灾难重现:嵌套量词的性能失控现场
3.1 回溯是怎么发生的:电话接线员的转接实验
正则引擎匹配文本的方式,可以类比成电话接线员查分机号。接线员拿到一个号码段,先尝试按第一条线路接过去;发现不通,退回上一个转接点,再试第二条线路。每次“退回重试”就是一次回溯。
对大多数正常正则来说,回溯次数有限,性能完全可接受。但一旦模式里出现嵌套量词,每个字符都可能成为转接点,而每个转接点又有多种选择路径,组合数量就会爆炸性增长。这就是灾难性回溯(Catastrophic Backtracking)的根源。
3.2 经典现场实操:给 25 个字符都够你喝一壶
最经典的灾难正则长这样:(a+)+$。它想表达“一堆 a 组成的字符串”,但多套了一层量词。
import re import time pattern = re.compile(r'(a+)+$') text = 'a' * 25 + '!' # 只比正常情况多一个不匹配的字符 start = time.time() print(pattern.search(text)) # None print(f"耗时: {time.time() - start:.4f} 秒")25 个a加上一个!,这段代码可能就要跑好几秒。如果你把a的个数加到 30 个以上,普通机器上直接卡到怀疑人生。因为引擎要尝试a+把 25 个字符拆成 1+24、2+23、3+22……每一种拆分都得到底验证一遍,而这个拆分数目是指数级别的。
嵌套量词的常见形态包括:
(a+)+(a*)*(a|a)+(\d+)*
它们的共同特征是:同一个字符可以被外层量词和内层量词反复“瓜分”,产生大量等价路径。
这里必须明确一点:Python 标准库re模块没有执行超时机制。一段灾难正则一旦跑起来,会一直拖着整个进程,线上一个请求就能把 CPU 打满。所以在写正则时就要有“防灾难”意识:能用普通量词解决的问题,绝不嵌套。
修复这类模式的核心思路是“拆掉一层量词”。(a+)+$改成a+$,(\d+)*改成\d*,语义基本不变,性能直接飞起。
3.3 Python re 没有原子组,但我们可以“曲线救国”
很多从 PCRE、Java 过来的朋友习惯了原子组(?>)和占有量词a*+。它们的作用是:一旦某个部分匹配成功,就不再交回已占用的字符,从而切断大量回溯路径。但是 Python 标准库re模块两个都不支持。
替代方案有三个,按优先级来说:
第一优先:重构模式,消灭嵌套量词。
这是最根本也是我实际项目里用得最多的方案。回溯灾难几乎都来自“无意义的多余选择”,减少选择即可。
第二优先:用前瞻模拟原子组。
你可以用(?=(a+))\1来模拟a+的原子效果。原理是:前瞻(?=(a+))只负责“看”,它把匹配结果存进分组\1,但不消耗文本;随后\1再去消费同样一段文本。因为前瞻匹配的内容是确定的,反向引用阶段不会把已匹配的字符再交出去重新分配,回溯路径就被切断了。
# 模拟原子组,匹配 ab 或 aab pattern = re.compile(r'(?=(a+))\1ab') print(pattern.search('aab')) # <re.Match object; span=(0, 3), match='aab'>这个技巧不是万金油,嵌套复杂模式时本身也容易写晕,但它确实是我在标准库限制下压测通过的一个可靠手段。
第三优先:上第三方 regex 模块。
第三方regex模块原生支持(?>)原子组和占有量词,还支持模糊匹配、嵌套集合等高级特性。如果项目允许引入依赖,遇到标准库改写起来特别别扭的灾难正则,换regex模块是最省事的。代价是第三方模块在部分平台编译安装会多一些步骤。
3.4 定位回溯热点:先把正则拆碎,再逐个试
遇到线上正则性能问题时,不要凭感觉改。我的排查套路是“二分定位”:
- 把正则拆成两半,分别测试耗时。
- 找出耗时高的那半,继续拆。
- 对命中的局部模式,替换成等价但更简单的写法再看耗时。
比如http://(?:[a-z]+\.)*[a-z]+/.*慢,就先试http://部分,再试后面域名部分,最后发现是.*在长 URL 上的回溯问题。此时要么加锚点,要么把.*改成[^/]*这种限制性更强的字符类。我一直强调:正则里能不用.就尽量不用.,.*是回溯大户。能用排除字符类表达的,别偷懒。
4. 项目里真正好用的 re 小技巧:sub 回调、split 分组与 DEBUG
4.1re.sub接一个函数,替换逻辑瞬间“活”了
re.sub的第二个参数repl除了可以是字符串,也可以是一个函数。这个函数接收 Match 对象,返回替换后的字符串。每匹配一处,函数会被调用一次。
这个特性在需要“根据匹配内容动态决定替换结果”的场景里非常好用。举一个我去年在日志脱敏脚本里写过的例子:把 IP 地址中间两段打码。
import re def mask_ip(m): ip = m.group(0) parts = ip.split('.') return f"{parts[0]}.***.***.{parts[3]}" text = "用户 192.168.10.23 登录,来源 10.0.0.1" print(re.sub(r'\b\d{1,3}(?:\.\d{1,3}){3}\b', mask_ip, text)) # 用户 192.***.***.23 登录,来源 10.***.***.1如果你写的是纯字符串替换,想做到这种“动态打码”就得用多次finditer配合字符串拼接,代码又长又容易写错。换成回调函数,整个逻辑缩进到一个函数里,清晰太多了。
再举一个:从 Markdown 文本里提取引用块,转成 HTML<blockquote>。纯字符串替换根本做不到,因为需要动态判断每行内容。
import re def to_blockquote(m): content = m.group(1).strip() return f"<blockquote>{content}</blockquote>" md = "> 这是一条引用" print(re.sub(r'>\s*(.+)', to_blockquote, md)) # <blockquote>这是一条引用</blockquote>注意一个细节:回调函数里想引用分组,直接用m.group(n)或m.group('name'),不能用\1这种语法。如果你已经写好了带\1的替换模板,可以调用m.expand(r'\1 ...')来复用模板逻辑。
4.2re.split的分组陷阱:想保留分隔符?这一节讲透
re.split也有一个让人迷惑的分组行为:当你用捕获组把分隔符包起来时,分隔符本身也会出现在结果列表里。
import re re.split(r'(\d+)', 'a1b22c') # ['a', '1', 'b', '22', 'c']如果你不想要分隔符出现在结果里,正解是把捕获组改成非捕获组(?:...):
re.split(r'(?:\d+)', 'a1b22c') # ['a', 'b', 'c']反过来,如果你确实想保留分隔符又不想它变成独立元素,就得换个思路,用findall或者先 split 再合并。比如要切分key1=value1;key2=value2这种字符串,还想保留“分号分隔符”信息供后续拼接,可以直接这样:
parts = re.split(r'([;])', 'a=1;b=2;c=3') # ['a=1', ';', 'b=2', ';', 'c=3']然后用''.join(parts)就能原样还原文本。这个技巧在“按关键词切分但保留原文结构”的场景里很好用,比如做语法高亮预处理。
4.3 compile 缓存、flags 组合与 DEBUG 调试利器
很多人不知道,re模块对最近 512 个不同模式是有内置缓存的。你直接写re.findall(pattern, text),内部也会走缓存。所以“必须 compile 才能性能好”这个说法并不完全准确。
真正的性能隐患是:如果你在循环里动态拼接、动态生成正则字符串,缓存就会被不断刷掉,每次都得重新解析编译。这种情况应该把 compile 提到循环外:
# 不推荐 for line in lines: m = re.search(fr'\b{keyword}\b', line) # 每次都可能重新编译 # 推荐 pattern = re.compile(r'\b' + re.escape(keyword) + r'\b') for line in lines: m = pattern.search(line)这里必须提醒re.escape()。拼接用户输入作为正则的一部分时,一定要先转义,否则用户随手一个.就能让你的“精确匹配”变成“匹配任意字符”。Python 3.7 之后re.escape只转义真正有特殊含义的字符,返回的结果也比以前干净得多。
flags 组合也是日常容易含糊的地方。常用的有:
re.I:忽略大小写re.M:多行模式,让^/$匹配每行re.S:让.匹配换行符re.X:详细模式,允许在正则里写注释和空白re.A:让\w、\d、\s、\b按 ASCII 处理re.DEBUG:打印正则编译后的内部指令
re.X是我个人最偏爱的 flag,它能让你把一团乱麻的正则写成带缩进、带注释的“迷你 DSL”。举个例子:
phone_pattern = re.compile(r''' (?P<area>\d{3}) # 区号 [-\s]? # 分隔符,可以是短横线或空格 \d{3} # 中间三位 [-\s]? \d{4} # 末尾四位 ''', re.X) m = phone_pattern.search('(010) 123-4567') # 当然,这个正则没考虑括号,效果一般,但结构清晰别小看这个习惯。到了维护阶段,别人看你的正则能不能一眼读懂,直接决定了他会不会半夜打电话问你。有人觉得re.X写起来啰嗦,但“能维护的正则”比“秀技的正则”值钱得多。
re.DEBUG则是我排查诡异匹配行为时的最后底牌。它会把编译结果打印成类似汇编的东西,让你看到ATOMIC、BRANCH、MAX_REPEAT这些内部结构。普通业务开发不用天天盯着它看,但如果你怀疑一个正则“为什么这里匹配了、那里没匹配”,跑一次 DEBUG 往往能发现是某个分支压根没走进去。
4.4 两个高性价比的零散技巧:.与换行的适配,以及起始差异的兜底
re.S(也叫re.DOTALL)和re.M是最容易被弄混的两个 flag:re.S管的是“.能不能匹配换行符”,re.M管的是“^/$是否匹配每行首尾”。两者互不影响,但可以同时使用。很多人在抓取网页正文时发现.匹配不到跨行内容,就是因为没加re.S。正确姿势是re.compile(r'<div>(.*?)</div>', re.S),这里的.*?才能跨行匹配。
还有一个实际开发中经常出现的差异化需求:要判断“某字符串是否以某个前缀开头”,match和startswith结果一致,但如果你还要同时提取前缀后面的部分,match更有优势。反过来,如果你只关心“是否包含”,search是更好的选择。这些 API 选型问题,我希望大家花三十分钟读一下官方文档的re模块总览,比我在这里列一百条笔记都管用。
最后一个我说过很多次的小技巧:凡是超过 20 个字符的业务正则,一律用re.X+ 命名分组写,并且加一行注释说明来源和边界。别担心可读性被人嘲笑,能维护的正则才是好正则。遇到性能瓶颈,先按第二节的方法拆开测,再决定要不要上“前瞻模拟原子组”这种骚操作。
这些经验都是我踩坑踩出来的。正则简单到可以五分钟上手,又复杂到值得用几年去填坑。希望这篇“拾遗”能帮你少走几步弯路。