Python 基础体系 · 第 41/112 篇。示例统一以 Python 3.14 为语言基线;第三方库使用与其兼容的现代稳定版本,版本敏感行为会单独说明。
Python 正则表达式:匹配模型、分组、回溯、性能和 Unicode
正则表达式不是“字符串通配符”的集合,而是一种描述字符串结构的模式语言。Python 通过标准库 re 提供正则表达式支持,模式可以作用于 str 或 bytes,但两种类型不能混用:str 模式必须匹配 str,bytes 模式必须匹配 bytes。(docs.python.org)
本文使用 Python 3.14 语法,重点解释五个相互关联的问题:
- 一个模式如何描述匹配模型;
- 分组如何保存、复用和约束文本;
- 回溯如何决定结果,以及为什么会导致灾难性性能;
- 如何设计更稳定、更快的模式;
- Unicode、字符边界、规范化和
bytes模式之间有什么差异。
一、先区分三个对象:文本、模式和匹配结果
正则表达式处理三个不同层次的对象:
- 被搜索文本:例如
"订单号:HZ-202503-001"; - 模式字符串:例如
r"(?P<city>[A-Z]{2})-(?P<month>\d{6})-(?P<serial>\d{3})"; - 匹配对象:成功匹配后由
re返回的Match对象。
模式字符串首先要经过 Python 字符串字面量解析,然后再经过正则表达式解析。因此反斜杠实际上可能被解析两次。
import re
pattern1 = "\\d+"
pattern2 = r"\d+"
print(pattern1 == pattern2) # True
print(re.fullmatch(pattern1, "123")) # Match
print(re.fullmatch(pattern2, "123")) # Match
"\\d+" 中的 \\ 先被 Python 解析成一个反斜杠,正则引擎最终看到 \d+。原始字符串 r"\d+" 则让反斜杠尽量原样保留。Python 文档也建议,除最简单的模式外,通常应使用原始字符串表示正则表达式;无效的 Python 反斜杠转义在现代 Python 中还会产生 SyntaxWarning,未来可能升级为 SyntaxError。(docs.python.org)
但原始字符串不是“任何反斜杠都能写”的特殊语法。例如,原始字符串不能以单个反斜杠结尾:
# SyntaxError
# pattern = r"\"
pattern = r"\\"
print(re.fullmatch(pattern, "\\")) # Match
这里的模式 r"\\" 表示匹配一个字面量反斜杠。正则层面的 \\ 负责转义反斜杠,原始字符串只负责避免 Python 再次处理它。
二、匹配模型:从普通字符到组合模式
2.1 普通字符表示自身
最简单的正则表达式是普通字符:
cat
它匹配连续的三个字符 c、a、t。
正则表达式的基本组合规则是连接。如果模式 A 匹配字符串 p,模式 B 匹配字符串 q,那么模式 AB 通常匹配连接后的字符串 pq。例如:
ab[0-9]+
可以拆成:
a:匹配字符a;b:匹配字符b;[0-9]:匹配一个 ASCII 数字;+:前一个原子重复至少一次。
因此:
import re
rx = re.compile(r"ab[0-9]+")
for value in ["ab1", "ab123", "a", "ab", "abx"]:
print(value, bool(rx.fullmatch(value)))
输出:
ab1 True
ab123 True
a False
ab False
abx False
这里的 fullmatch() 要求整个输入都符合模式。它与 match() 和 search() 的语义不同:
match():只尝试从字符串开头匹配;search():从字符串中寻找第一个可匹配位置;fullmatch():要求整个字符串完全匹配。(docs.python.org)
import re
text = "xx123yy"
print(re.match(r"\d+", text)) # None
print(re.search(r"\d+", text)) # 匹配 "123"
print(re.fullmatch(r"\d+", text)) # None
不要用 search() 实现输入校验。search() 的目标是“找到一段符合的文本”,不是“证明整个输入合法”。
2.2 字符类:从多个字符中选择一个
字符类使用方括号:
[abc]
它匹配 a、b、c 中的任意一个字符。
范围写法依赖字符编码顺序:
[a-z] # ASCII 小写英文字母
[0-9] # ASCII 数字
[0-9A-Fa-f] # ASCII 十六进制字符
[^,] # 除逗号外的任意一个字符
字符类中的 ^ 只有位于开头时才表示取反:
import re
rx = re.compile(r"[^,]+")
print(rx.findall("a,b,,c")) # ['a', 'b', 'c']
+ 使整个字符类连续匹配一次或多次,所以空字段不会出现在结果中。若需要保留空字段,应使用 str.split() 或重新设计分隔逻辑,而不是简单地把所有分隔问题交给正则表达式。
字符类中的元字符规则与类外不同。例如,在 [()] 中,圆括号就是普通字符;但在类外,圆括号表示分组。
2.3 常用预定义字符类
在 str 模式下,Python 默认使用 Unicode 语义:
| 模式 | 含义 |
|---|---|
\d |
Unicode 十进制数字 |
\s |
Unicode 空白字符 |
\w |
Unicode 字母数字字符及下划线 |
\D、\S、\W |
对应集合的补集 |
. |
除换行外的任意字符 |
例如,\d 不仅匹配 ASCII 的 0 到 9:
import re
for value in ["123", "123", "١٢٣"]:
print(value, bool(re.fullmatch(r"\d+", value)))
在普通 str 模式下,这些数字都可能匹配,因为 \d 对应 Unicode 的十进制数字类别,而不是单纯的 [0-9]。如果协议明确规定只能使用 ASCII 字符,可以使用 re.ASCII:
import re
rx = re.compile(r"\d+", re.ASCII)
print(bool(rx.fullmatch("123"))) # True
print(bool(rx.fullmatch("123"))) # False
re.ASCII 会使 \w、\d、\s 及相关边界操作使用 ASCII 语义;它只对 Unicode str 模式有实际意义,对 bytes 模式不起作用。(docs.python.org)
2.4 重复、贪婪和非贪婪
常用量词如下:
| 量词 | 含义 |
|---|---|
* |
0 次或更多次 |
+ |
1 次或更多次 |
? |
0 次或 1 次 |
{m} |
恰好 m 次 |
{m,n} |
m 到 n 次 |
普通量词默认是贪婪的:在不使整个模式失败的前提下,尽量多吃字符。
import re
text = "<a> one <b> two"
print(re.search(r"<.*>", text).group())
print(re.search(r"<.*?>", text).group())
输出:
<a> one <b>
<a>
.* 先吃掉尽可能多的内容,再在后续模式失败时回退;.*? 则采用非贪婪策略,尽量少吃内容。Python HOWTO 也以 HTML 标签为例说明,直接使用 .* 会跨越多个分隔符,不能表达“遇到第一个右定界符就停止”的结构。(docs.python.org)
对于简单定界文本,优先使用否定字符类:
import re
rx = re.compile(r"<[^>]*>")
print(rx.findall("<a> one <b> two"))
# ['<a>', '<b>']
这个模式的逻辑是:标签内容可以是任意非 > 字符的序列。它比 .*? 更直接地表达了边界,但仍然不是真正的 HTML 解析器,无法处理 HTML 注释、脚本内容、属性中的复杂语法等结构。
三、锚点和边界:匹配位置,而不是匹配字符
锚点是零宽断言,不消耗输入字符。
3.1 ^、$、\A 和 \z
^:字符串开头;在MULTILINE模式下也匹配每行开头;$:字符串结尾,或结尾换行符之前;在MULTILINE模式下也匹配每行结尾;\A:只表示整个字符串的开头;\z:只表示整个字符串的结尾,Python 3.14 新增;\Z:在 Python 3.14 中与\z相同,用于兼容旧代码。(docs.python.org)
校验整个字符串时,fullmatch() 通常比手写锚点更清晰:
import re
pattern = re.compile(r"[A-Z]{2}-\d{6}-\d{3}")
value = "HZ-202503-001"
if match := pattern.fullmatch(value):
print("合法订单号:", match.group())
else:
print("格式错误")
如果要处理多行文本中的每一行,才考虑 re.MULTILINE:
import re
text = "INFO start\nERROR failed\nINFO stop"
print(re.findall(r"^ERROR.*$", text, re.MULTILINE))
# ['ERROR failed']
需要注意,re.match() 即使在 MULTILINE 模式下,也只从整个字符串开头尝试;若要寻找每行开头,应使用 search() 或 finditer() 配合 ^。(docs.python.org)
3.2 \b 是“单词字符边界”,不是自然语言词边界
\b 匹配 \w 和 \W 的交界,或者 \w 与字符串开头、结尾的交界。
import re
text = "cat scatter 猫"
print(re.findall(r"\bcat\b", text))
# ['cat']
它不会匹配 scatter 中的 cat,因为 cat 后面仍然是单词字符。
但中文、组合字符和自然语言词语会暴露这个定义的局限:\b 依据的是字符类别边界,不是分词结果。对 "数据库" 这样的中文文本,整个连续中文片段通常被视为连续的 \w 区域,并不会自动按汉语词语切分。
因此:
- 要匹配程序标识符,可使用
\b; - 要匹配自然语言词语,不能把
\b当作通用分词器; - 要匹配协议字段,应明确写出允许字符,而不是依赖
\w。
Python 3.14 还改变了 \B 的行为:它现在可以匹配空输入字符串。若代码依赖空字符串上的边界行为,应在升级到 3.14 时加入测试。(docs.python.org)
四、分组:组织结构、保存结果和复用约束
4.1 捕获分组和非捕获分组
圆括号默认创建捕获分组:
import re
rx = re.compile(r"([A-Z]{2})-(\d{6})-(\d{3})")
match = rx.fullmatch("HZ-202503-001")
print(match.groups())
# ('HZ', '202503', '001')
print(match.group(1)) # HZ
print(match.group(2)) # 202503
print(match.group(3)) # 001
分组编号按左括号出现的顺序,从 1 开始;group(0) 表示整个匹配。
如果只需要改变优先级或重复范围,不需要提取内容,应使用非捕获分组 (?:...):
import re
capturing = re.compile(r"(https?|ftp)://([^/]+)")
non_capturing = re.compile(r"(?:https?|ftp)://([^/]+)")
print(capturing.groups) # 2
print(non_capturing.groups) # 1
非捕获分组不会减少运行时匹配逻辑,但可以避免后续新增括号导致所有数字分组编号变化。
4.2 命名分组:让模式成为可维护接口
命名分组使用 (?P<name>...):
import re
rx = re.compile(
r"(?P<city>[A-Z]{2})-"
r"(?P<month>\d{6})-"
r"(?P<serial>\d{3})"
)
match = rx.fullmatch("HZ-202503-001")
print(match.groupdict())
# {'city': 'HZ', 'month': '202503', 'serial': '001'}
命名分组仍然拥有数字编号,但业务代码应优先使用名称:
record = {
"city": match.group("city"),
"month": match.group("month"),
"serial": int(match.group("serial")),
}
命名分组还可以在同一个模式中被反向引用。例如,匹配成对的引号:
import re
rx = re.compile(r"""(?P<quote>['"]).*?(?P=quote)""")
for text in ["'hello'", '"hello"', "'hello\""]:
match = rx.fullmatch(text)
print(text, bool(match))
输出:
'hello' True
"hello" True
'hello" False
(?P=quote) 要求当前位置出现与前面 quote 分组完全相同的文本。反向引用因此不是简单的“再匹配一个字符”,而是引入了对先前匹配结果的依赖。
4.3 分组的最后一次匹配
如果捕获分组位于重复结构中,最终只能通过 group() 取得它最后一次成功捕获的内容:
import re
match = re.fullmatch(r"([a-z])+", "abc")
print(match.group(1))
# c
模式确实捕获了 a、b、c 三次,但普通 Match 对象不会通过 group(1) 返回全部历史值。若需要所有元素,应使用 findall() 或 finditer():
print(re.findall(r"[a-z]", "abc"))
# ['a', 'b', 'c']
这是“捕获分组”和“重复匹配结果”之间常见的概念混淆。
五、断言:检查上下文但不消耗字符
5.1 前瞻
正向前瞻 (?=...) 要求后续内容匹配,但不消耗它:
import re
rx = re.compile(r"\d+(?=元)")
print(rx.findall("苹果 3元,橘子 5美元"))
# ['3']
3 后面紧跟“元”,所以匹配;5 后面是“美元”,不匹配。由于“元”没有被消耗,结果只包含数字。
负向前瞻 (?!...) 表示后续不能匹配:
import re
rx = re.compile(r"\bfoo\b(?!\.com)")
print(rx.findall("foo foo.com"))
# ['foo']
第二个 foo 后面是 .com,因此被排除。
5.2 后顾
正向后顾 (?<=...) 检查前面的内容:
import re
rx = re.compile(r"(?<=订单号:)[A-Z0-9-]+")
print(rx.search("订单号:HZ-001").group())
# HZ-001
标准库 re 要求后顾中的模式具有固定长度,因此下面的写法会失败:
# re.error / re.PatternError
# re.compile(r"(?<=订单号|编号:)\d+")
因为 订单号 和 编号: 的长度不同。可以改为把前缀放入捕获分组,或者拆成两个模式:
rx = re.compile(r"(?:订单号:|编号:)(?P<value>\d+)")
match = rx.search("编号:12345")
print(match.group("value"))
# 12345
正向后顾开头的模式通常不能通过 match() 在字符串位置 0 成功,因为它需要检查当前位置之前的字符;此时 search() 更合适。(docs.python.org)
六、回溯:匹配失败后如何重新尝试
Python 标准库 re 使用带回溯的匹配模型。理解回溯,可以从一个简单模式开始:
a*a
输入:
aaaa
匹配过程可以表示为:
a*贪婪地先消费 4 个a;- 最后的
a没有剩余字符,失败; - 引擎回退一个字符;
a*改为消费 3 个a;- 最后的
a消费第 4 个字符; - 整体成功。
也就是说,量词不仅保存“当前匹配了多少次”,还会保存可供未来失败时重新尝试的状态点。
替代分支也会产生选择顺序:
import re
match = re.search(r"ab|a", "ab")
print(match.group())
# ab
如果写成:
match = re.search(r"a|ab", "ab")
print(match.group())
# a
| 按从左到右尝试分支。第一个分支一旦完整成功,第二个分支不会为了寻找更长匹配而继续测试。(docs.python.org)
6.1 灾难性回溯
问题出现在可重复结构彼此重叠时:
import re
import time
rx = re.compile(r"^(a+)+$")
for size in [10, 20, 25]:
text = "a" * size + "!"
start = time.perf_counter()
result = rx.fullmatch(text)
elapsed = time.perf_counter() - start
print(size, result is not None, elapsed)
输入由若干个 a 加一个无法匹配的 ! 构成。为了证明失败,模式需要尝试大量拆分方式:
aaaaaaaaaa
= aaaaaaaaaa
= a + aaaaaaaaa
= aa + aaaaaaaa
= a + a + aaaaaaaa
= ...
外层 + 可以把内层 a+ 的结果拆成许多段;这些段又有大量不同长度组合。最终字符 ! 使所有路径失败,回溯树被完整探索,时间可能呈指数级增长。
这是一个模式结构问题,不是简单的“输入太长”问题。攻击者可以通过提交精心构造的文本触发 ReDoS(Regular Expression Denial of Service)。
常见危险形态包括:
(a+)+
(\w+\s*)+
(.+)+
(a|aa)+
它们的共同特征是:
- 嵌套量词;
- 相邻量词可以消费同一批字符;
- 多个替代分支有重叠前缀;
- 失败发生在输入后部,迫使引擎回退大量状态。
七、贪婪、非贪婪、占有量词和原子分组
Python 3.11 引入了占有量词和原子分组,Python 3.14 继续支持这些能力。(docs.python.org)
7.1 占有量词
普通贪婪量词:
a*
会留下回溯点。占有量词:
a*+
会尽可能多地匹配,但不允许未来失败时回退。
import re
print(re.fullmatch(r"a*a", "aaaa"))
# Match
print(re.fullmatch(r"a*+a", "aaaa"))
# None
第一个模式中,a* 先吃掉 4 个字符,随后回退一个,让最后的 a 成功。第二个模式中,a*+ 吃掉 4 个字符后锁定结果,最后的 a 没有输入可消费,因此整体失败。
占有量词适用于“已经确定不应回退”的结构,例如:
rx = re.compile(r"[A-Za-z0-9]++")
但不能把所有 * 都机械替换成 *+。如果后续模式需要前面的量词让出字符,占有量词会改变语义。
7.2 原子分组
原子分组使用 (?>...):
import re
normal = re.compile(r"(a+)+$")
atomic = re.compile(r"(?>(a+))+$")
原子分组成功离开后,内部保存的回溯点会被丢弃。后续模式失败时,只能回溯到原子分组之前,而不能重新拆解原子分组内部的匹配。
原子分组可以减少某些模式的回溯,但它不是自动的性能证明。若原子化的位置不符合业务语义,可能把本来应该成功的输入变成失败。(?>.*). 便是一个明显例子:.* 会吃掉全部字符,原子分组不允许回退,最后的 . 永远没有字符可匹配。(docs.python.org)
八、性能:先减少搜索空间,再考虑编译
8.1 不要用 .* 隐藏结构
比较下面两个模式:
import re
bad = re.compile(r".*ERROR")
good = re.compile(r"ERROR")
text = "x" * 1_000_000 + "ERROR"
print(bool(bad.search(text)))
print(bool(good.search(text)))
.*ERROR 的语义是“先尝试消费任意内容,再寻找 ERROR”,会引入额外回溯;ERROR 本身已经允许 search() 从不同位置寻找起点。Python HOWTO 明确指出,添加 .* 会破坏引擎根据首字符快速扫描的优化,并迫使它扫描到末尾后再回退。(docs.python.org)
更好的原则是:
- 已知固定前缀时,直接写固定前缀;
- 已知定界符时,使用否定字符类;
- 能使用字符串方法时,不要用正则表达式;
- 能用
fullmatch()表达完整校验时,不要用宽泛的search(); - 对不可信输入,限制最大长度。
例如判断固定前缀:
if value.startswith("ERROR"):
...
通常比 re.search(r"^ERROR", value) 更直接。
8.2 编译模式与缓存
re.compile() 把模式编译为 re.Pattern 对象,之后可重复调用:
import re
LOG_LINE = re.compile(
r"(?P<level>INFO|WARN|ERROR)\s+"
r"(?P<message>.*)"
)
for line in ["INFO started", "ERROR failed"]:
match = LOG_LINE.fullmatch(line)
if match:
print(match.groupdict())
输出:
{'level': 'INFO', 'message': 'started'}
{'level': 'ERROR', 'message': 'failed'}
重复使用同一模式时,显式编译可以表达意图,并且避免在业务循环中重复准备模式。模块级函数本身也会缓存最近使用的模式,因此只使用少量固定模式的程序通常不需要为了“必须编译”而改写所有代码。(docs.python.org)
真正需要避免的是在循环中不断生成大量动态模式:
for keyword in keywords:
re.search(fr"{keyword}", text)
如果 keyword 来自外部输入,还存在正则注入问题。若只想匹配字面文本,应使用 re.escape():
import re
keyword = "price+tax"
rx = re.compile(re.escape(keyword))
print(bool(rx.search("price+tax"))) # True
print(bool(rx.search("priceeeetax"))) # False
re.escape() 只用于把文本放入模式;不能直接用于 re.sub() 的替换字符串。替换字符串中的反斜杠有另一套规则。(docs.python.org)
8.3 findall() 还是 finditer()
findall() 直接构造结果列表:
numbers = re.findall(r"\d+", "a1 b22 c333")
print(numbers)
# ['1', '22', '333']
finditer() 返回迭代器,适合需要位置、分组或处理大文本的场景:
import re
text = "a1 b22 c333"
for match in re.finditer(r"\d+", text):
print(match.group(), match.span())
输出:
1 (1, 2)
22 (4, 6)
333 (9, 12)
正则匹配结果按从左到右的顺序返回,默认寻找非重叠匹配;空匹配也有明确的迭代规则。(docs.python.org)
九、Unicode:匹配的是代码点,不是用户感知字符
Python str 是 Unicode 文本序列,但“一个 Python 字符”不一定等于用户看到的一个字符。
import unicodedata
a = "é"
b = "e\u0301"
print(a == b) # False
print(len(a), len(b)) # 1 2
a 是预组合字符 U+00E9;b 是普通 e 加组合重音符号。视觉上可能相同,但代码点序列不同,正则表达式也会分别处理它们。
需要统一等价形式时,先做 Unicode 规范化:
import unicodedata
a = "é"
b = "e\u0301"
print(unicodedata.normalize("NFC", a)
== unicodedata.normalize("NFC", b))
# True
规范化策略必须由业务定义:
- NFC 通常适合把可组合字符统一为预组合形式;
- NFKC 还会进行兼容性折叠,可能把某些视觉变体转成普通字符;
- 规范化不是大小写转换,也不是语言分词。
正则表达式中的 . 通常匹配一个 Unicode 代码点,而不是一个完整的用户感知字符(grapheme cluster)。因此一个表情符号、旗帜或带多个组合标记的视觉字符可能被多个 . 分别匹配。若业务需要处理用户感知字符边界,标准库 re 不能简单等价替代完整的 Unicode 文本分割算法。
9.1 \w 不等于“英文单词”
import re
text = "hello 世界_123"
print(re.findall(r"\w+", text))
# ['hello', '世界_123']
在 Unicode str 模式下,\w 包含 Unicode 字母数字字符和下划线,而不是只有 [A-Za-z0-9_]。对用户名、协议字段、文件名等格式校验,通常应该显式定义字符范围:
USERNAME = re.compile(r"[A-Za-z][A-Za-z0-9_]{2,31}")
for value in ["alice_01", "世界_01", "1alice"]:
print(value, bool(USERNAME.fullmatch(value)))
输出:
alice_01 True
世界_01 False
1alice False
如果协议允许 Unicode 用户名,就应该明确允许哪些 Unicode 类别、是否规范化、是否大小写折叠,而不是直接把 \w+ 当作完整安全策略。
十、str 与 bytes:正则边界必须与解码边界一致
网络、文件和压缩数据通常最初是字节:
raw = b"\xe4\xb8\xad\xe6\x96\x87"
text = raw.decode("utf-8")
print(text)
# 中文
解码后使用 str 正则:
import re
rx = re.compile(r"中文")
print(rx.search(text))
如果尚未解码,可以使用 bytes 模式:
import re
rx = re.compile(rb"\r?\n")
print(rx.split(b"one\r\ntwo\nthree"))
# [b'one', b'two', b'three']
下面的混用会抛出类型错误:
import re
# TypeError
# re.search(r"\d+", b"123")
# TypeError
# re.search(rb"\d+", "123")
bytes 模式中的 \d、\s、\w 默认只按 ASCII 字节解释;它不会理解 UTF-8 中一个汉字的 Unicode 属性。(docs.python.org)
工程上应先决定边界:
- 在协议层按字节匹配:使用
bytes模式; - 在文本层按字符、语言和 Unicode 属性匹配:先正确解码为
str; - 不要把 UTF-8 的字节片段当成字符;
- 不要在任意字节位置截断 UTF-8 后再假设仍是合法文本。
例如,HTTP 响应头、二进制文件签名可以按 bytes 处理;网页正文、JSON 字段和用户输入则通常应在确认编码后转换为 str。
十一、标志位:改变匹配解释,而不是改变数据
常用标志包括:
re.IGNORECASE/re.I:忽略大小写;re.MULTILINE/re.M:改变^和$的行边界行为;re.DOTALL/re.S:让.也匹配换行;re.VERBOSE/re.X:允许空白和注释;re.ASCII/re.A:让部分预定义字符类使用 ASCII 语义。
复杂模式应使用 re.VERBOSE:
import re
DATE = re.compile(
r"""
(?P<year> \d{4} )
-
(?P<month> \d{2} )
-
(?P<day> \d{2} )
""",
re.VERBOSE,
)
match = DATE.fullmatch("2025-03-08")
print(match.groupdict())
# {'year': '2025', 'month': '03', 'day': '08'}
VERBOSE 模式会忽略模式中的非字符类空白,并把未转义的 # 后内容视为注释。因此要匹配字面量空格或 #,必须写入字符类或转义。官方文档给出了等价的紧凑模式与多行注释模式示例。(docs.python.org)
标志也可以局部作用:
import re
rx = re.compile(r"(?i:warning):\s+(?-i:ID-\d+)")
print(bool(rx.fullmatch("WARNING: ID-123"))) # True
print(bool(rx.fullmatch("warning: id-123"))) # False
(?i:...) 只让分组内部忽略大小写;(?-i:...) 则在局部关闭该标志。局部标志适合“前缀不区分大小写、协议值区分大小写”这类混合规则。
十二、替换:匹配逻辑和替换逻辑是两套语言
import re
text = "2025/03/08"
result = re.sub(
r"(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})",
r"\g<year>-\g<month>-\g<day>",
text,
)
print(result)
# 2025-03-08
替换字符串使用 \g<name> 或 \g<1> 引用捕获分组。命名引用比 \1 更安全,因为 \10 可能被解释为第十组,而不是第一组后接字符 0。
如果替换逻辑需要计算,应传入函数:
import re
def replace_number(match: re.Match[str]) -> str:
value = int(match.group())
return str(value * 2)
print(re.sub(r"\d+", replace_number, "a1 b20"))
# a2 b40
re.subn() 会额外返回替换次数:
result, count = re.subn(r"\s+", " ", "a b \n c")
print(result) # a b c
print(count) # 2
十三、一个完整例子:解析日志字段并验证边界
假设日志格式如下:
2025-03-08T14:30:05+08:00 [ERROR] request_id=abc123 status=500
目标是提取时间、级别、请求 ID 和状态码:
import re
from datetime import datetime
LOG_LINE = re.compile(
r"""
(?P<timestamp>
\d{4}-\d{2}-\d{2}
T
\d{2}:\d{2}:\d{2}
[+-]\d{2}:\d{2}
)
\s+\[(?P<level>INFO|WARN|ERROR)\]
\s+request_id=(?P<request_id>[A-Za-z0-9_-]+)
\s+status=(?P<status>[1-5]\d{2})
""",
re.VERBOSE,
)
line = "2025-03-08T14:30:05+08:00 [ERROR] request_id=abc123 status=500"
match = LOG_LINE.fullmatch(line)
if match is None:
raise ValueError("日志格式错误")
data = match.groupdict()
data["timestamp"] = datetime.fromisoformat(data["timestamp"])
data["status"] = int(data["status"])
print(data)
预期结果类似:
{
'timestamp': datetime.datetime(2025, 3, 8, 14, 30, 5, tzinfo=datetime.timezone(datetime.timedelta(seconds=28800))),
'level': 'ERROR',
'request_id': 'abc123',
'status': 500
}
这个模式中的几个约束各自负责不同问题:
- 时间字段使用固定数字结构,而不是宽泛的
.*; - 日志级别使用显式枚举;
- 请求 ID 限定为 ASCII 字符;
- 状态码限定为三位、首位为
1到5; fullmatch()防止末尾偷偷附加未验证内容;- 正则只负责词法结构,日期是否真实、时区偏移是否符合业务规则,仍交给
datetime或专门校验逻辑。
例如,2025-99-99T99:99:99+99:99 可能通过部分数字模式,却不是有效时间。这说明正则表达式适合做词法验证,不应承担所有语义验证。
十四、诊断失败:先判断是模式错,还是调用方式错
14.1 None 不是异常
没有匹配时,search()、match()、fullmatch() 返回 None,而不是抛出异常:
import re
match = re.search(r"\d+", "abc")
if match is None:
print("没有数字")
else:
print(match.group())
错误写法是直接调用:
# AttributeError: 'NoneType' object has no attribute 'group'
# re.search(r"\d+", "abc").group()
14.2 编译错误与无匹配不同
非法模式会在编译阶段抛出 re.PatternError:
import re
try:
re.compile(r"[")
except re.PatternError as exc:
print(exc)
而合法模式匹配不到文本不是模式错误:
print(re.fullmatch(r"\d+", "abc"))
# None
Python 3.14 文档将 PatternError 定义为模式无效或编译、匹配阶段发生相关错误时的异常;“没有匹配结果”本身不是异常。(docs.python.org)
14.3 用 repr() 检查不可见字符
text = "abc\n"
print(repr(text))
# 'abc\n'
很多“明明一样”的匹配失败,实际原因是:
- 末尾存在
\n或\r\n; - 文本包含不间断空格;
- Unicode 字符是不同的规范化形式;
- 模式使用了
\d,输入却要求 ASCII 数字; match()被误用来做全文搜索;bytes与str混用。
调试正则时,先打印 repr(text),再查看 match.span()、match.groupdict() 和模式本身。
十五、正则表达式的边界
正则表达式最适合处理具有局部结构的文本:
- 日志字段;
- 简单协议字段;
- 标识符;
- 日期、版本号、编号的词法形式;
- 查找和替换;
- 从半结构化文本提取局部信息。
它不适合直接替代:
- HTML/XML 解析器;
- JSON 解析器;
- SQL 解析器;
- 自然语言分词器;
- 需要递归嵌套的语法解析器;
- 复杂 Unicode grapheme、语言学边界处理。
一个实用判断方法是:如果规则需要“记住任意深度的嵌套结构”,或者需要理解转义、注释、引号和上下文状态,应该使用对应的解析器。正则表达式可以作为解析前的筛选器,但不应伪装成完整语法分析器。
最终可以把正则表达式的设计过程归纳为以下推导链:
- 明确输入是
str还是bytes; - 明确目标是查找、前缀匹配还是完整校验;
- 把输入拆成固定文本、字符类、重复结构和边界;
- 需要提取的数据使用命名分组;
- 需要上下文约束时使用前瞻或固定长度后顾;
- 检查量词是否存在重叠和嵌套;
- 对不可信输入限制长度,并避免不必要的回溯;
- 对 Unicode 文本先确定规范化、大小写和字符边界策略;
- 将正则负责的词法验证与 Python 代码负责的语义验证分开。
掌握这些层次后,正则表达式就不再是靠试错拼接的符号串,而会变成一种可以分析、验证和优化的文本匹配程序。
系列导航与关联阅读
- 系列入口:Python 完整学习路线:从语言模型、并发到 Web、数据、AI 与生产交付
- 上一篇:Python 日期时间:datetime、时区、DST、时间戳和序列化
- 下一篇:Python collections:deque、Counter、defaultdict、ChainMap 与队列
- 延伸:Python 字符串、bytes 与 Unicode:编码、解码和文本边界
- 延伸:Python 网络采集:Requests、HTTPX、BeautifulSoup、限速和合规
官方资料
本文依据 Python 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。

评论
0 条讨论