正则表达式是「用一行代码解决一天工作量,或者制造一周调试噩梦」的典型工具。它的威力与风险都来自同一个特性:你可以写出一条自己都看不懂、却在生产环境跑了三年的模式。
Python 的文本处理能力远不止 re:字符串方法、unicodedata、difflib、textwrap、csv/json 各司其职。本文按「正则核心 → 性能陷阱 → 实战解析 → 非正则工具」的顺序组织,目标是让你在需要文本处理时能第一时间判断「该不该用正则」。
1. re 模块核心
1.1 六个入口函数
import re
re.match(r"\d+", "123abc") # 从开头匹配,返回 Match 或 None
re.search(r"\d+", "abc123") # 任意位置查找
re.fullmatch(r"\d+", "123") # 必须整串匹配
re.findall(r"\d+", "a1b22c333") # 返回所有匹配的列表
re.finditer(r"\d+", "a1b22c333") # 返回 Match 迭代器
re.sub(r"\d+", "#", "a1b22c333") # 替换
re.split(r"\s+", "a b c") # 按模式切分
| 函数 | 返回 | 何时用 |
|---|---|---|
match | Match/None | 校验前缀 |
search | Match/None | 找第一个 |
fullmatch | Match/None | 严格校验整串 |
findall | list | 只要结果值 |
finditer | iterator | 需要位置或分组 |
sub | str | 替换 |
split | list | 切分 |
1.2 编译与缓存
PATTERN = re.compile(r"(?P<level>INFO|WARN|ERROR)\s+(?P<msg>.+)")
m = PATTERN.search(line)
re 模块内部有缓存(默认 512 条),所以 re.search(pat, s) 并非每次都重新编译。但显式 compile 仍有两层收益:一是不受缓存淘汰影响(模式多时缓存会抖动),二是把模式定义集中到模块级常量,便于测试与复用。
# 反例:在热循环里反复构造
for line in lines:
re.search(r"\d{4}-\d{2}-\d{2}", line) # 依赖缓存,模式多时会退化
# 正例
DATE = re.compile(r"\d{4}-\d{2}-\d{2}")
for line in lines:
DATE.search(line)
1.3 标志位
re.compile(r"^abc$", re.MULTILINE) # ^ $ 匹配每行首尾
re.compile(r"a.b", re.DOTALL) # . 匹配换行
re.compile(r"hello", re.IGNORECASE) # 忽略大小写
re.compile(r"#.*", re.VERBOSE) # 允许空白与注释
re.compile(r"\w+", re.ASCII) # \w 只匹配 ASCII
re.VERBOSE 对复杂模式极其有用——它让正则可以排版成可读的段落:
PHONE = re.compile(r"""
(?P<country>\+\d{1,3})? # 国家码,可选
[\s-]* # 分隔符
(?P<area>\(?\d{2,4}\)?) # 区号
[\s-]*
(?P<number>\d{6,8}) # 号码
""", re.VERBOSE)
re.ASCII 值得特别注意:Python 3 里 \w、\d、\s 默认按 Unicode 语义匹配,\w 会匹配中文、全角字符等。若你只想匹配英文数字下划线,必须显式加 re.ASCII。
1.4 Match 对象
m = re.search(r"(?P<y>\d{4})-(?P<m>\d{2})", "2026-10")
m.group(0) # 整个匹配
m.group("y") # 命名分组
m.groups() # 所有分组元组
m.groupdict() # 命名分组字典
m.start(), m.end(), m.span()
groupdict() 是把匹配结果转成结构体的关键——日志解析、URL 拆解几乎都靠它:
record = m.groupdict() # {'y': '2026', 'm': '10'}
2. 语法要点
2.1 字符类与量词
| 语法 | 含义 | 注意 |
|---|---|---|
[abc] | 集合内任一 | — |
[^abc] | 取反 | — |
\d \w \s | 数字/词字符/空白 | Unicode 语义 |
\D \W \S | 上述的补集 | — |
\b | 词边界 | 零宽断言 |
* + ? | 0+/1+/0或1 | 默认贪婪 |
*? +? ?? | 懒惰版本 | 尽量少匹配 |
{m,n} | 次数区间 | {3,} 至少 3 |
{m,n}? | 懒惰区间 | — |
贪婪与懒惰的差别是初学最容易踩的坑:
s = "<b>bold</b> and <i>italic</i>"
re.findall(r"<.+>", s) # ['<b>bold</b> and <i>italic</i>'] 贪婪
re.findall(r"<.+?>", s) # ['<b>', '</b>', '<i>', '</i>'] 懒惰
2.2 分组与反向引用
re.search(r"(\w+)\s+\1", "hello hello") # 反向引用 \1
re.sub(r"(\d{4})-(\d{2})-(\d{2})", r"\3/\2/\1", "2026-10-07") # 2026/10/07 → 07/10/2026
re.sub(r"(?P<a>\w+)@(?P<b>\w+)", r"\g<b>@\g<a>", "user@host")
sub 的替换串里用 \1 或 \g<name> 引用分组。注意 \g<name> 的 <> 是必需的——若写 \gname 会被解析成字面量。
2.3 断言(零宽)
r"\d+(?=元)" # 后顾:数字后面必须是「元」,但不消耗「元」
r"(?<=\$)\d+" # 前置:数字前面必须是 $
r"(?!\d)\w+" # 负向后顾
r"(?<!\d)\w+" # 负向前置
断言不消耗字符,是「只匹配不替换」场景的关键。例如给金额加千分位但不动货币符号:
re.sub(r"(?<=\d)(?=(\d{3})+$)", ",", "1234567") # 1,234,567
注意 Python 的 re 要求前置断言是固定宽度((?<=abc) 可以,(?<=a|bc) 不行)。需要变宽断言时用第三方 regex 模块。
3. 性能与安全
3.1 灾难性回溯(Catastrophic Backtracking)
# 危险模式:嵌套量词
BAD = re.compile(r"(a+)+b")
BAD.match("a" * 30) # 指数级爆炸,卡死
(a+)+ 对同一段 a 有指数种划分方式,当末尾的 b 不匹配时,引擎会穷举所有组合。修复方式有三种:
# 方案 1:消除嵌套量词
re.compile(r"a+b")
# 方案 2:用原子组(Python 3.11+)
re.compile(r"(?>a+)+b")
# 方案 3:用占有量词(Python 3.11+)
re.compile(r"a++b")
| 构造 | 回溯行为 | 可用版本 |
|---|---|---|
(a+)+ | 指数回溯 | 全部 |
(?>a+) 原子组 | 不回溯 | 3.11+ |
a++ 占有量词 | 不回溯 | 3.11+ |
regex 模块 | 可选 POSIX 语义 | 需安装 |
更根本的原则是:任何「外层量词套内层量词」且内外可以匹配同一字符的模式,都是潜在炸弹。评审正则时先找这种结构。
3.2 用字符串方法替代正则
并非所有匹配都需要正则。当模式是固定子串或简单前后缀时,str 方法快一个数量级:
| 需求 | 正则 | 更快的方法 |
|---|---|---|
| 判断前缀 | re.match("abc", s) | s.startswith("abc") |
| 判断包含 | re.search("abc", s) | "abc" in s |
| 按单字符切分 | re.split(",", s) | s.split(",") |
| 替换固定串 | re.sub("a", "b", s) | s.replace("a", "b") |
| 提取数字 | re.findall(r"\d+", s) | s.isdigit() / filter |
# 反例
if re.match(r"^/api/", path): ...
# 正例
if path.startswith("/api/"): ...
3.3 性能测量
import timeit
t = timeit.timeit(
lambda: PATTERN.search("2026-10-07 INFO started"),
number=100_000,
)
print(f"{t * 10:.2f} us/op")
优化前先测量。常见结论是:预编译 + 精简模式能带来 2~5 倍提升,而「把正则改成字符串方法」在固定串场景下能带来 10 倍以上提升。
3.4 拒绝服务防护
用户可控的输入 + 用户可控的模式 = 灾难。若你的产品允许用户输入正则(如日志过滤、脱敏规则),必须:
- 限制模式长度与嵌套深度
- 在子进程中执行并设超时(
signal.alarm或multiprocessing+join(timeout)) - 用
regex模块的timeout参数(Python 的re无此参数)
import regex # 第三方模块,支持超时
try:
regex.search(user_pattern, text, timeout=0.5)
except TimeoutError:
raise BadRequest("模式过于复杂")
4. 实战:结构化文本解析
4.1 日志解析
LOG = re.compile(r"""
^
(?P<ts>\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2})
\s+
(?P<level>DEBUG|INFO|WARN|ERROR)
\s+
(?P<logger>[\w.]+)
\s+
(?P<msg>.*?)
(?:\s+\((?P<file>[\w./]+):(?P<line>\d+)\))?
$
""", re.VERBOSE)
def parse_line(line: str) -> dict | None:
m = LOG.match(line)
return m.groupdict() if m else None
line = "2026-10-07 22:30:00 ERROR app.db connection refused (db.py:88)"
# {'ts': '2026-10-07 22:30:00', 'level': 'ERROR', 'logger': 'app.db',
# 'msg': 'connection refused', 'file': 'db.py', 'line': '88'}
要点:用 (?P<msg>.*?) 懒惰匹配消息体,再用可选分组 (?:...)? 抓尾部的位置信息。行尾锚点 $ 配合 re.MULTILINE 可批量解析多行文本。
4.2 用替换函数做条件替换
def mask_secret(m: re.Match) -> str:
value = m.group("secret")
if len(value) <= 4:
return "****"
return value[:2] + "*" * (len(value) - 4) + value[-2:]
SECRET = re.compile(r'(?P<key>token|password|api_key)=(?P<secret>[^\s&]+)',
re.IGNORECASE)
SECRET.sub(mask_secret, "token=abcdef123456 password=x")
# 'token=ab********56 password=****'
sub 接受函数作为替换器,可以按匹配内容动态决定替换值。日志脱敏、URL 参数改写都靠这个能力。
4.3 解析键值对与查询串
from urllib.parse import parse_qs, urlparse
url = "https://example.com/search?q=python&page=2&tag=a&tag=b"
parsed = urlparse(url)
params = parse_qs(parsed.query) # {'q': ['python'], 'page': ['2'], 'tag': ['a','b']}
解析 URL 时永远不要自己写正则——urllib.parse 已处理了百分号编码、IPv6 主机、多值参数等所有边界情况。自己写的正则几乎必然在某个编码场景下出错。
4.4 结构化格式优先
import csv, json
from io import StringIO
# CSV:用 csv 模块而非 split(",")
rows = list(csv.DictReader(StringIO(text)))
# JSON:用 json 模块而非正则
data = json.loads(text)
| 格式 | 正确工具 | 用正则的后果 |
|---|---|---|
| CSV | csv | 引号内逗号被误切 |
| JSON | json | 嵌套/转义解析失败 |
| URL | urllib.parse | 编码处理出错 |
| XML/HTML | lxml / BeautifulSoup | 嵌套标签无法正确处理 |
| YAML | pyyaml | 缩进语义丢失 |
| 日志(非结构化) | 正则 ✅ | — |
原则是:只要有标准解析器就用标准解析器,正则只用于「无固定语法」的半结构化文本。HTML 解析尤其如此,正则处理嵌套标签的理论基础就不成立。
4.5 提取而非匹配
# 提取所有邮箱(简化模式,实际校验应交由 email-validator)
EMAIL = re.compile(r"[\w.+-]+@[\w-]+\.[\w.-]+")
found = EMAIL.findall(text)
# 提取并去重保序
unique = list(dict.fromkeys(found))
注意「提取用的正则」和「校验用的正则」标准不同:提取可以宽松(宁可多抓再过滤),校验应当严格或干脆用专门的库。用一条正则同时承担两个职责,是许多 bug 的来源。
5. Unicode 与文本规范化
5.1 规范化(Normalization)
同一个视觉字符可能有多种 Unicode 编码。é 可以是单个码点 U+00E9,也可以是 e + 组合重音 U+0301。直接比较会得到「看起来一样但不相等」的结果:
import unicodedata
a = "é" # é 单码点
b = "é" # e + 组合重音
print(a == b) # False
a2 = unicodedata.normalize("NFC", a)
b2 = unicodedata.normalize("NFC", b)
print(a2 == b2) # True
| 形式 | 含义 | 用途 |
|---|---|---|
| NFC | 组合为最简码点 | 存储、比较(推荐) |
| NFD | 分解为基字符 + 组合符 | 去重音、音标处理 |
| NFKC | 兼容分解 + 组合 | 搜索、去全半角差异 |
| NFKD | 兼容分解 | 最激进的归一化 |
# 去掉重音:先分解再去掉组合符
def strip_accents(s: str) -> str:
nfkd = unicodedata.normalize("NFKD", s)
return "".join(c for c in nfkd if not unicodedata.combining(c))
strip_accents("café") # 'cafe'
凡是用户输入的唯一性判断(用户名、邮箱、标签),都必须先规范化再比较,否则会出现两个「看起来一样」的账号。
5.2 全角半角与大小写
# 全角转半角(NFKC 会一并处理全角字母数字)
unicodedata.normalize("NFKC", "ABC123") # 'ABC123'
# 大小写折叠:比 lower() 更彻底
"ß".lower() # 'ß'
"ß".casefold() # 'ss'
"İ".casefold() # 'i̇'
# 判断类别
unicodedata.category("A") # 'Lu' 大写字母
unicodedata.category("1") # 'Nd' 十进制数字
unicodedata.category(" ") # 'Zs' 空格分隔符
大小写不敏感的比较应统一用 casefold() 而非 lower():lower() 只做简单映射,casefold() 实现了 Unicode 的大小写折叠规则,对德语 ß、土耳其语 İ 等特殊字符才正确。
5.3 文本清洗流水线
import re, unicodedata
CONTROL = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
WS = re.compile(r"\s+")
def clean(text: str) -> str:
text = unicodedata.normalize("NFC", text)
text = CONTROL.sub("", text) # 去控制字符
text = WS.sub(" ", text) # 折叠空白
return text.strip()
顺序很重要:先规范化再做其它处理,否则后续基于码点的规则可能在规范化后失效。控制字符要保留 \t\n\r(\x09\x0a\x0d),它们在很多格式里有语义。
5.4 模糊匹配与差异对比
import difflib
difflib.SequenceMatcher(None, "kitten", "sitting").ratio() # 0.615
# 找最相似的候选
difflib.get_close_matches("pythn", ["python", "pytorch", "perl"], n=1)
# ['python']
# 生成差异
for line in difflib.unified_diff(old.splitlines(), new.splitlines(),
lineterm="", fromfile="old", tofile="new"):
print(line)
difflib 适合「拼写纠错提示」「配置漂移对比」「生成 patch」这类场景。若需要更严格的相似度算法(如 Jaro-Winkler、编辑距离),用 rapidfuzz 库,速度与精度都更好。
5.5 排版工具
import textwrap
textwrap.wrap("一段很长的中文文本……", width=40) # 折行列表
textwrap.fill(text, width=40, initial_indent=" ") # 折行并缩进
textwrap.dedent("""\
def f():
return 1
""") # 去掉公共缩进
textwrap.dedent 是处理多行字符串的利器——三引号字符串常带意外缩进,dedent 能自动剥离公共前缀。生成帮助文本、SQL、代码片段时都值得用。
6. 工程建议
6.1 正则的可维护性
# 反例:一行塞满
re.compile(r"^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?(Z|[+-]\d{2}:\d{2})$")
# 正例:命名分组 + VERBOSE
TIMESTAMP = re.compile(r"""
^
(?P<year>\d{4})-(?P<mon>\d{2})-(?P<day>\d{2})
T
(?P<hour>\d{2}):(?P<min>\d{2}):(?P<sec>\d{2})
(?:\.(?P<frac>\d+))?
(?P<tz>Z|[+-]\d{2}:\d{2})
$
""", re.VERBOSE)
三条纪律:用命名分组(groupdict() 得到字典,字段顺序变更不影响调用方);用 VERBOSE 排版;每条正则写一个单元测试(正例、反例、边界各一条)。
6.2 测试正则
import pytest
@pytest.mark.parametrize("text,expected", [
("2026-10-07", True),
("2026-1-7", False),
("2026-13-07", True), # 语法匹配,语义不校验
])
def test_date_pattern(text, expected):
assert bool(DATE.fullmatch(text)) is expected
注意最后一条:正则擅长语法校验,不擅长语义校验(闰年、月份天数)。日期语义校验应交给 datetime.strptime 或 dateutil。这与 Python 文件 IO 与序列化
中「解析交给专业库」的思路一致。
6.3 与抓取流程的配合
文本提取是抓取管线的中段:拿到 HTML/JSON 后用选择器或正则抽字段,再做清洗入库。这部分与 Python 网页抓取与自动化 中的解析章节直接衔接——能用 CSS 选择器/XPath 就别用正则,正则留给 URL、时间戳、混合文本这类没有结构的地方。
6.4 与 shell 工具的分工
# 简单过滤交给 grep/sed/awk,避免启动 Python 进程
grep -oE 'ERROR [^ ]+' app.log | sort | uniq -c | sort -rn | head
# 复杂逻辑才落到 Python
python -c "import re,sys; ..." < app.log
判断标准是:能用一条 grep -E 解决的,不要写 Python。进程启动开销、管道组合能力,在一次性文本过滤上 shell 工具通常更划算。相关技巧可参考 Shell 脚本与自动化
。而当你需要复用一条复杂模式时,正则表达式工具与在线调试
能帮助快速验证与可视化回溯过程。
小结
Python 文本处理的能力边界可以这样划分:固定串用 str 方法(快一个数量级),有标准格式用标准解析器(CSV/JSON/URL/HTML 一律不写正则),半结构化文本才用 re(日志、时间戳、混合串),Unicode 比较先规范化(NFC + casefold),模式必须防回溯炸弹(消灭嵌套量词)。做到这五条,正则就会从「一周的调试噩梦」变回「一天工作量的省时工具」。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。