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

正则进阶:分组、捕获与非贪婪

Python正则表达式分组捕获回溯引用re 模块

引言

上一篇的元字符解决了「匹配哪些」:一条模式把符合形状的子串找出来。但从工程角度看这还远远不够——日志里我们不仅要找到「时间戳在哪里」,还要把年、月、日、时、分、秒分别取出来做聚合;配置文件里不仅要匹配 key=value,还要把 key 和 value 拆开存进字典。这类「提取哪部分」的需求,靠的是本篇的主角:分组(group)与捕获(capture)。

本篇是第五章的第 5 篇,承接第 4 篇的元字符与字符类,为第 6 篇的 re 模块 API 全家桶备好语法弹药:分组的编号规则、命名分组 (?P…)、非捕获组 (?:…)、回溯引用 \1 与 (?P=name),以及非贪婪量词 ??、*?、+? 的补课;最后把 re.compile 与四种常用标志(flags)逐项实测。读完本篇,你应该能手写「从一条日志行里按字段拆出结构化数据」的模式。所有正则行为均以本机 Python 3.12 实测为准。

概念与原理

分组:把模式拆成可提取的单元

圆括号 () 在正则里做两件事:其一是「划分子表达式」——它改变运算符的优先级,把 |、量词的作用范围限制在括号内(上篇 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 全家桶,以及它们的工程选择。

练习与思考题

  1. 用命名分组写一条模式解析 '2026-09-01 12:30',用 groupdict 输出年月日时分五个字段,程序应输出 {'year': '2026', 'month': '09', 'day': '01', 'hour': '12', 'minute': '30'}。
  2. 写一条模式匹配 'value=10 value=10' 这样的「重复赋值」,即第二个 value 后的数字必须与第一个相等(提示:回溯引用),并解释为什么 'value=10 value=99' 不匹配。
  3. 用 re.compile(r’^!’, re.MULTILINE) 找出一段多行文本里所有以「!」开头的行;再说明若改用 re.DOTALL 是否有效,为什么。
  4. 思考题:上文中 r'a*?' 对 'aaa' 的实测输出是 ['', 'a', '', 'a', '', 'a', '']。请推演 findall 在每个位置上的匹配过程,说明空串与 ‘a’ 为什么交替出现。