WR Blog 加载中...
返回文章
Python正则表达式文本处理

Python 正则表达式:匹配模型、分组、回溯、性能和 Unicode

Python 正则表达式:匹配模型、分组、回溯、性能和 Unicode封面

Python 基础体系 · 第 41/112 篇。示例统一以 Python 3.14 为语言基线;第三方库使用与其兼容的现代稳定版本,版本敏感行为会单独说明。

Python 正则表达式:匹配模型、分组、回溯、性能和 Unicode

正则表达式不是“字符串通配符”的集合,而是一种描述字符串结构的模式语言。Python 通过标准库 re 提供正则表达式支持,模式可以作用于 strbytes,但两种类型不能混用:str 模式必须匹配 strbytes 模式必须匹配 bytes。(docs.python.org)

本文使用 Python 3.14 语法,重点解释五个相互关联的问题:

  1. 一个模式如何描述匹配模型;
  2. 分组如何保存、复用和约束文本;
  3. 回溯如何决定结果,以及为什么会导致灾难性性能;
  4. 如何设计更稳定、更快的模式;
  5. 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

它匹配连续的三个字符 cat

正则表达式的基本组合规则是连接。如果模式 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]

它匹配 abc 中的任意一个字符。

范围写法依赖字符编码顺序:

[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 的 09

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} mn

普通量词默认是贪婪的:在不使整个模式失败的前提下,尽量多吃字符。

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

模式确实捕获了 abc 三次,但普通 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

匹配过程可以表示为:

  1. a* 贪婪地先消费 4 个 a
  2. 最后的 a 没有剩余字符,失败;
  3. 引擎回退一个字符;
  4. a* 改为消费 3 个 a
  5. 最后的 a 消费第 4 个字符;
  6. 整体成功。

也就是说,量词不仅保存“当前匹配了多少次”,还会保存可供未来失败时重新尝试的状态点。

替代分支也会产生选择顺序:

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+ 当作完整安全策略。


十、strbytes:正则边界必须与解码边界一致

网络、文件和压缩数据通常最初是字节:

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 字符;
  • 状态码限定为三位、首位为 15
  • 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() 被误用来做全文搜索;
  • bytesstr 混用。

调试正则时,先打印 repr(text),再查看 match.span()match.groupdict() 和模式本身。


十五、正则表达式的边界

正则表达式最适合处理具有局部结构的文本:

  • 日志字段;
  • 简单协议字段;
  • 标识符;
  • 日期、版本号、编号的词法形式;
  • 查找和替换;
  • 从半结构化文本提取局部信息。

它不适合直接替代:

  • HTML/XML 解析器;
  • JSON 解析器;
  • SQL 解析器;
  • 自然语言分词器;
  • 需要递归嵌套的语法解析器;
  • 复杂 Unicode grapheme、语言学边界处理。

一个实用判断方法是:如果规则需要“记住任意深度的嵌套结构”,或者需要理解转义、注释、引号和上下文状态,应该使用对应的解析器。正则表达式可以作为解析前的筛选器,但不应伪装成完整语法分析器。

最终可以把正则表达式的设计过程归纳为以下推导链:

  1. 明确输入是 str 还是 bytes
  2. 明确目标是查找、前缀匹配还是完整校验;
  3. 把输入拆成固定文本、字符类、重复结构和边界;
  4. 需要提取的数据使用命名分组;
  5. 需要上下文约束时使用前瞻或固定长度后顾;
  6. 检查量词是否存在重叠和嵌套;
  7. 对不可信输入限制长度,并避免不必要的回溯;
  8. 对 Unicode 文本先确定规范化、大小写和字符边界策略;
  9. 将正则负责的词法验证与 Python 代码负责的语义验证分开。

掌握这些层次后,正则表达式就不再是靠试错拼接的符号串,而会变成一种可以分析、验证和优化的文本匹配程序。


系列导航与关联阅读

官方资料

本文依据 Python 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。

评论

0 条讨论
0/1000
还没有评论,来聊聊你的看法
WR Blog 加载中...
返回文章
Python正则表达式文本处理

Python 正则表达式:匹配模型、分组、回溯、性能和 Unicode

Python 正则表达式:匹配模型、分组、回溯、性能和 Unicode封面

Python 基础体系 · 第 41/112 篇。示例统一以 Python 3.14 为语言基线;第三方库使用与其兼容的现代稳定版本,版本敏感行为会单独说明。

Python 正则表达式:匹配模型、分组、回溯、性能和 Unicode

正则表达式不是“字符串通配符”的集合,而是一种描述字符串结构的模式语言。Python 通过标准库 re 提供正则表达式支持,模式可以作用于 strbytes,但两种类型不能混用:str 模式必须匹配 strbytes 模式必须匹配 bytes。(docs.python.org)

本文使用 Python 3.14 语法,重点解释五个相互关联的问题:

  1. 一个模式如何描述匹配模型;
  2. 分组如何保存、复用和约束文本;
  3. 回溯如何决定结果,以及为什么会导致灾难性性能;
  4. 如何设计更稳定、更快的模式;
  5. 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

它匹配连续的三个字符 cat

正则表达式的基本组合规则是连接。如果模式 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]

它匹配 abc 中的任意一个字符。

范围写法依赖字符编码顺序:

[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 的 09

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} mn

普通量词默认是贪婪的:在不使整个模式失败的前提下,尽量多吃字符。

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

模式确实捕获了 abc 三次,但普通 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

匹配过程可以表示为:

  1. a* 贪婪地先消费 4 个 a
  2. 最后的 a 没有剩余字符,失败;
  3. 引擎回退一个字符;
  4. a* 改为消费 3 个 a
  5. 最后的 a 消费第 4 个字符;
  6. 整体成功。

也就是说,量词不仅保存“当前匹配了多少次”,还会保存可供未来失败时重新尝试的状态点。

替代分支也会产生选择顺序:

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+ 当作完整安全策略。


十、strbytes:正则边界必须与解码边界一致

网络、文件和压缩数据通常最初是字节:

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 字符;
  • 状态码限定为三位、首位为 15
  • 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() 被误用来做全文搜索;
  • bytesstr 混用。

调试正则时,先打印 repr(text),再查看 match.span()match.groupdict() 和模式本身。


十五、正则表达式的边界

正则表达式最适合处理具有局部结构的文本:

  • 日志字段;
  • 简单协议字段;
  • 标识符;
  • 日期、版本号、编号的词法形式;
  • 查找和替换;
  • 从半结构化文本提取局部信息。

它不适合直接替代:

  • HTML/XML 解析器;
  • JSON 解析器;
  • SQL 解析器;
  • 自然语言分词器;
  • 需要递归嵌套的语法解析器;
  • 复杂 Unicode grapheme、语言学边界处理。

一个实用判断方法是:如果规则需要“记住任意深度的嵌套结构”,或者需要理解转义、注释、引号和上下文状态,应该使用对应的解析器。正则表达式可以作为解析前的筛选器,但不应伪装成完整语法分析器。

最终可以把正则表达式的设计过程归纳为以下推导链:

  1. 明确输入是 str 还是 bytes
  2. 明确目标是查找、前缀匹配还是完整校验;
  3. 把输入拆成固定文本、字符类、重复结构和边界;
  4. 需要提取的数据使用命名分组;
  5. 需要上下文约束时使用前瞻或固定长度后顾;
  6. 检查量词是否存在重叠和嵌套;
  7. 对不可信输入限制长度,并避免不必要的回溯;
  8. 对 Unicode 文本先确定规范化、大小写和字符边界策略;
  9. 将正则负责的词法验证与 Python 代码负责的语义验证分开。

掌握这些层次后,正则表达式就不再是靠试错拼接的符号串,而会变成一种可以分析、验证和优化的文本匹配程序。


系列导航与关联阅读

官方资料

本文依据 Python 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。

评论

0 条讨论
0/1000
还没有评论,来聊聊你的看法
、`\\A` 和 `\\z`\n\n- `^`:字符串开头;在 `MULTILINE` 模式下也匹配每行开头;\n- ` Python 正则表达式:匹配模型、分组、回溯、性能和 Unicode - WR Blog
WR Blog 加载中...
返回文章
Python正则表达式文本处理

Python 正则表达式:匹配模型、分组、回溯、性能和 Unicode

Python 正则表达式:匹配模型、分组、回溯、性能和 Unicode封面

Python 基础体系 · 第 41/112 篇。示例统一以 Python 3.14 为语言基线;第三方库使用与其兼容的现代稳定版本,版本敏感行为会单独说明。

Python 正则表达式:匹配模型、分组、回溯、性能和 Unicode

正则表达式不是“字符串通配符”的集合,而是一种描述字符串结构的模式语言。Python 通过标准库 re 提供正则表达式支持,模式可以作用于 strbytes,但两种类型不能混用:str 模式必须匹配 strbytes 模式必须匹配 bytes。(docs.python.org)

本文使用 Python 3.14 语法,重点解释五个相互关联的问题:

  1. 一个模式如何描述匹配模型;
  2. 分组如何保存、复用和约束文本;
  3. 回溯如何决定结果,以及为什么会导致灾难性性能;
  4. 如何设计更稳定、更快的模式;
  5. 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

它匹配连续的三个字符 cat

正则表达式的基本组合规则是连接。如果模式 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]

它匹配 abc 中的任意一个字符。

范围写法依赖字符编码顺序:

[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 的 09

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} mn

普通量词默认是贪婪的:在不使整个模式失败的前提下,尽量多吃字符。

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

模式确实捕获了 abc 三次,但普通 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

匹配过程可以表示为:

  1. a* 贪婪地先消费 4 个 a
  2. 最后的 a 没有剩余字符,失败;
  3. 引擎回退一个字符;
  4. a* 改为消费 3 个 a
  5. 最后的 a 消费第 4 个字符;
  6. 整体成功。

也就是说,量词不仅保存“当前匹配了多少次”,还会保存可供未来失败时重新尝试的状态点。

替代分支也会产生选择顺序:

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+ 当作完整安全策略。


十、strbytes:正则边界必须与解码边界一致

网络、文件和压缩数据通常最初是字节:

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 字符;
  • 状态码限定为三位、首位为 15
  • 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() 被误用来做全文搜索;
  • bytesstr 混用。

调试正则时,先打印 repr(text),再查看 match.span()match.groupdict() 和模式本身。


十五、正则表达式的边界

正则表达式最适合处理具有局部结构的文本:

  • 日志字段;
  • 简单协议字段;
  • 标识符;
  • 日期、版本号、编号的词法形式;
  • 查找和替换;
  • 从半结构化文本提取局部信息。

它不适合直接替代:

  • HTML/XML 解析器;
  • JSON 解析器;
  • SQL 解析器;
  • 自然语言分词器;
  • 需要递归嵌套的语法解析器;
  • 复杂 Unicode grapheme、语言学边界处理。

一个实用判断方法是:如果规则需要“记住任意深度的嵌套结构”,或者需要理解转义、注释、引号和上下文状态,应该使用对应的解析器。正则表达式可以作为解析前的筛选器,但不应伪装成完整语法分析器。

最终可以把正则表达式的设计过程归纳为以下推导链:

  1. 明确输入是 str 还是 bytes
  2. 明确目标是查找、前缀匹配还是完整校验;
  3. 把输入拆成固定文本、字符类、重复结构和边界;
  4. 需要提取的数据使用命名分组;
  5. 需要上下文约束时使用前瞻或固定长度后顾;
  6. 检查量词是否存在重叠和嵌套;
  7. 对不可信输入限制长度,并避免不必要的回溯;
  8. 对 Unicode 文本先确定规范化、大小写和字符边界策略;
  9. 将正则负责的词法验证与 Python 代码负责的语义验证分开。

掌握这些层次后,正则表达式就不再是靠试错拼接的符号串,而会变成一种可以分析、验证和优化的文本匹配程序。


系列导航与关联阅读

官方资料

本文依据 Python 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。

评论

0 条讨论
0/1000
还没有评论,来聊聊你的看法
:字符串结尾,或结尾换行符之前;在 `MULTILINE` 模式下也匹配每行结尾;\n- `\\A`:只表示整个字符串的开头;\n- `\\z`:只表示整个字符串的结尾,Python 3.14 新增;\n- `\\Z`:在 Python 3.14 中与 `\\z` 相同,用于兼容旧代码。([docs.python.org](https://docs.python.org/3.14/library/re.html))\n\n校验整个字符串时,`fullmatch()` 通常比手写锚点更清晰:\n\n```python\nimport re\n\npattern = re.compile(r\"[A-Z]{2}-\\d{6}-\\d{3}\")\n\nvalue = \"HZ-202503-001\"\n\nif match := pattern.fullmatch(value):\n print(\"合法订单号:\", match.group())\nelse:\n print(\"格式错误\")\n```\n\n如果要处理多行文本中的每一行,才考虑 `re.MULTILINE`:\n\n```python\nimport re\n\ntext = \"INFO start\\nERROR failed\\nINFO stop\"\n\nprint(re.findall(r\"^ERROR.*$\", text, re.MULTILINE))\n# ['ERROR failed']\n```\n\n需要注意,`re.match()` 即使在 `MULTILINE` 模式下,也只从整个字符串开头尝试;若要寻找每行开头,应使用 `search()` 或 `finditer()` 配合 `^`。([docs.python.org](https://docs.python.org/3.14/library/re.html))\n\n---\n\n### 3.2 `\\b` 是“单词字符边界”,不是自然语言词边界\n\n`\\b` 匹配 `\\w` 和 `\\W` 的交界,或者 `\\w` 与字符串开头、结尾的交界。\n\n```python\nimport re\n\ntext = \"cat scatter 猫\"\n\nprint(re.findall(r\"\\bcat\\b\", text))\n# ['cat']\n```\n\n它不会匹配 `scatter` 中的 `cat`,因为 `cat` 后面仍然是单词字符。\n\n但中文、组合字符和自然语言词语会暴露这个定义的局限:`\\b` 依据的是字符类别边界,不是分词结果。对 `\"数据库\"` 这样的中文文本,整个连续中文片段通常被视为连续的 `\\w` 区域,并不会自动按汉语词语切分。\n\n因此:\n\n- 要匹配程序标识符,可使用 `\\b`;\n- 要匹配自然语言词语,不能把 `\\b` 当作通用分词器;\n- 要匹配协议字段,应明确写出允许字符,而不是依赖 `\\w`。\n\nPython 3.14 还改变了 `\\B` 的行为:它现在可以匹配空输入字符串。若代码依赖空字符串上的边界行为,应在升级到 3.14 时加入测试。([docs.python.org](https://docs.python.org/3.14/library/re.html))\n\n---\n\n## 四、分组:组织结构、保存结果和复用约束\n\n### 4.1 捕获分组和非捕获分组\n\n圆括号默认创建**捕获分组**:\n\n```python\nimport re\n\nrx = re.compile(r\"([A-Z]{2})-(\\d{6})-(\\d{3})\")\nmatch = rx.fullmatch(\"HZ-202503-001\")\n\nprint(match.groups())\n# ('HZ', '202503', '001')\n\nprint(match.group(1)) # HZ\nprint(match.group(2)) # 202503\nprint(match.group(3)) # 001\n```\n\n分组编号按左括号出现的顺序,从 `1` 开始;`group(0)` 表示整个匹配。\n\n如果只需要改变优先级或重复范围,不需要提取内容,应使用非捕获分组 `(?:...)`:\n\n```python\nimport re\n\ncapturing = re.compile(r\"(https?|ftp)://([^/]+)\")\nnon_capturing = re.compile(r\"(?:https?|ftp)://([^/]+)\")\n\nprint(capturing.groups) # 2\nprint(non_capturing.groups) # 1\n```\n\n非捕获分组不会减少运行时匹配逻辑,但可以避免后续新增括号导致所有数字分组编号变化。\n\n---\n\n### 4.2 命名分组:让模式成为可维护接口\n\n命名分组使用 `(?P\u003cname>...)`:\n\n```python\nimport re\n\nrx = re.compile(\n r\"(?P\u003ccity>[A-Z]{2})-\"\n r\"(?P\u003cmonth>\\d{6})-\"\n r\"(?P\u003cserial>\\d{3})\"\n)\n\nmatch = rx.fullmatch(\"HZ-202503-001\")\n\nprint(match.groupdict())\n# {'city': 'HZ', 'month': '202503', 'serial': '001'}\n```\n\n命名分组仍然拥有数字编号,但业务代码应优先使用名称:\n\n```python\nrecord = {\n \"city\": match.group(\"city\"),\n \"month\": match.group(\"month\"),\n \"serial\": int(match.group(\"serial\")),\n}\n```\n\n命名分组还可以在同一个模式中被反向引用。例如,匹配成对的引号:\n\n```python\nimport re\n\nrx = re.compile(r\"\"\"(?P\u003cquote>['\"]).*?(?P=quote)\"\"\")\n\nfor text in [\"'hello'\", '\"hello\"', \"'hello\\\"\"]:\n match = rx.fullmatch(text)\n print(text, bool(match))\n```\n\n输出:\n\n```text\n'hello' True\n\"hello\" True\n'hello\" False\n```\n\n`(?P=quote)` 要求当前位置出现与前面 `quote` 分组完全相同的文本。反向引用因此不是简单的“再匹配一个字符”,而是引入了对先前匹配结果的依赖。\n\n---\n\n### 4.3 分组的最后一次匹配\n\n如果捕获分组位于重复结构中,最终只能通过 `group()` 取得它最后一次成功捕获的内容:\n\n```python\nimport re\n\nmatch = re.fullmatch(r\"([a-z])+\", \"abc\")\n\nprint(match.group(1))\n# c\n```\n\n模式确实捕获了 `a`、`b`、`c` 三次,但普通 `Match` 对象不会通过 `group(1)` 返回全部历史值。若需要所有元素,应使用 `findall()` 或 `finditer()`:\n\n```python\nprint(re.findall(r\"[a-z]\", \"abc\"))\n# ['a', 'b', 'c']\n```\n\n这是“捕获分组”和“重复匹配结果”之间常见的概念混淆。\n\n---\n\n## 五、断言:检查上下文但不消耗字符\n\n### 5.1 前瞻\n\n正向前瞻 `(?=...)` 要求后续内容匹配,但不消耗它:\n\n```python\nimport re\n\nrx = re.compile(r\"\\d+(?=元)\")\n\nprint(rx.findall(\"苹果 3元,橘子 5美元\"))\n# ['3']\n```\n\n`3` 后面紧跟“元”,所以匹配;`5` 后面是“美元”,不匹配。由于“元”没有被消耗,结果只包含数字。\n\n负向前瞻 `(?!...)` 表示后续不能匹配:\n\n```python\nimport re\n\nrx = re.compile(r\"\\bfoo\\b(?!\\.com)\")\n\nprint(rx.findall(\"foo foo.com\"))\n# ['foo']\n```\n\n第二个 `foo` 后面是 `.com`,因此被排除。\n\n---\n\n### 5.2 后顾\n\n正向后顾 `(?\u003c=...)` 检查前面的内容:\n\n```python\nimport re\n\nrx = re.compile(r\"(?\u003c=订单号:)[A-Z0-9-]+\")\n\nprint(rx.search(\"订单号:HZ-001\").group())\n# HZ-001\n```\n\n标准库 `re` 要求后顾中的模式具有固定长度,因此下面的写法会失败:\n\n```python\n# re.error / re.PatternError\n# re.compile(r\"(?\u003c=订单号|编号:)\\d+\")\n```\n\n因为 `订单号` 和 `编号:` 的长度不同。可以改为把前缀放入捕获分组,或者拆成两个模式:\n\n```python\nrx = re.compile(r\"(?:订单号:|编号:)(?P\u003cvalue>\\d+)\")\n\nmatch = rx.search(\"编号:12345\")\nprint(match.group(\"value\"))\n# 12345\n```\n\n正向后顾开头的模式通常不能通过 `match()` 在字符串位置 0 成功,因为它需要检查当前位置之前的字符;此时 `search()` 更合适。([docs.python.org](https://docs.python.org/3.14/library/re.html))\n\n---\n\n## 六、回溯:匹配失败后如何重新尝试\n\nPython 标准库 `re` 使用带回溯的匹配模型。理解回溯,可以从一个简单模式开始:\n\n```text\na*a\n```\n\n输入:\n\n```text\naaaa\n```\n\n匹配过程可以表示为:\n\n1. `a*` 贪婪地先消费 4 个 `a`;\n2. 最后的 `a` 没有剩余字符,失败;\n3. 引擎回退一个字符;\n4. `a*` 改为消费 3 个 `a`;\n5. 最后的 `a` 消费第 4 个字符;\n6. 整体成功。\n\n也就是说,量词不仅保存“当前匹配了多少次”,还会保存可供未来失败时重新尝试的状态点。\n\n替代分支也会产生选择顺序:\n\n```python\nimport re\n\nmatch = re.search(r\"ab|a\", \"ab\")\nprint(match.group())\n# ab\n```\n\n如果写成:\n\n```python\nmatch = re.search(r\"a|ab\", \"ab\")\nprint(match.group())\n# a\n```\n\n`|` 按从左到右尝试分支。第一个分支一旦完整成功,第二个分支不会为了寻找更长匹配而继续测试。([docs.python.org](https://docs.python.org/3.14/library/re.html))\n\n---\n\n### 6.1 灾难性回溯\n\n问题出现在**可重复结构彼此重叠**时:\n\n```python\nimport re\nimport time\n\nrx = re.compile(r\"^(a+)+$\")\n\nfor size in [10, 20, 25]:\n text = \"a\" * size + \"!\"\n start = time.perf_counter()\n result = rx.fullmatch(text)\n elapsed = time.perf_counter() - start\n print(size, result is not None, elapsed)\n```\n\n输入由若干个 `a` 加一个无法匹配的 `!` 构成。为了证明失败,模式需要尝试大量拆分方式:\n\n```text\naaaaaaaaaa\n= aaaaaaaaaa\n= a + aaaaaaaaa\n= aa + aaaaaaaa\n= a + a + aaaaaaaa\n= ...\n```\n\n外层 `+` 可以把内层 `a+` 的结果拆成许多段;这些段又有大量不同长度组合。最终字符 `!` 使所有路径失败,回溯树被完整探索,时间可能呈指数级增长。\n\n这是一个**模式结构问题**,不是简单的“输入太长”问题。攻击者可以通过提交精心构造的文本触发 ReDoS(Regular Expression Denial of Service)。\n\n常见危险形态包括:\n\n```text\n(a+)+\n(\\w+\\s*)+\n(.+)+\n(a|aa)+\n```\n\n它们的共同特征是:\n\n- 嵌套量词;\n- 相邻量词可以消费同一批字符;\n- 多个替代分支有重叠前缀;\n- 失败发生在输入后部,迫使引擎回退大量状态。\n\n---\n\n## 七、贪婪、非贪婪、占有量词和原子分组\n\nPython 3.11 引入了占有量词和原子分组,Python 3.14 继续支持这些能力。([docs.python.org](https://docs.python.org/3.14/library/re.html))\n\n### 7.1 占有量词\n\n普通贪婪量词:\n\n```text\na*\n```\n\n会留下回溯点。占有量词:\n\n```text\na*+\n```\n\n会尽可能多地匹配,但不允许未来失败时回退。\n\n```python\nimport re\n\nprint(re.fullmatch(r\"a*a\", \"aaaa\"))\n# Match\n\nprint(re.fullmatch(r\"a*+a\", \"aaaa\"))\n# None\n```\n\n第一个模式中,`a*` 先吃掉 4 个字符,随后回退一个,让最后的 `a` 成功。第二个模式中,`a*+` 吃掉 4 个字符后锁定结果,最后的 `a` 没有输入可消费,因此整体失败。\n\n占有量词适用于“已经确定不应回退”的结构,例如:\n\n```python\nrx = re.compile(r\"[A-Za-z0-9]++\")\n```\n\n但不能把所有 `*` 都机械替换成 `*+`。如果后续模式需要前面的量词让出字符,占有量词会改变语义。\n\n---\n\n### 7.2 原子分组\n\n原子分组使用 `(?>...)`:\n\n```python\nimport re\n\nnormal = re.compile(r\"(a+)+$\")\natomic = re.compile(r\"(?>(a+))+$\")\n```\n\n原子分组成功离开后,内部保存的回溯点会被丢弃。后续模式失败时,只能回溯到原子分组之前,而不能重新拆解原子分组内部的匹配。\n\n原子分组可以减少某些模式的回溯,但它不是自动的性能证明。若原子化的位置不符合业务语义,可能把本来应该成功的输入变成失败。`(?>.*).` 便是一个明显例子:`.*` 会吃掉全部字符,原子分组不允许回退,最后的 `.` 永远没有字符可匹配。([docs.python.org](https://docs.python.org/3.14/library/re.html))\n\n---\n\n## 八、性能:先减少搜索空间,再考虑编译\n\n### 8.1 不要用 `.*` 隐藏结构\n\n比较下面两个模式:\n\n```python\nimport re\n\nbad = re.compile(r\".*ERROR\")\ngood = re.compile(r\"ERROR\")\n\ntext = \"x\" * 1_000_000 + \"ERROR\"\n\nprint(bool(bad.search(text)))\nprint(bool(good.search(text)))\n```\n\n`.*ERROR` 的语义是“先尝试消费任意内容,再寻找 ERROR”,会引入额外回溯;`ERROR` 本身已经允许 `search()` 从不同位置寻找起点。Python HOWTO 明确指出,添加 `.*` 会破坏引擎根据首字符快速扫描的优化,并迫使它扫描到末尾后再回退。([docs.python.org](https://docs.python.org/3.14/howto/regex.html))\n\n更好的原则是:\n\n- 已知固定前缀时,直接写固定前缀;\n- 已知定界符时,使用否定字符类;\n- 能使用字符串方法时,不要用正则表达式;\n- 能用 `fullmatch()` 表达完整校验时,不要用宽泛的 `search()`;\n- 对不可信输入,限制最大长度。\n\n例如判断固定前缀:\n\n```python\nif value.startswith(\"ERROR\"):\n ...\n```\n\n通常比 `re.search(r\"^ERROR\", value)` 更直接。\n\n---\n\n### 8.2 编译模式与缓存\n\n`re.compile()` 把模式编译为 `re.Pattern` 对象,之后可重复调用:\n\n```python\nimport re\n\nLOG_LINE = re.compile(\n r\"(?P\u003clevel>INFO|WARN|ERROR)\\s+\"\n r\"(?P\u003cmessage>.*)\"\n)\n\nfor line in [\"INFO started\", \"ERROR failed\"]:\n match = LOG_LINE.fullmatch(line)\n if match:\n print(match.groupdict())\n```\n\n输出:\n\n```text\n{'level': 'INFO', 'message': 'started'}\n{'level': 'ERROR', 'message': 'failed'}\n```\n\n重复使用同一模式时,显式编译可以表达意图,并且避免在业务循环中重复准备模式。模块级函数本身也会缓存最近使用的模式,因此只使用少量固定模式的程序通常不需要为了“必须编译”而改写所有代码。([docs.python.org](https://docs.python.org/3.14/library/re.html))\n\n真正需要避免的是在循环中不断生成大量动态模式:\n\n```python\nfor keyword in keywords:\n re.search(fr\"{keyword}\", text)\n```\n\n如果 `keyword` 来自外部输入,还存在正则注入问题。若只想匹配字面文本,应使用 `re.escape()`:\n\n```python\nimport re\n\nkeyword = \"price+tax\"\nrx = re.compile(re.escape(keyword))\n\nprint(bool(rx.search(\"price+tax\"))) # True\nprint(bool(rx.search(\"priceeeetax\"))) # False\n```\n\n`re.escape()` 只用于把文本放入**模式**;不能直接用于 `re.sub()` 的替换字符串。替换字符串中的反斜杠有另一套规则。([docs.python.org](https://docs.python.org/3.14/library/re.html))\n\n---\n\n### 8.3 `findall()` 还是 `finditer()`\n\n`findall()` 直接构造结果列表:\n\n```python\nnumbers = re.findall(r\"\\d+\", \"a1 b22 c333\")\nprint(numbers)\n# ['1', '22', '333']\n```\n\n`finditer()` 返回迭代器,适合需要位置、分组或处理大文本的场景:\n\n```python\nimport re\n\ntext = \"a1 b22 c333\"\n\nfor match in re.finditer(r\"\\d+\", text):\n print(match.group(), match.span())\n```\n\n输出:\n\n```text\n1 (1, 2)\n22 (4, 6)\n333 (9, 12)\n```\n\n正则匹配结果按从左到右的顺序返回,默认寻找非重叠匹配;空匹配也有明确的迭代规则。([docs.python.org](https://docs.python.org/3.14/library/re.html))\n\n---\n\n## 九、Unicode:匹配的是代码点,不是用户感知字符\n\nPython `str` 是 Unicode 文本序列,但“一个 Python 字符”不一定等于用户看到的一个字符。\n\n```python\nimport unicodedata\n\na = \"é\"\nb = \"e\\u0301\"\n\nprint(a == b) # False\nprint(len(a), len(b)) # 1 2\n```\n\n`a` 是预组合字符 U+00E9;`b` 是普通 `e` 加组合重音符号。视觉上可能相同,但代码点序列不同,正则表达式也会分别处理它们。\n\n需要统一等价形式时,先做 Unicode 规范化:\n\n```python\nimport unicodedata\n\na = \"é\"\nb = \"e\\u0301\"\n\nprint(unicodedata.normalize(\"NFC\", a)\n == unicodedata.normalize(\"NFC\", b))\n# True\n```\n\n规范化策略必须由业务定义:\n\n- NFC 通常适合把可组合字符统一为预组合形式;\n- NFKC 还会进行兼容性折叠,可能把某些视觉变体转成普通字符;\n- 规范化不是大小写转换,也不是语言分词。\n\n正则表达式中的 `.` 通常匹配一个 Unicode 代码点,而不是一个完整的用户感知字符(grapheme cluster)。因此一个表情符号、旗帜或带多个组合标记的视觉字符可能被多个 `.` 分别匹配。若业务需要处理用户感知字符边界,标准库 `re` 不能简单等价替代完整的 Unicode 文本分割算法。\n\n---\n\n### 9.1 `\\w` 不等于“英文单词”\n\n```python\nimport re\n\ntext = \"hello 世界_123\"\n\nprint(re.findall(r\"\\w+\", text))\n# ['hello', '世界_123']\n```\n\n在 Unicode `str` 模式下,`\\w` 包含 Unicode 字母数字字符和下划线,而不是只有 `[A-Za-z0-9_]`。对用户名、协议字段、文件名等格式校验,通常应该显式定义字符范围:\n\n```python\nUSERNAME = re.compile(r\"[A-Za-z][A-Za-z0-9_]{2,31}\")\n\nfor value in [\"alice_01\", \"世界_01\", \"1alice\"]:\n print(value, bool(USERNAME.fullmatch(value)))\n```\n\n输出:\n\n```text\nalice_01 True\n世界_01 False\n1alice False\n```\n\n如果协议允许 Unicode 用户名,就应该明确允许哪些 Unicode 类别、是否规范化、是否大小写折叠,而不是直接把 `\\w+` 当作完整安全策略。\n\n---\n\n## 十、`str` 与 `bytes`:正则边界必须与解码边界一致\n\n网络、文件和压缩数据通常最初是字节:\n\n```python\nraw = b\"\\xe4\\xb8\\xad\\xe6\\x96\\x87\"\ntext = raw.decode(\"utf-8\")\n\nprint(text)\n# 中文\n```\n\n解码后使用 `str` 正则:\n\n```python\nimport re\n\nrx = re.compile(r\"中文\")\nprint(rx.search(text))\n```\n\n如果尚未解码,可以使用 `bytes` 模式:\n\n```python\nimport re\n\nrx = re.compile(rb\"\\r?\\n\")\nprint(rx.split(b\"one\\r\\ntwo\\nthree\"))\n# [b'one', b'two', b'three']\n```\n\n下面的混用会抛出类型错误:\n\n```python\nimport re\n\n# TypeError\n# re.search(r\"\\d+\", b\"123\")\n\n# TypeError\n# re.search(rb\"\\d+\", \"123\")\n```\n\n`bytes` 模式中的 `\\d`、`\\s`、`\\w` 默认只按 ASCII 字节解释;它不会理解 UTF-8 中一个汉字的 Unicode 属性。([docs.python.org](https://docs.python.org/3.14/library/re.html))\n\n工程上应先决定边界:\n\n- 在协议层按字节匹配:使用 `bytes` 模式;\n- 在文本层按字符、语言和 Unicode 属性匹配:先正确解码为 `str`;\n- 不要把 UTF-8 的字节片段当成字符;\n- 不要在任意字节位置截断 UTF-8 后再假设仍是合法文本。\n\n例如,HTTP 响应头、二进制文件签名可以按 `bytes` 处理;网页正文、JSON 字段和用户输入则通常应在确认编码后转换为 `str`。\n\n---\n\n## 十一、标志位:改变匹配解释,而不是改变数据\n\n常用标志包括:\n\n- `re.IGNORECASE` / `re.I`:忽略大小写;\n- `re.MULTILINE` / `re.M`:改变 `^` 和 ` Python 正则表达式:匹配模型、分组、回溯、性能和 Unicode - WR Blog
WR Blog 加载中...
返回文章
Python正则表达式文本处理

Python 正则表达式:匹配模型、分组、回溯、性能和 Unicode

Python 正则表达式:匹配模型、分组、回溯、性能和 Unicode封面

Python 基础体系 · 第 41/112 篇。示例统一以 Python 3.14 为语言基线;第三方库使用与其兼容的现代稳定版本,版本敏感行为会单独说明。

Python 正则表达式:匹配模型、分组、回溯、性能和 Unicode

正则表达式不是“字符串通配符”的集合,而是一种描述字符串结构的模式语言。Python 通过标准库 re 提供正则表达式支持,模式可以作用于 strbytes,但两种类型不能混用:str 模式必须匹配 strbytes 模式必须匹配 bytes。(docs.python.org)

本文使用 Python 3.14 语法,重点解释五个相互关联的问题:

  1. 一个模式如何描述匹配模型;
  2. 分组如何保存、复用和约束文本;
  3. 回溯如何决定结果,以及为什么会导致灾难性性能;
  4. 如何设计更稳定、更快的模式;
  5. 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

它匹配连续的三个字符 cat

正则表达式的基本组合规则是连接。如果模式 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]

它匹配 abc 中的任意一个字符。

范围写法依赖字符编码顺序:

[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 的 09

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} mn

普通量词默认是贪婪的:在不使整个模式失败的前提下,尽量多吃字符。

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

模式确实捕获了 abc 三次,但普通 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

匹配过程可以表示为:

  1. a* 贪婪地先消费 4 个 a
  2. 最后的 a 没有剩余字符,失败;
  3. 引擎回退一个字符;
  4. a* 改为消费 3 个 a
  5. 最后的 a 消费第 4 个字符;
  6. 整体成功。

也就是说,量词不仅保存“当前匹配了多少次”,还会保存可供未来失败时重新尝试的状态点。

替代分支也会产生选择顺序:

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+ 当作完整安全策略。


十、strbytes:正则边界必须与解码边界一致

网络、文件和压缩数据通常最初是字节:

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 字符;
  • 状态码限定为三位、首位为 15
  • 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() 被误用来做全文搜索;
  • bytesstr 混用。

调试正则时,先打印 repr(text),再查看 match.span()match.groupdict() 和模式本身。


十五、正则表达式的边界

正则表达式最适合处理具有局部结构的文本:

  • 日志字段;
  • 简单协议字段;
  • 标识符;
  • 日期、版本号、编号的词法形式;
  • 查找和替换;
  • 从半结构化文本提取局部信息。

它不适合直接替代:

  • HTML/XML 解析器;
  • JSON 解析器;
  • SQL 解析器;
  • 自然语言分词器;
  • 需要递归嵌套的语法解析器;
  • 复杂 Unicode grapheme、语言学边界处理。

一个实用判断方法是:如果规则需要“记住任意深度的嵌套结构”,或者需要理解转义、注释、引号和上下文状态,应该使用对应的解析器。正则表达式可以作为解析前的筛选器,但不应伪装成完整语法分析器。

最终可以把正则表达式的设计过程归纳为以下推导链:

  1. 明确输入是 str 还是 bytes
  2. 明确目标是查找、前缀匹配还是完整校验;
  3. 把输入拆成固定文本、字符类、重复结构和边界;
  4. 需要提取的数据使用命名分组;
  5. 需要上下文约束时使用前瞻或固定长度后顾;
  6. 检查量词是否存在重叠和嵌套;
  7. 对不可信输入限制长度,并避免不必要的回溯;
  8. 对 Unicode 文本先确定规范化、大小写和字符边界策略;
  9. 将正则负责的词法验证与 Python 代码负责的语义验证分开。

掌握这些层次后,正则表达式就不再是靠试错拼接的符号串,而会变成一种可以分析、验证和优化的文本匹配程序。


系列导航与关联阅读

官方资料

本文依据 Python 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。

评论

0 条讨论
0/1000
还没有评论,来聊聊你的看法
的行边界行为;\n- `re.DOTALL` / `re.S`:让 `.` 也匹配换行;\n- `re.VERBOSE` / `re.X`:允许空白和注释;\n- `re.ASCII` / `re.A`:让部分预定义字符类使用 ASCII 语义。\n\n复杂模式应使用 `re.VERBOSE`:\n\n```python\nimport re\n\nDATE = re.compile(\n r\"\"\"\n (?P\u003cyear> \\d{4} )\n -\n (?P\u003cmonth> \\d{2} )\n -\n (?P\u003cday> \\d{2} )\n \"\"\",\n re.VERBOSE,\n)\n\nmatch = DATE.fullmatch(\"2025-03-08\")\nprint(match.groupdict())\n# {'year': '2025', 'month': '03', 'day': '08'}\n```\n\n`VERBOSE` 模式会忽略模式中的非字符类空白,并把未转义的 `#` 后内容视为注释。因此要匹配字面量空格或 `#`,必须写入字符类或转义。官方文档给出了等价的紧凑模式与多行注释模式示例。([docs.python.org](https://docs.python.org/3.14/library/re.html))\n\n标志也可以局部作用:\n\n```python\nimport re\n\nrx = re.compile(r\"(?i:warning):\\s+(?-i:ID-\\d+)\")\n\nprint(bool(rx.fullmatch(\"WARNING: ID-123\"))) # True\nprint(bool(rx.fullmatch(\"warning: id-123\"))) # False\n```\n\n`(?i:...)` 只让分组内部忽略大小写;`(?-i:...)` 则在局部关闭该标志。局部标志适合“前缀不区分大小写、协议值区分大小写”这类混合规则。\n\n---\n\n## 十二、替换:匹配逻辑和替换逻辑是两套语言\n\n```python\nimport re\n\ntext = \"2025/03/08\"\n\nresult = re.sub(\n r\"(?P\u003cyear>\\d{4})/(?P\u003cmonth>\\d{2})/(?P\u003cday>\\d{2})\",\n r\"\\g\u003cyear>-\\g\u003cmonth>-\\g\u003cday>\",\n text,\n)\n\nprint(result)\n# 2025-03-08\n```\n\n替换字符串使用 `\\g\u003cname>` 或 `\\g\u003c1>` 引用捕获分组。命名引用比 `\\1` 更安全,因为 `\\10` 可能被解释为第十组,而不是第一组后接字符 `0`。\n\n如果替换逻辑需要计算,应传入函数:\n\n```python\nimport re\n\ndef replace_number(match: re.Match[str]) -> str:\n value = int(match.group())\n return str(value * 2)\n\nprint(re.sub(r\"\\d+\", replace_number, \"a1 b20\"))\n# a2 b40\n```\n\n`re.subn()` 会额外返回替换次数:\n\n```python\nresult, count = re.subn(r\"\\s+\", \" \", \"a b \\n c\")\nprint(result) # a b c\nprint(count) # 2\n```\n\n---\n\n## 十三、一个完整例子:解析日志字段并验证边界\n\n假设日志格式如下:\n\n```text\n2025-03-08T14:30:05+08:00 [ERROR] request_id=abc123 status=500\n```\n\n目标是提取时间、级别、请求 ID 和状态码:\n\n```python\nimport re\nfrom datetime import datetime\n\nLOG_LINE = re.compile(\n r\"\"\"\n (?P\u003ctimestamp>\n \\d{4}-\\d{2}-\\d{2}\n T\n \\d{2}:\\d{2}:\\d{2}\n [+-]\\d{2}:\\d{2}\n )\n \\s+\\[(?P\u003clevel>INFO|WARN|ERROR)\\]\n \\s+request_id=(?P\u003crequest_id>[A-Za-z0-9_-]+)\n \\s+status=(?P\u003cstatus>[1-5]\\d{2})\n \"\"\",\n re.VERBOSE,\n)\n\nline = \"2025-03-08T14:30:05+08:00 [ERROR] request_id=abc123 status=500\"\n\nmatch = LOG_LINE.fullmatch(line)\nif match is None:\n raise ValueError(\"日志格式错误\")\n\ndata = match.groupdict()\ndata[\"timestamp\"] = datetime.fromisoformat(data[\"timestamp\"])\ndata[\"status\"] = int(data[\"status\"])\n\nprint(data)\n```\n\n预期结果类似:\n\n```text\n{\n 'timestamp': datetime.datetime(2025, 3, 8, 14, 30, 5, tzinfo=datetime.timezone(datetime.timedelta(seconds=28800))),\n 'level': 'ERROR',\n 'request_id': 'abc123',\n 'status': 500\n}\n```\n\n这个模式中的几个约束各自负责不同问题:\n\n- 时间字段使用固定数字结构,而不是宽泛的 `.*`;\n- 日志级别使用显式枚举;\n- 请求 ID 限定为 ASCII 字符;\n- 状态码限定为三位、首位为 `1` 到 `5`;\n- `fullmatch()` 防止末尾偷偷附加未验证内容;\n- 正则只负责词法结构,日期是否真实、时区偏移是否符合业务规则,仍交给 `datetime` 或专门校验逻辑。\n\n例如,`2025-99-99T99:99:99+99:99` 可能通过部分数字模式,却不是有效时间。这说明正则表达式适合做**词法验证**,不应承担所有语义验证。\n\n---\n\n## 十四、诊断失败:先判断是模式错,还是调用方式错\n\n### 14.1 `None` 不是异常\n\n没有匹配时,`search()`、`match()`、`fullmatch()` 返回 `None`,而不是抛出异常:\n\n```python\nimport re\n\nmatch = re.search(r\"\\d+\", \"abc\")\n\nif match is None:\n print(\"没有数字\")\nelse:\n print(match.group())\n```\n\n错误写法是直接调用:\n\n```python\n# AttributeError: 'NoneType' object has no attribute 'group'\n# re.search(r\"\\d+\", \"abc\").group()\n```\n\n### 14.2 编译错误与无匹配不同\n\n非法模式会在编译阶段抛出 `re.PatternError`:\n\n```python\nimport re\n\ntry:\n re.compile(r\"[\")\nexcept re.PatternError as exc:\n print(exc)\n```\n\n而合法模式匹配不到文本不是模式错误:\n\n```python\nprint(re.fullmatch(r\"\\d+\", \"abc\"))\n# None\n```\n\nPython 3.14 文档将 `PatternError` 定义为模式无效或编译、匹配阶段发生相关错误时的异常;“没有匹配结果”本身不是异常。([docs.python.org](https://docs.python.org/3.14/library/re.html))\n\n### 14.3 用 `repr()` 检查不可见字符\n\n```python\ntext = \"abc\\n\"\nprint(repr(text))\n# 'abc\\n'\n```\n\n很多“明明一样”的匹配失败,实际原因是:\n\n- 末尾存在 `\\n` 或 `\\r\\n`;\n- 文本包含不间断空格;\n- Unicode 字符是不同的规范化形式;\n- 模式使用了 `\\d`,输入却要求 ASCII 数字;\n- `match()` 被误用来做全文搜索;\n- `bytes` 与 `str` 混用。\n\n调试正则时,先打印 `repr(text)`,再查看 `match.span()`、`match.groupdict()` 和模式本身。\n\n---\n\n## 十五、正则表达式的边界\n\n正则表达式最适合处理具有局部结构的文本:\n\n- 日志字段;\n- 简单协议字段;\n- 标识符;\n- 日期、版本号、编号的词法形式;\n- 查找和替换;\n- 从半结构化文本提取局部信息。\n\n它不适合直接替代:\n\n- HTML/XML 解析器;\n- JSON 解析器;\n- SQL 解析器;\n- 自然语言分词器;\n- 需要递归嵌套的语法解析器;\n- 复杂 Unicode grapheme、语言学边界处理。\n\n一个实用判断方法是:如果规则需要“记住任意深度的嵌套结构”,或者需要理解转义、注释、引号和上下文状态,应该使用对应的解析器。正则表达式可以作为解析前的筛选器,但不应伪装成完整语法分析器。\n\n最终可以把正则表达式的设计过程归纳为以下推导链:\n\n1. 明确输入是 `str` 还是 `bytes`;\n2. 明确目标是查找、前缀匹配还是完整校验;\n3. 把输入拆成固定文本、字符类、重复结构和边界;\n4. 需要提取的数据使用命名分组;\n5. 需要上下文约束时使用前瞻或固定长度后顾;\n6. 检查量词是否存在重叠和嵌套;\n7. 对不可信输入限制长度,并避免不必要的回溯;\n8. 对 Unicode 文本先确定规范化、大小写和字符边界策略;\n9. 将正则负责的词法验证与 Python 代码负责的语义验证分开。\n\n掌握这些层次后,正则表达式就不再是靠试错拼接的符号串,而会变成一种可以分析、验证和优化的文本匹配程序。\n\n---\n\n## 系列导航与关联阅读\n\n- 系列入口:[Python 完整学习路线:从语言模型、并发到 Web、数据、AI 与生产交付](https://wrblog.cn/articles/b0228e4e-70ca-59ce-b17d-bc115cbb11f7)\n- 上一篇:[Python 日期时间:datetime、时区、DST、时间戳和序列化](https://wrblog.cn/articles/26da4139-7c70-5502-8402-1c86671b2405)\n- 下一篇:[Python collections:deque、Counter、defaultdict、ChainMap 与队列](https://wrblog.cn/articles/61cb3da8-bab7-5969-850f-13a6e1a8492c)\n- 延伸:[Python 字符串、bytes 与 Unicode:编码、解码和文本边界](https://wrblog.cn/articles/6cac13d2-5834-524f-b105-bdb1d25c7f2e)\n- 延伸:[Python 网络采集:Requests、HTTPX、BeautifulSoup、限速和合规](https://wrblog.cn/articles/e166c2e3-dfb8-5c73-860e-b4f1af438f43)\n\n## 官方资料\n\n- [Python 3.14 Standard Library](https://docs.python.org/3.14/library/)\n- [Python HOWTOs](https://docs.python.org/3.14/howto/)\n\n> 本文依据 Python 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。\n","tags":["Python","正则表达式","文本处理"],"likeCount":0,"commentCount":0,"createdByUserId":"10000000000","createdByDisplayName":"小郝","createdByAvatar":"/public/profile/10000000000/avatar/2026/08/04/db02b81c-42f2-441b-8a80-61370cdbb581.webp","publishTime":"2026-09-01 15:42:05","updateTime":"2026-09-01 15:47:09"}},"status":200,"locale":"zh-CN","theme":"light"}