Chapter 03 / 10

捕获组与反向引用

用括号捕获子匹配、命名组、非捕获组、反向引用——提取与重组文本的核心工具

什么是捕获组

圆括号 (...) 包裹的部分形成一个捕获组。括号有两个作用:

  1. 分组:让括号内的模式作为一个整体,可以对整体应用量词或选择
  2. 捕获:记录括号内匹配到的子字符串,以便后续提取或引用
捕获组(Capturing Group)
(...) 定义。引擎匹配时,会记录括号内匹配到的子字符串。每个捕获组按从左到右的顺序编号(从1开始)。可以在匹配后通过 .group(n)(Python)或 match[n](JavaScript)获取。
命名捕获组(Named Capturing Group)
(?P<name>...)(Python)或 (?<name>...)(JavaScript/PCRE)定义,给捕获组起一个名字。通过名字而非编号访问,代码可读性更好。
非捕获组(Non-Capturing Group)
(?:...) 定义。括号只起分组作用(用于量词或选择),不记录捕获内容。比捕获组性能略好,代码意图更清晰。
反向引用(Backreference)
在正则内部用 \1\2 等引用前面已捕获的内容,要求该位置的内容必须与捕获组捕获的内容完全相同。在替换字符串中也可以用反向引用重组文本。

捕获组基础

/(\d{4})-(\d{2})-(\d{2})/
2026-03-26
group(1)="2026",group(2)="03",group(3)="26"
import re

# 捕获组的基本使用
m = re.search(r'(\d{4})-(\d{2})-(\d{2})', 'today is 2026-03-26')

m.group(0)    # '2026-03-26'  整个匹配(等价于 m.group())
m.group(1)    # '2026'        第1个捕获组(年)
m.group(2)    # '03'          第2个捕获组(月)
m.group(3)    # '26'          第3个捕获组(日)
m.groups()    # ('2026', '03', '26')  所有捕获组的元组

# 一次提取多个组
year, month, day = m.groups()
print(f'{year}年{month}月{day}日')  # '2026年03月26日'

命名捕获组

当有多个捕获组时,用数字编号容易混乱,命名组让代码更清晰:

# Python:(?P<name>...)
m = re.search(
    r'(?P<year>\d{4})-(?P<month>\d{2})-(?P<day>\d{2})',
    'today is 2026-03-26'
)

m.group('year')    # '2026'
m.group('month')   # '03'
m.group('day')     # '26'
m.groupdict()      # {'year': '2026', 'month': '03', 'day': '26'}

// JavaScript(ES2018+):(?<name>...)
const m = '2026-03-26'.match(/(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/)
m.groups  // { year: '2026', month: '03', day: '26' }
m.groups.year   // '2026'

findall 与捕获组的关系

Python 的 findall 在有捕获组时,返回捕获组内容而非整个匹配——这是常见的困惑来源:

# 无捕获组:返回整个匹配
re.findall(r'\d{4}-\d{2}-\d{2}', '2026-03-26 和 2025-12-31')
# → ['2026-03-26', '2025-12-31']

# 有1个捕获组:返回该组的内容
re.findall(r'(\d{4})-\d{2}-\d{2}', '2026-03-26 和 2025-12-31')
# → ['2026', '2025']  (只有年份!)

# 有多个捕获组:返回元组
re.findall(r'(\d{4})-(\d{2})-(\d{2})', '2026-03-26 和 2025-12-31')
# → [('2026', '03', '26'), ('2025', '12', '31')]

# 如果想用捕获组提取部分但 findall 返回整体
# 方案1:用非捕获组 (?:...) 包裹不需要的部分
re.findall(r'(?:\d{4})-(\d{2})-(?:\d{2})', '2026-03-26')
# → ['03']  (只有月份,因为只有一个捕获组)

# 方案2:用 finditer 获取完整 Match 对象
for m in re.finditer(r'(\d{4})-(\d{2})-(\d{2})', '2026-03-26 和 2025-12-31'):
    print(m.group(0), m.group(1))
# 2026-03-26 2026
# 2025-12-31 2025
WARNING(findall 的捕获组陷阱)这是 Python 正则最常见的误区之一:添加了捕获组后,findall 的返回值就变了。如果你本意是"用括号分组以便加量词",请使用非捕获组 (?:...),而不是普通捕获组 (...)

非捕获组 (?:...)

只需要分组功能(如对整体加量词),不需要提取内容时,用非捕获组:

# ❌ 使用捕获组仅为了分组
re.findall(r'(ab)+', 'ab ababab')
# → ['ab', 'ab']  (findall 返回捕获组内容,不是整个匹配!)

# ✅ 使用非捕获组
re.findall(r'(?:ab)+', 'ab ababab')
# → ['ab', 'ababab']  (正确!返回整个匹配)

# 非捕获组用于选择
re.findall(r'(?:gray|grey)', 'color: gray or grey')
# → ['gray', 'grey']  (findall 返回整个匹配)

# 对比:捕获组用于选择,findall 返回组内容
re.findall(r'(gray|grey)', 'color: gray or grey')
# → ['gray', 'grey']  (这里结果一样,因为组内容等于整个匹配)

反向引用(Backreference)

反向引用 \1\2 等在正则内部引用已捕获的内容。这不是"引用模式",而是"引用捕获到的实际字符串"。

/(\w+)\s+\1/
the the the quick fox
\1 要求和第1组捕获的内容完全相同——检测重复单词
# 检测重复单词(常见用途)
m = re.search(r'\b(\w+)\s+\1\b', 'the the quick brown fox')
m.group()     # 'the the'
m.group(1)   # 'the'

# 匹配成对的 HTML 标签
m = re.search(r'<(\w+)>.*?</\1>', '<b>bold text</b>', re.DOTALL)
m.group()     # '<b>bold text</b>'

# 匹配成对引号
m = re.search(r'(["\']).*?\1', '"hello" and \'world\'')
m.group()     # '"hello"'  (开头和结尾引号类型必须相同)

# 命名反向引用
re.sub(r'(?P<word>\w+) (?P=word)', r'\g<word>', 'hello hello world')
# → 'hello world'  (删除重复词)

在替换中使用反向引用

反向引用最强大的用途是文本重组——在 re.sub() 的替换字符串中重新排列捕获的内容:

# 日期格式转换:YYYY-MM-DD → MM/DD/YYYY
re.sub(
    r'(\d{4})-(\d{2})-(\d{2})',
    r'\2/\3/\1',         # \1=年 \2=月 \3=日,重排顺序
    'Date: 2026-03-26'
)
# → 'Date: 03/26/2026'

# 交换姓名:First Last → Last, First
re.sub(
    r'(\w+)\s+(\w+)',
    r'\2, \1',
    'John Smith Alice Johnson'
)
# → 'Smith, John Johnson, Alice'

# 为 Python 函数调用加上类型注解(示例性质)
re.sub(
    r'def (\w+)\(self, (\w+)\)',
    r'def \1(self, \2: str)',
    'def get(self, key)'
)
# → 'def get(self, key: str)'

// JavaScript 中的反向引用(用 $ 号)
'2026-03-26'.replace(/(\d{4})-(\d{2})-(\d{2})/, '$2/$3/$1')
// → '03/26/2026'

// 命名引用
'2026-03-26'.replace(
    /(?<y>\d{4})-(?<m>\d{2})-(?<d>\d{2})/,
    '$<m>/$<d>/$<y>'
)
// → '03/26/2026'

分组的嵌套与编号规则

嵌套捕获组的编号规则:按左括号从左到右,从1开始计数。

# 嵌套组示例
m = re.match(r'(a(b(c)d)e)', 'abcde')

m.group(1)  # 'abcde'  ← 最外层的 (a(b(c)d)e)
m.group(2)  # 'bcd'    ← 中间的 (b(c)d)
m.group(3)  # 'c'      ← 最内层的 (c)

# 量词重复时,捕获组只记录最后一次匹配
m = re.match(r'(\d)+', '1234')
m.group(1)  # '4'  ← 只记录最后一次捕获!
m.group(0)  # '1234'  ← 整个匹配

# 如果需要捕获整个重复序列,把量词放在括号外
m = re.match(r'(\d+)', '1234')
m.group(1)  # '1234'  ← 正确捕获整个数字串
INFO(量词与捕获组)当捕获组带量词(如 (\d)+)时,每次重复都会覆盖捕获组的内容,最终只保留最后一次匹配的结果。如果你需要匹配"一个或多个数字"并捕获整个数字串,应该把量词放在括号内:(\d+) 而不是 (\d)+

其他特殊分组语法

语法名称含义
(...)捕获组捕获并编号,可通过 \1 等引用
(?:...)非捕获组仅分组,不捕获,不占用编号
(?P<name>...)命名捕获组(Python)命名捕获,可通过 \g<name> 引用
(?<name>...)命名捕获组(JS/PCRE)命名捕获,可通过 $<name> 引用
(?=...)正向前瞻(见第4章)断言右侧必须匹配
(?!...)负向前瞻(见第4章)断言右侧不匹配
(?<=...)正向后顾(见第4章)断言左侧必须匹配
(?<!...)负向后顾(见第4章)断言左侧不匹配

实战:解析日志行

import re

# 解析 Apache 访问日志的一行
LOG_LINE = '192.168.1.1 - - [26/Mar/2026:10:30:45 +0800] "GET /index.html HTTP/1.1" 200 1234'

LOG_PATTERN = re.compile(r"""
    (?P<ip>\d+\.\d+\.\d+\.\d+)      # IP 地址
    \s+-\s+-\s+                       # ident 和 authuser(通常为 -)
    \[(?P<time>[^\]]+)\]              # 时间(方括号内)
    \s+"(?P<method>\w+)              # HTTP 方法
    \s+(?P<path>\S+)                 # 请求路径
    \s+(?P<proto>HTTP/\S+)"          # 协议
    \s+(?P<status>\d{3})             # 状态码
    \s+(?P<size>\d+|-)               # 响应大小
""", re.VERBOSE)

m = LOG_PATTERN.match(LOG_LINE)
if m:
    info = m.groupdict()
    print(info['ip'])      # '192.168.1.1'
    print(info['status']) # '200'
    print(info['path'])   # '/index.html'

小结

本章要点