正则表达式(二)

前言

  • 《正则表达式(一)》讲规则本身(元字符、次数、边界、断言、修饰符、转义),这一篇讲拿结果和改文本:分组与编号、反向引用、findall 与分组的坑、split、sub / subn,最后是贪婪带来的回溯开销与 3.11 的占有量词。
  • 所有示例在本机 Python 3.14.10 上实跑过,输出原样贴在代码块下面。

分组与编号

  • 组号按左括号顺序

    「组号」就是括号在模式里从左往右数第几个 (,0 号永远是整段匹配;它跟谁先匹配到、跟 ) 在哪、跟嵌套深浅都无关。几条连带规则:

    • 嵌套时外层先拿号,内层往后排。
    • (?:...) 非捕获组不占号。
    • 命名组照样占一个号,名字只是给同一个号起别名。
    • 分支 (...)|(...) 的编号也是按左括号定死的。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    import 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
    12
    groups 个数       : 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 a
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    import 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
    16
    group()        : 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
    7
    import 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
    3
    groups(): ('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
    17
    import 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/03
    1
    2
    3
    4
    5
    6
    7
    8
    import 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
    4
    groupdict()   : {'province': '410', 'city': '782', 'born': '1995'}
    group('city') : 782
    lastgroup : born | lastindex: 3
    span("born") : (6, 10)

    groupdict() 只包含有名字的组,匿名组不会出现在里面;lastgroup 给出最后一个参与匹配的组的组名。

反向引用

\1 / (?P=name) 引用的是那一组已经匹配到的文本,不是「再按同样规则匹配一次」。

1
2
3
4
5
6
7
8
import re

# \1 引用的是第 1 组「已经匹配到的内容」,不是「同样的规则」
print('重复单词:', re.findall(r'\b(\w+)\s+\1\b', 'the the cat and and dog'))
m = re.search(r'<([a-z]+)>.*?</\1>', '<b>粗体</b><i>斜体</i>')
print('标签配对:', m.group(), '| 标签名:', m.group(1))
print('命名版 :', re.search(r'<(?P<tag>[a-z]+)>.*?</(?P=tag)>', '<div>x</div>').group())
print('换个标签就不匹配:', re.search(r'<([a-z]+)>.*?</\1>', '<b>x</i>'))

输出:

1
2
3
4
重复单词: ['the', 'and']
标签配对: <b>粗体</b> | 标签名: b
命名版 : <div>x</div>
换个标签就不匹配: None

典型用途:找重复词、配对同名标签、校验「引号成对」。注意最后一行——<b>x</i> 不匹配,因为 \1 已经锁定了 b,这正是我们要的行为。

findall 与分组的坑

1
2
3
4
5
6
import re

s = '2026-11-02 02:41:07 ERROR db timeout'
print('有组 -> 只给组:', re.findall(r'(\d{4})-(\d{2})-(\d{2})', s))
print('转非捕获 :', re.findall(r'\d{4}-\d{2}-(?:\d{2})', s))
print('要整段用 finditer:', [m.group() for m in re.finditer(r'\d{4}-\d{2}-\d{2}', s)])

输出:

1
2
3
有组 -> 只给组: [('2026', '11', '02')]
转非捕获 : ['2026-11-02']
要整段用 finditer: ['2026-11-02']

findall 只要检测到捕获组,返回的就不是整段匹配:单组给字符串列表,多组给元组列表。想要整段 + 位置,用 finditer;想保留整段又不想多一层结构,把不需要的那部分写成 (?:...)。

字符串切割 split

str.split() 只能按固定串切,分隔符不固定(一个或多个空白、按数字)就交给 re.split。

1
2
3
4
5
6
7
8
import re

s = 'a1b22c333d'
print('str.split 只认固定串:', 'a,b,,c'.split(','))
print('re.split 按规则 :', re.split(r'\d+', s))
print('带括号会保留分隔符 :', re.split(r'(\d+)', s))
print('maxsplit 限制次数 :', re.split(r'\d+', s, maxsplit=2))
print('非捕获组不留分隔符 :', re.split(r'(?:\d+)', s))

输出:

1
2
3
4
5
str.split 只认固定串: ['a', 'b', '', 'c']
re.split 按规则 : ['a', 'b', 'c', 'd']
带括号会保留分隔符 : ['a', '1', 'b', '22', 'c', '333', 'd']
maxsplit 限制次数 : ['a', 'b', 'c333d']
非捕获组不留分隔符 : ['a', 'b', 'c', 'd']

关键一条:模式里带捕获组时,被切掉的分隔符也会留在结果里(第 3 行)。这不是 bug,是让你切完还能知道「原来分隔的是什么」;不想要就用 (?:...)(第 5 行)。maxsplit 控制切几刀,剩余部分原样留在最后一个元素里。

字符串替换 sub / subn

  • 替换串里引用分组

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    import 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
    8
    import 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
    6
    import 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
    11
    import 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
    9
    a*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
    8
    import 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
    6
    import 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
    6
    import 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
    18
    import 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
    10
    a*+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
    13
    import 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import re

LOG = re.compile(
r'(?P<ip>\d{1,3}(?:\.\d{1,3}){3})' # 客户端 IP
r' - .*? \[(?P<time>[^\]]+)\]' # 时间戳
r' "(?P<method>\w+) (?P<path>\S+) [^"]*"' # 请求行
r' (?P<status>\d{3}) (?P<size>\d+|-)' # 状态码与字节数
)

lines = [
'10.0.0.7 - - [02/Nov/2026:01:40:11 +0800] "GET /index.html HTTP/1.1" 200 1042',
'2001:db8::1 - - [02/Nov/2026:01:40:12 +0800] "POST /api/login HTTP/1.1" 401 0',
'10.0.0.9 - - [02/Nov/2026:01:40:13 +0800] "GET /static/app.js HTTP/1.1" 200 20480',
]

hits = []
for line in lines:
m = LOG.search(line)
if not m:
print('没匹配:', line[:22], '...')
continue
d = m.groupdict()
d['size'] = 0 if d['size'] == '-' else int(d['size'])
hits.append(d)
print('{:<10} {:<5} {:<14} {} {}'.format(d['ip'], d['method'], d['path'], d['status'], d['size']))

print('命中', len(hits), '条,合计字节', sum(h['size'] for h in hits))
print('路径改写:', [re.sub(r'^/static/', '/cdn/', h['path']) for h in hits])
print('IP 脱敏 :', [re.sub(r'\.\d{1,3}\.\d{1,3}$', '.0.0', h['ip']) for h in hits])

输出:

1
2
3
4
5
6
10.0.0.7   GET   /index.html    200 1042
没匹配: 2001:db8::1 - - [02/No ...
10.0.0.9 GET /static/app.js 200 20480
命中 2 条,合计字节 21522
路径改写: ['/index.html', '/cdn/app.js']
IP 脱敏 : ['10.0.0.0', '10.0.0.0']

几点读法:

  • 第二行没匹配上:模式里的 ip 只写了 IPv4 的形状。正则只会匹配你想到的形状,上线前要把反例列全(这里要么放宽 ip,要么换 ipaddress 判断)。
  • size 可能是 -,所以先当字符串捕获、再在代码里转成 0,比在正则里写两套分支好维护。
  • 路径改写和 IP 脱敏都是 re.sub,配合 ^ / $ 锚点避免误伤中间内容。

参考

本文结束 感谢您的阅读
正在加载今日诗词....