正则表达式(二)
前言
- 《正则表达式(一)》讲规则本身(元字符、次数、边界、断言、修饰符、转义),这一篇讲拿结果和改文本:分组与编号、反向引用、
findall与分组的坑、split、sub/subn,最后是贪婪带来的回溯开销与 3.11 的占有量词。 - 所有示例在本机 Python 3.14.10 上实跑过,输出原样贴在代码块下面。
分组与编号
-
组号按左括号顺序
「组号」就是括号在模式里从左往右数第几个
(,0号永远是整段匹配;它跟谁先匹配到、跟)在哪、跟嵌套深浅都无关。几条连带规则:- 嵌套时外层先拿号,内层往后排。
(?:...)非捕获组不占号。- 命名组照样占一个号,名字只是给同一个号起别名。
- 分支
(...)|(...)的编号也是按左括号定死的。
1
2
3
4
5
6
7
8
9
10
11
12
13import re
m = re.search(r'((\d{4})-(\d{2}))/(\d{2})', '订单 2024-03/15')
print('groups 个数 :', len(m.groups()))
for k in range(1, 5):
print('group(%d) : %r' % (k, m.group(k)))
print('整段 group(0) :', repr(m.group(0)))
print('嵌套时外层先拿号 :', re.search(r'(a(b(c)))', 'abc').groups())
print('命名组照样占号 :', re.search(r'(?P<ym>\d{4})-(\d{2})', '2024-03').group(1))
print('非捕获组不占号 :', re.search(r'(?:\d{4})-(\d{2})', '2024-03').groups())
print('分支里的编号固定 :', re.search(r'(a)|(b)', 'b').groups())
print('重复只留最后一次 :', re.match(r'(\w)+', 'abcd').group(1))
print('反向引用看的也是号:', re.search(r'(\w) \1', 'a a b b').group())输出:
1
2
3
4
5
6
7
8
9
10
11
12groups 个数 : 4
group(1) : '2024-03'
group(2) : '2024'
group(3) : '03'
group(4) : '15'
整段 group(0) : '2024-03/15'
嵌套时外层先拿号 : ('abc', 'bc', 'c')
命名组照样占号 : 2024
非捕获组不占号 : ('03',)
分支里的编号固定 : (None, 'b')
重复只留最后一次 : d
反向引用看的也是号: a a1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24import re
m = re.search(r'(\d+)-(\d+)/(\d+)', '订单 2024-03/15')
print('group() :', m.group())
print('group(0) :', m.group(0))
print('group(1) :', m.group(1))
print('group(1, 3) :', m.group(1, 3))
print('groups() :', m.groups())
print('m[2] :', m[2])
print('start(1)/end(1):', m.start(1), m.end(1))
print('lastindex :', m.lastindex)
print('group() == group(0) == m[0]:', m.group() == m.group(0) == m[0])
m2 = re.search(r'(a)?b(c)', 'bc')
print('没参与匹配的组 :', m2.groups())
print('groups(default):', m2.groups(default='-'))
try:
m2.group(1, '-') # group() 的每个参数都是组号,第二个参数不是默认值
except IndexError as e:
print('group(1, default):', 'IndexError:', e)
print('单组想要默认值 :', m2.group(1) or '-')
print('严格判空 :', m2.group(1) if m2.group(1) is not None else '-')
print('空字符串的坑 :', repr(re.search(r'(a*)b', 'b').group(1)), '-> or 会换成 -')
print('m2 的 group() :', repr(m2.group()), '<- 整段是 bc,1 号组没参与')输出:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16group() : 2024-03/15
group(0) : 2024-03/15
group(1) : 2024
group(1, 3) : ('2024', '15')
groups() : ('2024', '03', '15')
m[2] : 03
start(1)/end(1): 3 7
lastindex : 3
group() == group(0) == m[0]: True
没参与匹配的组 : (None, 'c')
groups(default): ('-', 'c')
group(1, default): IndexError: no such group
单组想要默认值 : -
严格判空 : -
空字符串的坑 : '' -> or 会换成 -
m2 的 group() : 'bc' <- 整段是 bc,1 号组没参与三个要点:
group()不传参就等于group(0),拿到的是整段匹配(m[0]同理),它跟有几组、组有没有参与匹配都无关,永远返回整个命中的文本;- 未参与匹配的组返回
None,groups()里也会带出这个None,想换默认值用groups(default=...)。 group()的多余参数是「再要几个组」,不是默认值,m.group(1, '-')会把'-'当组号,直接IndexError。 单个组想要默认值就自己判:m.group(1) or '-'够用,但组匹配到的是空字符串时也会被换成'-'(上面最后一行演示的就是这个),要严格区分None和''就写m.group(1) if m.group(1) is not None else '-'。
-
嵌套分组的编号
1
2
3
4
5
6
7import re
# 组号按「左括号出现的顺序」编号,嵌套也照此算
m = re.search(r'((a)(b))((c)(d))', 'abcdef')
print('groups():', m.groups())
print('group(1):', m.group(1), '| group(2):', m.group(2), '| group(4):', m.group(4))
print('lastindex:', m.lastindex)输出:
1
2
3groups(): ('ab', 'a', 'b', 'cd', 'c', 'd')
group(1): ab | group(2): a | group(4): cd
lastindex: 4编号只看左括号出现的先后,跟嵌套层级无关,所以
((a)(b))是 1、2、3 三组。 -
命名分组
写法只有一种:
(?P<组名>要匹配的内容)。拆开看是四段:(起头。?P<是固定前缀,P不是占位符、不能省,也别写成小写p。- 组名到
>为止,必须是合法标识符:字母或下划线开头,后面只能跟字母/数字/下划线,区分大小写;关键字(如class)反而可以当组名。 >后面照常写这组要匹配的正则,最后用)收尾。
几条配套规则:
- 同一个模式里组名不能重复。
- 别用 JS 的
(?<name>...),Python 不认,报unknown extension ?<n。 - 取值:
m.group(名)、m[名]、m.span(名)、m.groupdict()。 - 模式内反向引用写
(?P=名);替换串里写\g<名>。 - 命名组照样占一个数字号(按左括号顺序),所以
m.group(1)和m.group('province')是同一个东西。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17import re
m = re.search(r'(?P<province>\d{3})', '410782')
print('按名字取 :', m.group('province'), m['province'], m.span('province'))
print('按编号取 :', m.group(1), '<- 同一个组,两种写法等价')
print('groupdict() :', m.groupdict())
print('re.error 是 re.PatternError 的别名:', re.error is re.PatternError)
cases = [('组名以数字开头', r'(?P<1a>x)'), ('关键字当组名', r'(?P<class>x)'),
('同名重复', r'(?P<n>a)(?P<n>b)'), ('JS 的写法', r'(?<name>x)')]
for label, pat in cases:
try:
re.compile(pat)
print('%s -> 合法' % label)
except re.error as e:
print('%s -> %s' % (label, e))
print('模式内引用 :', re.search(r'(?P<w>\w)(?P=w)', 'aa').group())
print('替换串引用 :', re.sub(r'(?P<y>\d{4})-(\d{2})', r'\g<y>/\g<2>', '2024-03'))输出:
1
2
3
4
5
6
7
8
9
10按名字取 : 410 410 (0, 3)
按编号取 : 410 <- 同一个组,两种写法等价
groupdict() : {'province': '410'}
re.error 是 re.PatternError 的别名: True
组名以数字开头 -> bad character in group name '1a' at position 4
关键字当组名 -> 合法
同名重复 -> redefinition of group name 'n' as group 2; was group 1 at position 12
JS 的写法 -> unknown extension ?<n at position 1
模式内引用 : aa
替换串引用 : 2024/031
2
3
4
5
6
7
8import re
pat = re.compile(r'(?P<province>\d{3})(?P<city>\d{3})(?P<born>\d{4})')
m = pat.search('4107821995xxxxxxxx')
print('groupdict() :', m.groupdict())
print("group('city') :", m.group('city'))
print('lastgroup :', m.lastgroup, '| lastindex:', m.lastindex)
print('span("born") :', m.span('born'))输出:
1
2
3
4groupdict() : {'province': '410', 'city': '782', 'born': '1995'}
group('city') : 782
lastgroup : born | lastindex: 3
span("born") : (6, 10)groupdict()只包含有名字的组,匿名组不会出现在里面;lastgroup给出最后一个参与匹配的组的组名。
反向引用
\1 / (?P=name) 引用的是那一组已经匹配到的文本,不是「再按同样规则匹配一次」。
1 | import re |
输出:
1 | 重复单词: ['the', 'and'] |
典型用途:找重复词、配对同名标签、校验「引号成对」。注意最后一行——<b>x</i> 不匹配,因为 \1 已经锁定了 b,这正是我们要的行为。
findall 与分组的坑
1 | import re |
输出:
1 | 有组 -> 只给组: [('2026', '11', '02')] |
findall 只要检测到捕获组,返回的就不是整段匹配:单组给字符串列表,多组给元组列表。想要整段 + 位置,用 finditer;想保留整段又不想多一层结构,把不需要的那部分写成 (?:...)。
字符串切割 split
str.split() 只能按固定串切,分隔符不固定(一个或多个空白、按数字)就交给 re.split。
1 | import re |
输出:
1 | str.split 只认固定串: ['a', 'b', '', 'c'] |
关键一条:模式里带捕获组时,被切掉的分隔符也会留在结果里(第 3 行)。这不是 bug,是让你切完还能知道「原来分隔的是什么」;不想要就用 (?:...)(第 5 行)。maxsplit 控制切几刀,剩余部分原样留在最后一个元素里。
字符串替换 sub / subn
-
替换串里引用分组
1
2
3
4
5
6
7
8
9
10
11
12
13import re
s = '2026-11-02'
print('普通替换 :', re.sub(r'\d', '#', s))
print('只换 2 次 :', re.sub(r'\d', '#', s, count=2))
print('组重排 \\2\\1 :', re.sub(r'(\d{4})-(\d{2})-(\d{2})', r'\3/\2/\1', s))
print('推荐 \\g<1> :', re.sub(r'(\d)', r'x\g<1>y', '12'))
try:
re.sub(r'(\d)', r'\10', '1') # \10 被解析成「第 10 组」
except re.error as e:
print('\\10 的歧义 :', type(e).__name__ + ':', e)
print('命名引用 :', re.sub(r'(?P<y>\d{4})-(?P<m>\d{2})-(?P<d>\d{2})', r'\g<d>/\g<m>/\g<y>', s))
print('subn 带次数 :', re.subn(r'\d', '#', s))输出:
1
2
3
4
5
6
7普通替换 : ####-##-##
只换 2 次 : ##26-11-02
组重排 \2\1 : 02/11/2026
推荐 \g<1> : x1yx2y
\10 的歧义 : PatternError: invalid group reference 10 at position 1
命名引用 : 02/11/2026
subn 带次数 : ('####-##-##', 8)替换串走的是模板解析,不是正则解析:
\1、\g<1>、\g<name>表示组,\g<0>表示整段。\10会被读成「第 10 组」而不是「第 1 组后面跟个 0」,组不存在就直接PatternError——所以只要后面可能跟数字,一律写\g<1>。 -
repl 传函数
需要「按匹配到的内容算一下」时,把函数作为第二个参数,它收到的是
Match对象。1
2
3
4
5
6
7
8import re
def double(m):
return str(int(m.group()) * 2)
print('替换成函数结果:', re.sub(r'\d+', double, '买2个送3个'))
print('脱敏手机号 :', re.sub(r'(1[3-9]\d)\d{4}(\d{4})', r'\1****\2', '联系 13812345678'))
print('大小写互换 :', re.sub(r'[A-Za-z]+', lambda m: m.group().swapcase(), 'Hello World'))输出:
1
2
3替换成函数结果: 买4个送6个
脱敏手机号 : 联系 138****5678
大小写互换 : hELLO wORLD脱敏、单位换算、大小写变换这类需求都用这个套路,比手写循环干净。
re.subn会额外返回替换次数,做「有没有改到」的判断很方便。
贪婪、占有量词与灾难性回溯
-
贪婪先吃满,再退回去凑
1
2
3
4
5
6import re
html = '<p>一段</p><p>两段</p>'
print('贪婪 :', re.findall(r'<p>.*</p>', html))
print('非贪婪 :', re.findall(r'<p>.*?</p>', html))
print('更稳的写法:', re.findall(r'<p>[^<]*</p>', html))输出:
1
2
3贪婪 : ['<p>一段</p><p>两段</p>']
非贪婪 : ['<p>一段</p>', '<p>两段</p>']
更稳的写法: ['<p>一段</p>', '<p>两段</p>']非贪婪
.*?只是「先少拿、不够再补」,并不保证结果最短到你想的那样;要「绝不跨标签」,最稳的是把字符类写清楚([^<]*)。 -
占有量词与原子组(3.11+)
先把「回溯」说清楚:普通量词吃进去的字符只是暂时记账,后面的模式匹配不上时,引擎会退回这个量词、让它少拿一点再试,直到没有退路为止。占有量词就是封掉这条退路——末尾多出来的那个
+读作「吃了就不还」。这一组写法 Python 3.11 才支持(re模块新增)。逐个写法,规律是在普通量词后面直接再加一个
+:a*+:a*的占有版——0 个或多个 a,吃多少锁多少,一个都不退。a++:a+的占有版——1 个或多个 a,锁在最多个数上。a?+:a?的占有版——0 或 1 个 a,抢到就锁住,连「当作这里没有 a」这条退路也一起封掉。a{m,n}+:a{m,n}的占有版——取到上限就锁死,不会为了迁就后面而减回下限m。(?>...):原子组——括号里照常匹配,整体一旦成功就把结果锁住,内部不再回溯。
a*+与(?>a*)等价,区别是原子组能包住任意复杂的模式(分支、嵌套量词),占有量词只能挂在单个 token 后面。每个写法的实测对比,成对出现的是「普通 / 占有」,后面那个
a就是来抢字符的竞争者:1
2
3
4
5
6
7
8
9
10
11import re
print('a*a :', re.findall(r'a*a', 'aaaa'))
print('a*+a :', re.findall(r'a*+a', 'aaaa'))
print('a+a :', re.findall(r'a+a', 'aaaa'))
print('a++a :', re.findall(r'a++a', 'aaaa'))
print('a?a :', re.findall(r'a?a', 'a'))
print('a?+a :', re.findall(r'a?+a', 'a'))
print('a{1,3}a :', re.findall(r'a{1,3}a', 'aaa'))
print('a{1,3}+a:', re.findall(r'a{1,3}+a', 'aaa'))
print('(?>a*)a :', re.findall(r'(?>a*)a', 'aaaa'))输出:
1
2
3
4
5
6
7
8
9a*a : ['aaaa']
a*+a : []
a+a : ['aaaa']
a++a : []
a?a : ['a']
a?+a : []
a{1,3}a : ['aaa']
a{1,3}+a: []
(?>a*)a : []四对结果讲的是同一件事:普通写法靠「退回去一个 a」才让后面那个
a有得匹配,占有写法不许退,于是整条匹配直接失败。a?+a在'a'上输得最典型——a?+抢走唯一的 a 就锁住,后面的a无从匹配;a?则会主动放手,把这个 a 让给后面。占有量词不是加速开关,而是「禁止重试」的声明,用错会真的漏掉匹配:
1
2
3
4
5
6
7
8import re
print('后面不抢 a+ :', re.findall(r'a+', 'aaaa'))
print('后面不抢 a++ :', re.findall(r'a++', 'aaaa'))
print('a++b :', re.findall(r'a++b', 'aaab'))
print('定次数 a{2}+a :', re.findall(r'a{2}+a', 'aaa'))
print(r'普通 \d{2,3}\d :', re.findall(r'\d{2,3}\d', '123'))
print(r'占有 \d{2,3}+\d:', re.findall(r'\d{2,3}+\d', '123'))输出:
1
2
3
4
5
6后面不抢 a+ : ['aaaa']
后面不抢 a++ : ['aaaa']
a++b : ['aaab']
定次数 a{2}+a : ['aaa']
普通 \d{2,3}\d : ['123']
占有 \d{2,3}+\d: []三条结论:
- 后面没有模式来抢同一批字符时,加不加
+结果一样(a++和a+都吃满 4 个 a)。 {m}是定次数,本来就没有可多可少的余地,所以a{2}+与a{2}完全等价,多出来的+纯属噪声。\d{2,3}+\d匹配不上123是真实代价:普通写法靠「取 2 位、留 1 位给后面」成功,占有写法锁死 3 位就不松手。
原子组还能锁住分支顺序,这是占有量词做不到的:
1
2
3
4
5
6import re
print('(a|ab)c :', re.findall(r'(a|ab)c', 'abc'))
print('(?:a|ab)c :', re.findall(r'(?:a|ab)c', 'abc'))
print('(?>a|ab)c :', re.findall(r'(?>a|ab)c', 'abc'))
print('(?>ab|a)c :', re.findall(r'(?>ab|a)c', 'abc'))输出:
1
2
3
4(a|ab)c : ['ab']
(?:a|ab)c : ['abc']
(?>a|ab)c : []
(?>ab|a)c : ['abc']前两行的匹配结果是一样的(
re.search看整段都是abc,span都是(0, 3)),差别只在findall的返回规则:模式里有捕获组时它返回组内容,一个捕获组都没有时才返回整段——(a|ab)c里 1 号组拿到的是ab,所以打印出['ab']。这就是本篇前面「findall 与分组的坑」那条老规矩,跟原子组无关,别把它当成?的作用。普通分组先试
a,接不上c就回头试ab;原子组只要a匹配成功就锁死,排在后面的ab永远轮不到,于是整条匹配失败。所以原子组里的分支要把可能的长匹配放前面((?>ab|a)),否则会出现「每个分支单独都能匹配、合起来匹配不到」的怪事。原子组只锁它自己包住的那一层:
1
2
3
4
5
6import re
text = 'ababab'
print('原子组只包一次:', bool(re.fullmatch(r'(?>ab)+ab', text)))
print('量词括进原子组:', bool(re.fullmatch(r'(?>(?>ab)+)ab', text)))
print('原子组加占有版:', bool(re.fullmatch(r'(?>ab)++ab', text)))输出:
1
2
3原子组只包一次: True
量词括进原子组: False
原子组加占有版: False(?>ab)+ab能成功,是因为原子组只锁「一次ab」,外层+仍然可以把已经吃下的整段重复退回去一个;要锁住重复本身,得把量词一起括进去写成(?>(?>ab)+),或者给原子组再加占有量词(?>ab)++——后两种实测都匹配不上,语义一致。语法边界与两个坑(下面每行都是本机实跑的报错或结果):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18import re
for pat in [r'a*+b', r'a++b', r'a?+b', r'a{1,3}+b', r'(?>a)b']:
print(f'{pat:12} 可编译 ->', re.compile(pat).pattern)
for bad in [r'a+*', r'a+{2}']:
try:
re.compile(bad)
except re.error as e:
print(f'{bad:12} 报错: {type(e).__name__}: {e}')
for look in [r'(?<=a++)b', r'(?<=a+)b']:
try:
re.compile(look)
except re.error as e:
print(f'{look:12} 报错: {type(e).__name__}: {e}')
print('定宽原子组放进后行断言:', re.search(r'(?<=(?>aa)b)', 'aaab').span())输出:
1
2
3
4
5
6
7
8
9
10a*+b 可编译 -> a*+b
a++b 可编译 -> a++b
a?+b 可编译 -> a?+b
a{1,3}+b 可编译 -> a{1,3}+b
(?>a)b 可编译 -> (?>a)b
a+* 报错: PatternError: multiple repeat at position 2
a+{2} 报错: PatternError: multiple repeat at position 2
(?<=a++)b 报错: PatternError: look-behind requires fixed-width pattern
(?<=a+)b 报错: PatternError: look-behind requires fixed-width pattern
定宽原子组放进后行断言: (4, 4)读法:
a+*、a+{2}是真·双重量词,照旧报multiple repeat;只有紧跟在普通量词后面的+才会被解释成占有量词。(?<=a++)b的报错来自「后行断言要求定宽」,不是占有量词的问题——(?<=a+)b报的是同一个错;定宽的原子组可以放进后行断言,(?<=(?>aa)b)在'aaab'里能定位到匹配。
什么时候该用它:让注定失败的匹配尽快失败,别把时间浪费在无用的回溯上——下一节「回溯能慢到什么程度」就是它的主战场。
-
回溯能慢到什么程度
嵌套量词
(?:a+)+在「匹配失败」时会指数级地重试,这就是灾难性回溯(ReDoS)。用原子组把回溯掐掉,同一个输入的量级差别:1
2
3
4
5
6
7
8
9
10
11
12
13import re
import time
text = 'a' * 22 + '!' # 22 个 a 后面跟一个永远匹配不上的 !
def level(ms):
return '几十毫秒' if ms > 10 else '亚毫秒'
for pat, label in [(r'(?:a+)+b', '普通量词'), (r'(?>a+)+b', '原子组')]:
t0 = time.perf_counter()
ok = bool(re.fullmatch(pat, text))
cost = (time.perf_counter() - t0) * 1000
print('{:<8} 匹配={} 量级={}'.format(label, ok, level(cost)))输出:
1
2普通量词 匹配=False 量级=几十毫秒
原子组 匹配=False 量级=亚毫秒写法规避:
- 能写
a+就别写(?:a+)+。 - 给重复模式加边界(
\A...\Z、明确的结束符)。 - 对外部输入的正则,用原子组/占有量词,或换
regex第三方库(支持更完整的原子组与超时参数)。
- 能写
整体示例:解析一行访问日志
命名分组 + groupdict() + sub 的组合,是最常见的实际用法:把一行日志结构化,再做统计和改写。
1 | import re |
输出:
1 | 10.0.0.7 GET /index.html 200 1042 |
几点读法:
- 第二行没匹配上:模式里的
ip只写了 IPv4 的形状。正则只会匹配你想到的形状,上线前要把反例列全(这里要么放宽ip,要么换ipaddress判断)。 size可能是-,所以先当字符串捕获、再在代码里转成0,比在正则里写两套分支好维护。- 路径改写和 IP 脱敏都是
re.sub,配合^/$锚点避免误伤中间内容。