分组与捕获:( ) (?: ) 命名组,括号的两种身份

括号的第一重身份:划地盘

量词只管紧挨着的一个单元,想让 ab 整体重复三次,就得请括号出场:(ab){3}。这是括号的第一重身份——把一串元素捆绑成一个原子,让量词、| 都能作用于"这一整块"。它不产出任何数据,纯属于"结构工程"。

import re
# 匹配重复两遍的三位分节号,如 010-010
print(re.findall(r"\b(\d{3})-\1\b", "010-010 与 021-888"))
# 输出:['010']

这里 \1 是反向引用(下一章主角),但让 \d{3} 能被"记住",靠的正是括号的圈地能力。

括号的第二重身份:捕获现场

括号同时还有一个更常用的身份:捕获组(capture group)。凡是括号圈住的部分,匹配成功后都能单独取出来。这是正则从"判断工具"升级为"提取工具"的关键一步。

import re
log = "2024-10-26 14:32:07 ORDER-8817 支付成功"
m = re.search(r"(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2})", log)
print(m.groups())
# 输出:('2024', '10', '26', '14', '32')

五个括号按开括号的出现顺序编号 1 到 5,group(1) 到 group(5) 逐一取值,编号 0 留给整个匹配。先观察数据的字段结构,再给每个字段套一个括号——日志解析、URL 解析、表单提取,全是这个套路的变体。

在替换里同样能引用捕获组,把 2024-10-26 变成 2024/10/26 之类的重排只需一条命令:

import re
print(re.sub(r"(\d{4})-(\d{2})-(\d{2})", r"\2月\3日\1年", "上线日期 2024-10-26"))
# 输出:上线日期 10月26日2024年

不要的括号请写 (?: )

捕获是默认行为,但不是每次都想要。比如只想判断格式、不想取字段,多余的括号有两个坏处:一是让组编号膨胀,后续引用容易数错;二是在 Python 的 findall 里改变返回结构(有组返回组,没组返回整匹配),常造成"输出少了"的错觉。用 (?: )(非捕获组)声明"只圈地、不捕获":

import re
# 只想要文件名,不想捕获扩展名
print(re.findall(r"\b[\w-]+(?:\.png|\.jpg)\b", "head.png body.jpg icon.svg"))
# 输出:['head.png', 'body.jpg']

若把 (?:...) 写成 (...),findall 返回的会变成 ['.png', '.jpg']——扩展名抢走了整匹配的位置。经验法则:默认写 (?: ),确认需要提取时才升级为 (...),这个反向习惯能省掉大量调试时间。

命名组:给编号起名字

组一多,纯数字编号就成了负担:\5 到底是哪五个括号之前的?命名组直接给括号挂牌。各语言语法略异但思路一致:

语言 命名写法 替换/引用写法
Python (?P<名>...) \g<名> 或 m.group('名')
JavaScript (?<名>...) $<名> 或 m.groups.名
Java (?<名>...) matcher.group("名")
Go(RE2) (?P<名>...) ${名}
import re
access = '203.0.113.7 - - [26/Oct/2024] "GET /api/v1/orders HTTP/1.1" 200'
m = re.search(r'(?P<ip>\d{1,3}(?:\.\d{1,3}){3}).*?"(?P<method>[A-Z]+) (?P<path>\S+)', access)
print(m.group("method"), m.group("path"), m.group("ip"))
# 输出:GET /api/v1/orders 203.0.113.7

三个月后回读这段代码,ip、method、path 不需要任何注释就能看懂意图。命名组是"可维护正则"的第一块基石,第五章还会专门展开。

避坑提醒:Python 的 findall 遇到捕获组时返回的是组内容而不是整段匹配——这是新手提问频率最高的问题。看到"findall 结果怎么变短了",先数一数正则里有几个捕获括号;要整匹配,改用 finditer 或去掉不必要的捕获。

括号使用守则

  1. 每个括号问自己两个问题:它是为了量词圈地,还是为了捕获数据?答案决定写 (?: ) 还是 (...)。
  2. 能命名就命名:超过两组的正则,命名组的可读性收益立竿见影。
  3. 嵌套按开括号顺序编号:((a)(b)) 中外层是 1,a 是 2,b 是 3;引用嵌套组时在纸上画编号,别靠猜。
  4. 替换串里的转义:Python 替换串想输出字面量 \1,写成 r"\\1";JS 的 $$ 表示字面量 $。

嵌套编号与替换回调:两个必练动作

先练嵌套编号。括号一嵌套,编号规则是"按开括号出现顺序",外层在前:

import re
print(re.search(r"((a)(b))", "ab").groups())
# 输出:('ab', 'a', 'b')

组 1 是最外层整体,组 2、组 3 依次是内层。读嵌套正则时在纸上给开括号标号,是老手也在用的笨办法——笨办法不出错。

再练替换回调:分组值要做计算时,替换串不够用了,交给函数。把分页 URL 翻到下一页:

import re
print(re.sub(r"page=(\d+)",
             lambda m: "page=" + str(int(m.group(1)) + 1),
             "https://shop.example.com/list?page=2&size=20"))
# 输出:https://shop.example.com/list?page=3&size=20

回调函数收到捕获组 2,加一后原位放回——捕获负责"取出来",回调负责"算一下",替换负责"放回去"。这条三段式流水线,是从"文本搬运工"升级为"文本加工厂"的分水岭。

两个高频疑问

问:括号最多能嵌套几层? 语法上没有实质限制,但工程上超过两层就该警惕:三层嵌套的编号已经很难口算,四层基本必然出错。解法不是"小心点",而是拆——把最内层的逻辑提成独立模式常量,或干脆交给代码。

问:命名组能重名吗? Python 的 re 模块禁止重名,重名直接编译报错;部分其它引擎(如 .NET)允许在分支里重名,能否使用以所用语言的官方文档为准。Python 的标准解法是把公共部分提成组、差异部分留在组外:

import re
# 错误示范:(?P<year>\d{4})年|(?P<year>\d{4})- 会报 redefinition 错误
m = re.search(r"(?P<year>\d{4})[年-]", "创办于 1998-")
print(m["year"])
# 输出:1998

[年-] 把两种分隔符收进一个字符组,year 组只写一次——无论文本用中文"年"还是横杠格式,出口统一。遇到"多分支、同字段"的需求,先想办法合并分支的公共前缀,实在合不了再考虑换引擎或拆两次匹配。命名组的这种"统一出口"设计,是它比数字分组更适合工程代码的根本原因。

参考资源

本章由 WorkBuddy 原创撰写,示例均经实际验证。