RSS
菜单
全部文章快讯开发科技深度热点

正则表达式(Python 从精通到入门 · 15)

内容摘要

正则不是玄学,是一门有明确语法的小语言;这篇从元字符讲到灾难性回溯,让你既能写对也能写快。

正则不是玄学,是一门有明确语法的小语言;这篇从元字符讲到灾难性回溯,让你既能写对也能写快。

你将学到

  • re 模块的 compile / match / search / findall / sub 各自语义
  • 元字符、字符类、量词,以及贪婪与非贪婪的差别
  • 分组、命名分组、反向引用
  • 邮箱、手机、IP、日期等常用校验正则
  • re.VERBOSE、re.DOTALL 等标志,预编译与避免灾难性回溯的性能建议
  • 用正则解析日志文件的实战

前置知识

需要掌握字符串方法和文件读取,见 上一篇:文件与路径处理。

re 模块的五个核心函数

区分 match 和 search 是入门第一课:match 只从开头匹配,search 扫描整个串找第一个匹配。

import re

text = "订单号: A12345, 金额: 88"

# match:只从开头尝试,失败就返回 None
print(re.match(r"\d+", text))       # 输出: None(开头是“订”)
print(re.match(r"\D+", text))       # 输出: <re.Match object; span=(0, 3) ...>

# search:全串扫描,找到第一个就返回
m = re.search(r"\d+", text)
print(m.group())                    # 输出: 88

# findall:返回所有匹配的字符串列表
print(re.findall(r"\d+", text))     # 输出: ['12345', '88']

# sub:替换所有匹配
print(re.sub(r"\d+", "#", text))    # 输出: 订单号: #, 金额: #

# compile:预编译成 Pattern,重复使用复用编译结果
phone_re = re.compile(r"1[3-9]\d{9}")
print(phone_re.search("联系 13812345678"))   # 输出: <re.Match object; span=(3, 14) ...>

Match 对象上:group() 取整个匹配,group(n) 取第 n 组,groups() 取所有组,start()/end()/span() 给位置。

元字符与字符类

# .   任意字符(默认不含换行)
# \d  数字 0-9      \D  非数字
# \w  字母数字下划线  \W  非 \w
# \s  空白          \S  非空白
# ^   行首 / 串首     $   行尾 / 串尾
# []  字符类         |   或        () 分组

print(re.findall(r"\w+", "hello_world 中文 abc123"))   # 输出: ['hello_world', 'abc123']
print(re.findall(r"[aeiou]", "hello"))                  # 输出: ['e', 'o']
print(re.findall(r"[^aeiou\s]", "hello"))               # 输出: ['h', 'l', 'l']
print(re.findall(r"cat|dog", "I have a cat and a dog")) # 输出: ['cat', 'dog']

注意 ^ 和 $ 默认匹配整段文本的首尾;配合 re.MULTILINE 才变成“每行”的首尾。

量词:贪婪与非贪婪

html = "<b>加粗</b><i>斜体</i>"

# 贪婪:尽可能多匹配
print(re.findall(r"<.*>", html))     # 输出: ['<b>加粗</b><i>斜体</i>']

# 非贪婪:量词后加 ?,尽可能少匹配
print(re.findall(r"<.*?>", html))    # 输出: ['<b>', '</b>', '<i>', '</i>']

# 常用量词
# *     0 次或多次
# +     1 次或多次
# ?     0 次或 1 次
# {n}   恰好 n 次
# {n,}  至少 n 次
# {n,m} n 到 m 次

解析 HTML 时“贪婪 vs 非贪婪”是最经典的坑。记住:默认贪婪,加 ? 变非贪婪。

分组、命名分组与反向引用

括号 () 既是分组(可被 group(n) 取用),也会被捕获。只想分组不想捕获用 (?:...)。

# 普通分组 + 位置取用
m = re.search(r"(\d{4})-(\d{2})-(\d{2})", "2026-10-07")
print(m.group(1), m.group(2), m.group(3))   # 输出: 2026 10 07

# 命名分组 (?P<name>...),可读性更好
m = re.search(r"(?P<y>\d{4})-(?P<m>\d{2})-(?P<d>\d{2})", "2026-10-07")
print(m.group("y"), m.groupdict())   # 输出: 2026 {'y': '2026', 'm': '10', 'd': '07'}

# 非捕获分组 (?:...) —— 只分组不占编号
print(re.findall(r"(?:AB)+", "ABABAC"))   # 输出: ['ABAB']

# 反向引用 \1 / (?P=name):匹配前面出现过的相同内容
print(re.search(r"(\w+) \1", "hey hey there"))   # 输出: hey hey

命名分组 + sub 配合回调或 \g<name>,是做文本重排的利器:

# 把 MM/DD/YYYY 改成 YYYY-MM-DD
print(re.sub(r"(?P<m>\d{2})/(?P<d>\d{2})/(?P<y>\d{4})",
             r"\g<y>-\g<m>-\g<d>", "10/07/2026"))
# 输出: 2026-10-07

常用校验正则

# 邮箱(够用版,别追求完美——RFC 里合法的邮箱格式复杂到没人想背)
EMAIL = re.compile(r"^[\w.+-]+@[\w-]+\.[\w.-]+$")
print(bool(EMAIL.match("a.b+c@x.co")))     # 输出: True

# 中国大陆手机号
PHONE = re.compile(r"^1[3-9]\d{9}$")
print(bool(PHONE.match("13812345678")))    # 输出: True
print(bool(PHONE.match("12345678901")))    # 输出: False

# IPv4(每段 0-255,校验范围)
IPV4 = re.compile(
    r"^((25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)\.){3}"
    r"(25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)$"
)
print(bool(IPV4.match("192.168.1.1")))     # 输出: True
print(bool(IPV4.match("999.1.1.1")))       # 输出: False

# 日期 YYYY-MM-DD(只校验格式,不校验 2 月 30 日这种)
DATE = re.compile(r"^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$")
print(bool(DATE.match("2026-13-01")))      # 输出: False

实际校验日期请用 datetime.strptime——正则管格式,语义交给专用库。

标志(flags)

# re.IGNORECASE(或 re.I):忽略大小写
print(re.findall(r"hello", "Hello HELLO", re.I))   # 输出: ['Hello', 'HELLO']

# re.DOTALL(或 re.S):让 . 也能匹配换行
print(re.search(r"a.b", "a\nb", re.S))             # 输出: <re.Match object; ...>

# re.MULTILINE(或 re.M):^ $ 匹配每行
print(re.findall(r"^\w+", "foo\nbar", re.M))       # 输出: ['foo', 'bar']

# re.VERBOSE(或 re.X):忽略空白和 # 注释,写复杂正则时可读性大增
pattern = re.compile(r"""
    (?P<ip>\d{1,3}(?:\.\d{1,3}){3})   # IP 地址
    \s-\s-\s
    \[(?P<ts>[^\]]+)\]                 # 时间戳
    \s"(?P<method>\w+)\s(?P<path>\S+)  # 请求方法与路径
""", re.VERBOSE)

re.VERBOSE 是长正则的救命稻草——否则你会写出没人看懂的一行天书。

性能提示

# ✅ 循环里反复用的是同一个正则:预编译一次,复用
WORD = re.compile(r"\w+")
for line in lines:
    WORD.findall(line)          # 不重复编译

# ❌ 每次循环都调用 re.findall,内部会走缓存但仍有开销
for line in lines:
    re.findall(r"\w+", line)

re 内部有 LRU 缓存(默认 512 条),所以小脚本里 re.findall 也能接受;但热路径和复杂正则一定要 compile。

灾难性回溯是正则的性能杀手:嵌套量词遇到长的不匹配串时,回溯次数指数级爆炸。

# ❌ 危险:(a+)+ 这种嵌套量词,遇到不匹配的长串会指数级回溯
bad = re.compile(r"(a+)+$")
# 输入 "aaaaaaaaaaaaaaaaaaaaaaaaaaX" 会让它卡死几秒到几分钟

# ✅ 用原子/占有式思路改写,或者干脆用字符串方法
good = re.compile(r"a+$")       # 语义相同的场景下别套嵌套量词

# ✅ Python 3.11+ 支持占有量词 ++ *+ ?+,匹配后不回溯
no_backtrack = re.compile(r"a++$")

原则:能用简单结构就别用嵌套量词;能用字符串方法(in、startswith、str.split)就别上正则。

实战:解析 Nginx 访问日志

import re

LOG_RE = re.compile(r"""
    (?P<ip>\d{1,3}(?:\.\d{1,3}){3})        # 客户端 IP
    \s-\s-\s
    \[(?P<time>[^\]]+)\]                    # 时间,如 07/Oct/2026:10:00:00 +0800
    \s"(?P<method>[A-Z]+)\s(?P<path>\S+)\s[^"]*"   # 请求行
    \s(?P<status>\d{3})\s                   # 状态码
    (?P<size>\d+)                           # 响应字节数
""", re.VERBOSE)

raw = '''192.168.1.1 - - [07/Oct/2026:10:00:00 +0800] "GET /index.html HTTP/1.1" 200 1024
10.0.0.5 - - [07/Oct/2026:10:00:01 +0800] "POST /api/login HTTP/1.1" 401 0
192.168.1.1 - - [07/Oct/2026:10:00:02 +0800] "GET /favicon.ico HTTP/1.1" 404 0'''

stats = {}
total_bytes = 0
for line in raw.splitlines():
    m = LOG_RE.search(line)
    if not m:
        continue                        # 格式不符就跳过,别让一行坏数据炸掉整个解析
    d = m.groupdict()
    stats[d["status"]] = stats.get(d["status"], 0) + 1
    total_bytes += int(d["size"])

print(stats)          # 输出: {'200': 1, '401': 1, '404': 1}
print(total_bytes)    # 输出: 1024

解析非结构化文本时,正则 + 命名分组 + groupdict() 的组合比手工 split 稳定得多。

常见坑

# ❌ 用 search 却以为它从开头匹配,把 “a1b2” 里的数字也捞出来
re.search(r"\d+", "a1b2").group()    # 输出: 1(不是从开头,是第一个数字)

# ✅ 需要从开头就锚定,用 match 或 ^
re.match(r"\d+", "a1b2")             # 输出: None

# ❌ 忘记转义点号,. 会匹配任意字符
re.findall(r"1.5", "125 1-5 1.5")    # 输出: ['125', '1-5', '1.5']

# ✅ 想要字面量点号就转义
re.findall(r"1\.5", "125 1-5 1.5")   # 输出: ['1.5']
# ❌ 用户输入直接拼进正则,元字符引发意外甚至注入
user = "a.b"
re.search(user, "axb")               # 输出: 匹配成功!. 被当成任意字符

# ✅ 用 re.escape 转义用户输入
re.search(re.escape(user), "axb")    # 输出: None
# ❌ 贪婪量词解析成对标签,一口吞掉中间所有
re.findall(r"<.*>", "<b>x</b>")      # 输出: ['<b>x</b>']

# ✅ 非贪婪,或者用更精确的字符类
re.findall(r"<.*?>", "<b>x</b>")     # 输出: ['<b>', '</b>']

小结

  • match 从开头匹配,search 全串扫描,别再搞混。
  • 默认贪婪,量词后加 ? 变非贪婪;嵌套量词((a+)+)是灾难性回溯的重灾区。
  • 优先用命名分组 (?P<name>...) + groupdict(),代码自解释。
  • 常用校验用现成正则,但日期等语义校验交给 datetime。
  • 热路径预编译;re.VERBOSE 让复杂正则可维护;用户输入一律 re.escape。
  • 能用字符串方法解决的,别动用正则。

延伸阅读

  • 官方文档:re 模块,注意 re.Match 对象
  • 下一篇 数据处理 json/csv/dataclasses 处理结构化数据,和正则处理非结构化文本互补

上一篇:文件与路径处理 · 下一篇:数据处理 json/csv/dataclasses

— 全文完 —回到顶部 ↑
下载推广海报

文章推广海报

《正则表达式(Python 从精通到入门 · 15)》完整推广海报
DISCUSSION

文章回复

0 条公开回复
未登录回复需要审核后公开
还没有回复,欢迎参与讨论。