AI 技术博客
返回首页
Python 基础 19 分钟阅读

re 模块实战:match/search/findall/sub 全解

Pythonre 模块正则表达式文本处理匹配

引言

前两篇把正则的语法体系搭完了:第 4 篇是元字符与字符类(匹配哪些),第 5 篇是分组、捕获与 flags(提取哪部分)。语法是刀,本篇是刀法——re 模块的函数级 API:match、search、fullmatch 三种「找」,findall、finditer 两种「批量找」,sub、subn 两种「换」,split 的「切」,加上 escape 的「护」。这些都是工程里每天要用的接口,理解它们的细微差别(match 锚定开头而 search 任意位置、findall 遇捕获组返回组内容、sub 替换串里的 \1 引用)比记住语法更重要。

本篇是第五章的第 6 篇,前承语法两篇,后接第 7 篇格式化与第 8、9 篇的日志解析实战。读完本篇,你应当能独立完成:「从一条访问日志里抽出全部 IP 并按出现次数排序」「把配置文件里的 key=value 批量改写」这类任务。所有行为均以本机 Python 3.12 实测为准。

概念与原理

模块级函数与编译对象:一次编译,处处复用

re 的每个模块级函数(findall、search、sub……)都可以拆成两步:先把模式字符串编译成内部字节码(实现上相当于一个「编译」步骤),再在目标文本上执行。为摊薄成本,re 内部维护了一个 LRU 缓存(默认容量 512 个模式,re._MAXCACHE 可查):同一个模式字符串再次出现时直接命中缓存。因此「同一个模式反复用」的场景下,模块级函数的开销大多被缓存抹平;而「模式每次都不一样」的场景(动态拼模式、批量处理多种格式),缓存不断换血,编译开销就真实地暴露出来。re.compile 显式返回 Pattern 对象,把「编译」提前到循环之外,语义上也更好——模式+flags 打包成一个可命名的值,可以传入函数、集中管理、提前发现语法错误。什么时候该用 compile,本篇末尾用本机实测数据给出结论。

match 与 search:锚定开头 vs 任意位置

re.match(pattern, string) 只从字符串的开头尝试匹配——等价于在模式前面加了 ^ 的语义;re.search(pattern, string) 则扫描整个字符串,在任意位置找到第一处匹配。这是 re 模块里最容易混淆的一对:match 不是「包含匹配」的别名,而是「开头对齐」。实测 re.match(r'\d+', 'abc 123') 返回 None(开头是字母),而 re.search(r'\d+', 'abc 123') 找到 ‘123’。re.fullmatch(pattern, string) 要求整个字符串恰好匹配,多一个字符都不行——注意它比「match+$」更严格:实测 re.fullmatch(r'\d{2}', '20\n') 返回 None,因为尾部换行也算「额外的字符」;而 $ 锚定允许结尾一个换行。三者的使用场景:校验输入格式用 fullmatch(如校验一个字段是不是恰好 4 位数字)、判断「是否以某模式开头」用 match、找任意位置的模式用 search。初学者最常见的错是用 match 找「包含」,得到 None 后怀疑正则有 bug——先检查是不是该用 search。

Match 对象:匹配结果是个「富对象」

match/search/fullmatch 返回的是 Match 对象而不是布尔值。它的常用成员:group(0) 是整个匹配、group(n) 是第 n 组、groups() 是所有组内容的元组、groupdict() 是命名组的字典;start()、end()、span() 给出匹配在原文中的位置。位置信息是「提取」的关键——它让你知道匹配出现在哪里,而不是只拿到内容。注意 groups() 中未参与匹配的可选组是 None(第 5 篇实测过),取值前判空是惯例。

findall 与 finditer:两种批量提取

re.findall 一次返回所有匹配,但返回值的形式由模式里的捕获组决定,这是整个 re 模块最容易踩的坑:模式不含括号时返回字符串列表(每个匹配的整体);含一个捕获组时返回该组内容的列表(整个匹配被丢掉);含多个捕获组时返回「元组」列表(每组一项)。实测三种形态见操作与实现。re.finditer 返回迭代器,逐个产出 Match 对象——当匹配数量很大(几万条日志)时,finditer 不一次性把结果全部装进内存;需要拿位置信息(span)或组信息时,finditer 也比 findall 顺手。判断标准:只要「整个匹配或位置」,用 finditer 最稳。

sub 与 subn:替换的两副面孔

re.sub(pattern, repl, string, count=0) 把匹配到的每处替换为 repl,返回新字符串;repl 可以是字符串或函数。替换串里反斜杠有特殊含义:\1、\2 引用捕获组内容(命名组用 \g<name>),要用原始字符串或注意转义,否则 \1 可能被解释成其他转义序列。当替换逻辑复杂(大小写转换、数字运算、查表)时,repl 传函数:函数接收 Match 对象、返回替换文本,每匹配一处调用一次——实测里把数字替换成 <数字> 就是函数版。re.subn 与 sub 相同,但返回 (新字符串, 替换次数) 元组,想知道「改了几处」时用它。

split 与 escape:「切」与「护」

re.split(pattern, string, maxsplit=0) 按模式切割字符串,比 str.split 强大在分隔符本身可以是模式(如「逗号或分号后跟任意空白」)。但有一个隐藏行为:模式里含捕获组时,分隔符会被保留在结果里——实测 re.split(r'([,;])', 'a,b;c') 返回 ['a', ',', 'b', ';', 'c'],分隔符占住了自己的位置。想丢掉分隔符就不要加括号。re.escape 在第 4 篇讲过是「字面量保护」,本篇把它用在真实场景:把用户提供的文件名转义后编译成模式再搜索,元字符全部失效、只认字面文本。

性能:预编译到底快多少(本机实测)

「预编译更快」流传很广,但它快在哪、快多少,值得实测而不是人云亦云。本机(Python 3.12.10)两组对比,每组取 5 轮重复的最小值压噪:场景一,同一个模式 r'\d+' 在约 1200 字符的文本上跑 50 万次——模块级 findall 与预编译 Pattern 的耗时比 0.98,即在测量噪声内完全打平;场景二,500 个不同模式各用一次——耗时比 1.02,同样打平。原因在于 re 模块的编译结果本身走 LRU 缓存(默认容量 512,re._MAXCACHE 可查),模块级函数与预编译对象最终调用的是同一份编译产物,差别只剩一层薄薄的字典查找。结论:在 CPython 3.12 上,预编译几乎买不到速度,买的是工程质量——模式语法错误在加载期一次性暴露(实测 re.compile('(') 立即抛出 re.error「unterminated subpattern」),Pattern 对象可复用、可传参、可命名,flags 与模式打包成一份显式配置。数字依机器浮动,「两种场景均打平」的结论在本机多轮实测中稳定。

操作与实现

三兄弟:match、search、fullmatch

import re

line = 'status: 200 OK'
print(re.match(r'\d+', line))              # 开头是字母,匹配失败
print(re.search(r'\d+', line).group())     # 任意位置找第一处
m = re.fullmatch(r'status: \d+ OK', line)
print(m.group(0))                          # 整串恰好匹配
print(re.fullmatch(r'\d{2}', '20\n'))      # 尾部换行算额外字符
None
200
status: 200 OK
None

match 返回 None、search 找到 ‘200’、fullmatch 要求整串,「20\n」因为多了一个换行而被 fullmatch 拒绝——与 $ 允许结尾换行形成对比,校验用户输入时必须记得先 strip 或考虑这个差异。

findall 的三种返回形态

import re

print(re.findall(r'\d+', 'a1b22c333'))          # 无括号:整个匹配
print(re.findall(r'(\d+)', 'a1b22'))            # 一个括号:只返回组内容
print(re.findall(r'(\d+)-(\d+)', '12-34 56-78'))  # 两个括号:元组列表
['1', '22', '333']
['1', '22']
[('12', '34'), ('56', '78')]

三种形态各有用处,但「一个括号」形态最容易埋雷——你以为是整个匹配,拿到手的却是组内容。

finditer:逐个 Match,位置与内容兼得

import re

for m in re.finditer(r'\d+', 'a1b22c333'):
    print(m.group(), m.span())
1 (1, 2)
22 (3, 5)
333 (6, 9)

每行是「内容 + 在原文中的起止位置」。需要按位置切片原文、或匹配量巨大时,这是首选。

Match 对象全家桶

import re

m = re.search(r'(?P<year>\d{4})-(?P<month>\d{2})', '日志 2026-09-01 完成')
print(m.group(0))             # 整个匹配
print(m.group(1))             # 按编号
print(m.group('month'))       # 按名字
print(m.groups())             # 全部组内容
print(m.groupdict())          # 命名组字典
print(m.span())               # 位置
2026-09
2026
09
('2026', '09')
{'year': '2026', 'month': '09'}
(3, 10)

命名组 + groupdict 是解析日志字段的标准姿势:模式里提取,groupdict 一次收进字典,直接进后续的数据处理管线。

sub 与 subn:字符串替换、函数替换、计次

import re

print(re.sub(r'\d+', '#', 'a1b22c333'))               # 全部替换
print(re.sub(r'\d+', '#', 'a1b22c333', count=1))      # 只换第一处

def repl(m):
    return f'<{m.group()}>'
print(re.sub(r'(\d+)', repl, 'a1b22'))                # 函数替换

print(re.sub(r'(\d{4})-(\d{2})-(\d{2})', r'\3/\2/\1', '2026-09-01'))  # 组重排
print(re.subn(r'\d+', '#', 'a1b22c333'))              # 返回替换次数
a#b#c#
a#b22c333
a<1>b<22>
01/09/2026
('a#b#c#', 3)

count 限制替换次数;函数版 repl 每命中一处调用一次、返回替换文本;替换串里的 \3/\2/\1 把「年-月-日」重排成「日/月/年」——这是数据格式标准化的常用手法;subn 把次数一并告诉你。

split:切分与「捕获组保留分隔符」陷阱

import re

print(re.split(r'[,;]\s*', 'a, b;c,d'))    # 无括号:分隔符被丢弃
print(re.split(r'([,;])', 'a,b;c'))        # 有括号:分隔符被保留
['a', 'b', 'c', 'd']
['a', ',', 'b', ';', 'c']

第二行是「保留分隔符」的写法,常用来在切分的同时记录每个字段的分隔符类型(比如分析 CSV 变体)。不想要就删掉括号。

escape 实战:按用户提供的文件名搜索

import re

name = 'report.2026.v2'
pat = re.compile(re.escape(name))
line = '备份 report.2026.v2 已上传'
print(bool(pat.search(line)))
True

re.escape('report.2026.v2') 产出 'report\.2026\.v2',点号全部字面化——用户输入里有几个元字符都不会误伤。

预编译性能实测(本机数值,量级参考)

import re
import timeit

text = 'x=12;y=34;z=56;' * 100          # 约 1200 字符的待匹配文本
pat = re.compile(r'\d+')

# 场景一:同一个模式反复匹配 50 万次
t_inline = min(timeit.repeat(lambda: re.findall(r'\d+', text), number=50000, repeat=5))
t_compiled = min(timeit.repeat(lambda: pat.findall(text), number=50000, repeat=5))
print('场景一 compiled/inline 耗时比:', round(t_compiled / t_inline, 2))

# 场景二:500 个不同模式各用一次
def many_inline():
    for i in range(500):
        re.findall('item-%d' % i, 'text')

def many_compiled():
    pats = [re.compile('item-%d' % i) for i in range(500)]
    for p in pats:
        p.findall('text')

t_many_inline = min(timeit.repeat(many_inline, number=10, repeat=5))
t_many_compiled = min(timeit.repeat(many_compiled, number=10, repeat=5))
print('场景二 inline/compiled 耗时比:', round(t_many_inline / t_many_compiled, 2))

# 预编译的工程收益:语法错误在加载期暴露
try:
    re.compile('(')
except re.error as e:
    print('语法错误提前抛出:', type(e).__name__, e)
场景一 compiled/inline 耗时比: 0.98
场景二 inline/compiled 耗时比: 1.02
语法错误提前抛出: error missing ), unterminated subpattern at position 0

两个比值都贴近 1:预编译在本机两种场景下均未带来可观的提速(缓存兜底),而最后一行展示了它真正的价值——错误在加载期报出,而不是等到运行到某一行才炸。数值随机器浮动,结论稳定。

综合小例子:解析 key=value 配置段

把本篇的接口串成一个小工具——提取配置段里的全部键值对:

import re

cfg = 'host=db01;port=5432;user=admin'
pairs = re.findall(r'(\w+)=([^;]+)', cfg)
print(pairs)
print(dict(pairs))
[('host', 'db01'), ('port', '5432'), ('user', 'admin')]
{'host': 'db01', 'port': '5432', 'user': 'admin'}

(\w+)=([^;]+):键是词字符串、值是非分号字符串;findall 的多组形态直接产出元组列表,dict() 一步转成配置字典。

易错点与陷阱

1. match 不是「包含匹配」

re.match(r'\d+', 'abc 123') 实测返回 None——match 只在开头尝试。想「任意位置找」用 search;「判断整串是否为某格式」用 fullmatch。三兄弟的分工记牢,可以省掉大量「为什么匹配不到」的排查时间。

2. findall 带捕获组时返回「组内容」

实测 re.findall(r'(\d+)', 'a1b22') 返回 ['1', '22'] 而不是 ['a1b22'] 之类的整体。想要整个匹配:去掉括号,或改用 finditer 逐个取 group(0)。

3. sub 替换串里的反斜杠与函数返回值

替换串中 \1、\g<name> 是引用,不是字面反斜杠;想输出字面 \1 要写 \\1。函数版 repl 必须返回字符串(返回 None 会在运行时抛 TypeError),且参数是 Match 对象而不是字符串——def repl(m): return m.group(1).upper() 是标准形态。这两点的报错信息都不直观,容易绕圈。

4. split 的捕获组会保留分隔符

re.split(r'([,;])', 'a,b;c') 返回 ['a', ',', 'b', ';', 'c'](实测)——意外之喜也是意外之坑:以为分隔符被丢掉,结果数组里多了元素。按需决定要不要括号。

5. 「预编译更快」的迷信

本机实测推翻了两个流行说法——「同一个模式反复用必须预编译」与「模式多了预编译就快一个数量级」:两种场景的耗时比都是 1 左右(场景一 0.98、场景二 1.02),re 的 512 个缓存位把这层成本抹平了。但这不代表 compile 没用:它的真实收益在质量而非速度——加载期暴露语法错误(实测 re.compile(’(’) 立即抛 re.error)、Pattern 对象可复用与传参、flags 集中声明。工程上别用「性能」给 compile 编理由,要用「结构」给它找位置。

小结

本篇把 re 模块的函数级 API 全部过了一遍:match(开头锚定)、search(任意位置)、fullmatch(整串校验)三种找法;findall(按捕获组形态返回列表)与 finditer(逐个 Match、带位置)两种批量提取;sub(字符串/函数/组重排三种替换)与 subn(附带次数);split 的切分与捕获组保留行为;escape 的字面量保护;以及预编译的实测结论——本机两种场景下均与模块级函数打平,收益在工程质量而非速度。语法(第 4、5 篇)与 API(本篇)齐备后,第 7 篇回到字符串的格式化,第 8、9 篇将以 nginx 日志解析与文本清洗工具链把它们全部用起来。

练习与思考题

  1. 用 finditer 统计 'id=7;id=22;id=1001' 中所有 id 值及其出现位置,输出应为 7 (3, 4) 22 (8, 10) 1001 (14, 18)。
  2. 用 sub 把 '2026-09-01 12:30:45' 里的日期部分重排为 '01/09/2026'(提示:一个模式、三组括号,替换串用 \3/\2/\1)。
  3. 用 fullmatch 校验用户输入必须是「恰好 4 位数字的年份」,分别测试 '2026' 与 '2026\n',解释为什么后者不通过,以及工程上如何避免(提示:strip)。
  4. 思考题:根据本篇实测,在「解析 10 万行日志、每行用同一个模式取值」的场景里预编译是否必要?在「循环里为每行动态拼一个新模式」的场景里呢?各给出理由。