引言
上一篇的元字符解决了「匹配哪些」:一条模式把符合形状的子串找出来。但从工程角度看这还远远不够——日志里我们不仅要找到「时间戳在哪里」,还要把年、月、日、时、分、秒分别取出来做聚合;配置文件里不仅要匹配 key=value,还要把 key 和 value 拆开存进字典。这类「提取哪部分」的需求,靠的是本篇的主角:分组(group)与捕获(capture)。
本篇是第五章的第 5 篇,承接第 4 篇的元字符与字符类,为第 6 篇的 re 模块 API 全家桶备好语法弹药:分组的编号规则、命名分组 (?P
概念与原理
分组:把模式拆成可提取的单元
圆括号 () 在正则里做两件事:其一是「划分子表达式」——它改变运算符的优先级,把 |、量词的作用范围限制在括号内(上篇 c(a|o)t 已经见过);其二是「捕获」——把括号内匹配到的子串单独记录下来,匹配完成后可以通过编号或名字取回。捕获是分组价值的核心:正则不但回答「匹配了吗」,还能回答「每一部分分别是什么」。
分组编号的规则只有一条:按左括号从左到右出现的位置编号 1、2、3……,整个匹配是第 0 组。嵌套时同样按左括号顺序数,和括号的深度无关。实测例子:r'((\w+)-(\d+))' 匹配 'item-42' 时,第 1 组是整个 'item-42',第 2 组是 'item',第 3 组是 '42'——注意第 1 组是外层括号,先出现所以编号在前。
为什么需要「捕获」
捕获的工程价值有三:第一,提取——匹配日志行后直接拿到 IP、时间、状态码等字段,不用再对整体匹配结果做第二次字符串切片;第二,回溯引用——在模式内部引用「刚刚捕获的内容」,让模式的某一段必须与前面相同(如叠词、成对引号);第三,替换——在第 6 篇你会看到 sub 的替换文本里用 \1 引用捕获内容做重排。可以说,不会捕获的正则只能「找」,会捕获的正则才能「读」。
命名分组 (?P…)
编号捕获的弱点是可读性:m.group(3) 的「3」是什么,写的人和读的人都要数括号。命名分组用 (?P<名字>...) 给组起名,名字是 Python 标识符(P 必须大写,这是 Python 专属语法,其他语言惯用 (?m.group('名字') 按名取值,也能用 m.groupdict() 一次拿到「名字→内容」的字典——这在批量解析日志时可以直接喂给字典构建。命名不改变编号:命名组同样占用编号,m.groups() 依然按顺序返回全部内容。
非捕获组 (?:…)
有时我们只需要「成组」而不需要「捕获」:比如 (?:\d{4})-(\d{1,2}) 里,年份组只是用来规整结构、不需要单独取值。这时用非捕获组 (?:...):它和 (...) 的匹配行为完全一致,但不计入编号、不进 groups()。实测 re.match(r'(?:ab)+', 'ababab').groups() 返回空元组。什么时候用?一是纯粹为分组或运算优先级而写括号时,避免污染编号(编号一乱,\1 式的回溯引用就容易指错);二是解析性能敏感的大文件场景,少捕获一个组就少一份记录开销。它不会「更快」到肉眼可辨,但语义更干净。
回溯引用:\1 与 (?P=name)
回溯引用(backreference)是捕获的「复用」:在模式中写 \1、\2,表示「此处必须是第 1、2 组刚刚捕获到的内容」。原理是引擎在匹配过程中把捕获内容记在临时槽里,遇到 \1 时把槽里的内容当作字面量继续比对。模式 r'(\w+) \1' 匹配 'na na hey hey hi',得到 ['na', 'hey']——每组都是「一个词、空格、同一个词」:再写一遍的 ‘hey’ 对得上,而 ‘hey hi’ 对不上。命名组的回溯引用写法是 (?P=名字),实测 r'(?P<w>\w+) (?P=w)' 在 'go go stop by by' 上得到 ['go', 'by']。注意 \1 在「普通字符串里」可能被解释为八进制转义或控制字符,所以模式字符串务必写成原始字符串(r’\1’),这是新手高频翻车点。
非贪婪量词 ??、*?、+? 补课
上篇讲过贪婪与懒惰的直觉,这里把懒惰家族补齐:??、*?、+?、{m,n}?。懒惰只改变「尝试的起点」和「何时停下」,不改变匹配成功的判据——引擎仍以整体匹配为准。三个实测行为值得记住:r'\d{3,5}?' 匹配 '20269' 时先按最少 3 位取 '202',因为第 4 位开始就匹配成功了,它不会「贪心地多看」;r'\d+?' 对 '12345' 的 findall 是逐个数字 ['1','2','3','4','5'],因为每次只消费最少字符、完成匹配后引擎再次从头开始找下一个;而 ?? 极少单独使用——r'a*?' 匹配 'aaa' 时甚至交替产出空串和 ‘a’(实测 ['', 'a', '', 'a', '', 'a', '']),因为「0 次」也是合法匹配。工程实践中 *?、+? 最常见,?? 几乎只出现在「必须留出空匹配」的罕见场景。
编译正则与四种 flags
每次 re.findall(pattern, text) 都会隐式做一次「编译」,re 模块内部有缓存(默认最多 512 个模式)来摊薄成本。而 re.compile(pattern, flags) 显式返回一个 Pattern 对象,之后反复调用 pat.findall(text)、pat.search(text) 直接复用编译结果——既省下重复编译,也把「模式 + flags」与「使用」分离,还让模式对象成为可传参、可测试的独立值。上篇提过的 re.ASCII 以及本篇的四种常用 flags 都要在编译时传入:
- re.IGNORECASE:忽略大小写。对 ASCII 与 Unicode 字母都生效(大小写折叠),只对「字母」有意义。
- re.MULTILINE:让
^匹配每行的开头、$匹配每行的结尾;注意\A与\Z仍是「全文开头/结尾」,不受影响。 - re.DOTALL:让
.匹配包括换行在内的任意字符——上篇「点不匹配换行」的官方解法。 - re.VERBOSE:忽略模式里的空白并允许写
#注释,多行书写长模式时可读性大增(字面空白要用\x20或[ ]表示)。
多个 flag 用按位或合并:re.compile(pattern, re.I | re.M)。下面全部实测。
操作与实现
分组提取实测:编号与嵌套
先看编号规则——按左括号从左到右编号,嵌套也如此:
import re
m = re.match(r'((\w+)-(\d+))', 'item-42 extra')
print(m.group(0)) # 整个匹配
print(m.group(1)) # 外层括号
print(m.group(2)) # 第一个内层括号
print(m.group(3)) # 第二个内层括号
print(m.groups()) # 除第 0 组外的全部内容
item-42
item-42
item
42
('item-42', 'item', '42')
.groups() 返回除第 0 组外的全部组内容元组。可选分组在没参与匹配时记为 None:
import re
m = re.match(r'(\d+)(-(\d+))?', '42')
print(m.groups())
('42', None, None)
(-(\d+))? 整体可缺省,缺省时第 2、3 组都是 None——解析可选字段时这是常态,取值前要记得判空。
命名分组与 groupdict
命名分组让「按名字取字段」成为可能:
import re
m = re.match(r'(?P<year>\d{4})-(?P<month>\d{1,2})', '2026-09')
print(m.group('year'))
print(m.groupdict())
2026
{'year': '2026', 'month': '09'}
groupdict() 返回「名字 → 内容」字典,配合 ** 展开即可直接构造结构化数据,是第 8 篇日志解析的主力工具。
非捕获组:只要成组,不要捕获
import re
# 年份只用来分组,不参与捕获:findall 只返回第 1 组内容
print(re.findall(r'(?:\d{4})-(\d{1,2})', '2026-09 1999-12'))
print(re.match(r'(?:ab)+', 'ababab').groups())
['09', '12']
()
注意第一行的意外收获(先留个悬念给第 6 篇展开):模式里有捕获组时,findall 返回的是「组的内容」而不是「整个匹配」——这里年月的整个匹配被非捕获组隔开,返回的正是每月两位数字。
回溯引用实战:找叠词
「同一个词出现两遍」这类约束,只有回溯引用能表达:
import re
print(re.findall(r'(\w+) \1', 'na na hey hey hi'))
print(re.findall(r'(?P<w>\w+) (?P=w)', 'go go stop by by'))
['na', 'hey']
['go', 'by']
(\w+) \1 的读法:捕获一个词,空一格,然后「必须是刚才那个词」——‘hey hey’ 命中,‘hey hi’ 不命中。(?P=w) 是命名形式,语义相同。这类模式在「找重复行、找成对引号、检测连续重复字段」时很常用。
懒惰量词再实测
import re
print(re.findall(r'\d{3,5}', '20269')) # 贪婪:尽量多取
print(re.findall(r'\d{3,5}?', '20269')) # 懒惰:先取最少 3 位
print(re.findall(r'\d+?', '12345')) # +? :每次只拿 1 个
print(re.findall(r'a*?', 'aaa')) # *? :零长度也合法,结果很怪
['20269']
['202']
['1', '2', '3', '4', '5']
['', 'a', '', 'a', '', 'a', '']
前三行印证原理:贪婪取满、懒惰取最少;最后一行是 ??/*? 类的「零长度陷阱」——引擎在每个位置都允许「匹配 0 次」,于是空串与单个 ‘a’ 交替出现。记住:懒惰量词解决的是「过度吞噬」,不是「更短的匹配」。
compile 与 flags 逐项实测
IGNORECASE 与 MULTILINE:
import re
pat = re.compile(r'python', re.IGNORECASE)
print(pat.findall('Python python PYTHON'))
pat = re.compile(r'^第\d+条', re.MULTILINE)
print(pat.findall('第1条 甲\n乙\n第2条 丙'))
['Python', 'python', 'PYTHON']
['第1条', '第2条']
第二条衬托出 MULTILINE 的价值:不加标志时 ^ 只认字符串开头,加了以后每一行都是候选起点。而全局锚点不受影响:
import re
pat1 = re.compile(r'\A\w+', re.MULTILINE)
print(pat1.findall('ab\ncd'))
pat2 = re.compile(r'\w+\Z', re.MULTILINE)
print(pat2.findall('ab\ncd'))
['ab']
['cd']
\A 仍只匹配全文开头(‘ab’),\Z 仍只匹配全文结尾(‘cd’)——「逐行」是 ^、$ 的专属行为。
DOTALL 是上篇「点不匹配换行」的官方解法:
import re
t = '<a>\n<b>'
print(re.findall(r'<.*>', t))
print(re.findall(r'<.*>', t, re.DOTALL))
['<a>', '<b>']
['<a>\n<b>']
不加标志时 .* 到换行处就会停下,各自匹配一个标签;DOTALL 后 . 跨过换行,<.*> 一口气吞到最后一个 >。
VERBOSE 让长模式可读——解析「年年-月月」并加注释:
import re
pat = re.compile(r'''
(?P<year>\d{4}) # 四位年份
- # 分隔符
(?P<month>\d{2}) # 两位月份
''', re.VERBOSE)
m = pat.match('2026-09')
print(m.groupdict())
{'year': '2026', 'month': '09'}
flags 的组合用按位或:
import re
pat = re.compile(r'^ok$', re.I | re.M)
print(pat.findall('OK\nok\nno'))
['OK', 'ok']
逐行匹配「OK」与「ok」,忽略大小写,「no」被排除。
易错点与陷阱
1. 分组编号按「左括号」数,嵌套时极易数错
r'((\w+)-(\d+))' 里第 1 组竟然是外层括号(实测),很多人默认编号按「最内层优先」而数错,导致 \2、\3 指到错误的内容,错误还静默无声。对策:括号一多就用命名分组,让名字说话。
2. (?P…) 的 P 必须大写
写成 (?p<name>...) 直接报错,这是 Python 语法和其他语言(JavaScript 用 (?<name>...))最容易混淆的点。同样的还有 (?P=name) 的回溯引用写法。遇到语法报错先检查 P 的大小写。
3. findall 遇捕获组返回「组内容」而不是「整个匹配」
实测 re.findall(r'(?:\d{4})-(\d{1,2})', '2026-09 1999-12') 返回 ['09', '12']——年月整体不见了。这是 findall 的既定行为(下篇细讲),但刚从「匹配思维」转「捕获思维」时最容易惊讶:想拿整个匹配,请用 finditer 逐项处理,或把整个模式再包一层 (...)。
4. MULTILINE、DOTALL 各管一摊,方向别搞反
「逐行」归 ^、$ 与 MULTILINE 管,. 跨换行归 DOTALL 管,\A、\Z 永远锚定全文(实测)。新人常把「多行文本匹配失败」全部归咎于应该开 DOTALL——如果是锚定问题,开 DOTALL 没用。先想清楚失败发生在「位置」还是「字符」。
5. 懒惰 ≠ 最短整体匹配
r'\d{3,5}?' 匹配 '20269' 得到 '202'(实测),看起来是「最短」,但它只是在每个尝试点「从最少开始」。懒惰量词改的是量词的试探顺序,不是匹配的整体路径;当模式复杂时,「懒惰」的最终结果仍然可能很长。要精确控制取多长,明确写 {m,n} 或用字符类限定边界。
小结
本篇把圆括号从「分组运算符」升级为「提取工具」:按左括号顺序编号的捕获组、(?P<name>...) 命名分组与 groupdict、只分组不捕获的 (?:...)、模式内复用的回溯引用 \1 与 (?P=name),并补全了非贪婪量词 ??、*?、+? 的实测行为。最后实测了 re.compile 与 IGNORECASE、MULTILINE、DOTALL、VERBOSE 四种 flags 及其按位或组合。语法到此完备,下一篇把这些武器全部装进 re 模块的 API:match/search/findall/finditer/sub/split 全家桶,以及它们的工程选择。
练习与思考题
- 用命名分组写一条模式解析
'2026-09-01 12:30',用 groupdict 输出年月日时分五个字段,程序应输出{'year': '2026', 'month': '09', 'day': '01', 'hour': '12', 'minute': '30'}。 - 写一条模式匹配
'value=10 value=10'这样的「重复赋值」,即第二个 value 后的数字必须与第一个相等(提示:回溯引用),并解释为什么'value=10 value=99'不匹配。 - 用 re.compile(r’^!’, re.MULTILINE) 找出一段多行文本里所有以「!」开头的行;再说明若改用 re.DOTALL 是否有效,为什么。
- 思考题:上文中
r'a*?'对'aaa'的实测输出是['', 'a', '', 'a', '', 'a', '']。请推演 findall 在每个位置上的匹配过程,说明空串与 ‘a’ 为什么交替出现。