从几十万行日志里捞出所有报错时间,把用户输入的手机号、邮箱逐个校验——手写字符串处理又臭又长还容易漏,而正则表达式三五行就能搞定。它是文本处理领域绕不开的瑞士军刀。本文从零讲透 re 模块的常用函数、核心模式语法,最后聊聊贪心匹配和性能这些容易踩的坑。

1. 正则表达式是什么

正则表达式(简称正则)是一套描述"字符串模式"的小语言:比如"1 开头、后面跟 10 个数字""任意连续数字""以 ERROR 结尾"……Python 用标准库 re 模块来用它。先看第一个例子:

import re

text = "我的手机号是 13812345678,记得联系我"
m = re.search(r"1\d{10}", text)
if m:
    print("找到了:", m.group())

这里 r"1\d{10}" 是模式:r 表示原始字符串(反斜杠不被转义,写正则必须加);\d 匹配任意数字;{10} 表示前面的 \d 重复 10 次。合起来就是"1 后面跟 10 个数字"。search() 在文本里找第一处匹配,group() 取出匹配到的内容。

不过正则也不是万能的,先划清边界:它适合"文本形状比较规整"的任务——手机号、邮箱、日期、日志、配置项,模式清晰,正则一击即中;而解析 HTML、JSON、Python 代码这种有嵌套结构的文本,正则天生搞不定(嵌套层级一深就会写崩),应该用 html.parser、json、ast 等专业模块。记住这个判断:先问自己"这文本的结构是扁平的还是嵌套的",再决定动不动正则。

2. re 模块的五大函数

日常 90% 的场景就五个函数:match、search、findall、sub、split。它们的区别必须分清:

import re

text = "2026-08-07 19:52:30 ERROR 连接超时"

# search:在任意位置找第一处匹配
print(re.search(r"ERROR", text).group())          # ERROR

# findall:找出所有匹配,返回列表
print(re.findall(r"\d+", text))                   # ['2026', '08', '07', '19', '52', '30']

# sub:替换所有匹配
print(re.sub(r"\d{4}-\d{2}-\d{2}", "某日期", text))

# split:按匹配位置切分
print(re.split(r"[\s:]+", text))

输出依次是 ERROR、六段数字、替换后的文本和按空白/冒号切出的单词列表。最容易搞混的是 match 和 search:match 只从字符串开头匹配,search 在任意位置找。所以 re.match(r"ERROR", text) 会返回 None——因为这个文本不是以 ERROR 开头的。

再补充一个容易踩的细节:如果找不到匹配,search 返回 None,直接 .group() 会抛 AttributeError。所以工程代码里一定要先判空:m = re.search(...); if m: ...。同理 findall 找不到时返回空列表,不会报错,这也是它用起来更省心的原因。至于 split,它和字符串的 str.split() 区别在于:分隔符本身可以是一个正则模式,像例子里 [\s:]+ 一次匹配"空白或冒号"的连续组合,切分更彻底。

3. 字符类与量词

正则的"字母表"由字符类和量词构成。字符类用方括号:比如 [0-9] 匹配数字、[a-zA-Z] 匹配字母;更常用的是预定义简写:\d(数字)、\w(字母数字下划线)、\s(空白)。量词控制重复次数:*(0 次或多次)、+(1 次或多次)、?(0 次或 1 次)、{n,m}(n 到 m 次):

import re

print(re.findall(r"\d+", "价格 99 元,原价 199 元"))        # ['99', '199']
print(re.search(r"\w+@\w+\.\w+", "联系 test@example.com").group())
print(bool(re.search(r"^Python", "Python 是最好的语言")))  # True,^ 锚定开头
print(bool(re.search(r"end$", "this is the end")))         # True,$ 锚定结尾

^ 和 $ 是锚点,分别锁定开头和结尾,常用于"整串校验"——比如后面校验手机号时,没有锚点就会允许 13812345678xxx 这种半截匹配蒙混过关。

这一节还有个绕不开的话题:转义。正则里 . * + ? ( ) 等字符都有特殊含义,想匹配字面量本身必须加反斜杠,比如匹配 IP 地址里的点要写 \.。而原始字符串 r"..." 正是为了让你放心写这些反斜杠:不加 r 的话,"\d" 在字符串层面就会被解析成 \d 还是 d 全看转义规则,极易出错。铁律一条:写正则一律用 r"...",省掉一半的转义烦恼。

4. 分组:提取想要的片段

光匹配整段还不够,实战中经常要从匹配结果里拆出子片段。用括号 () 分组,再用 group(1)、group(2) 按顺序取,还可以给分组起名字:

import re

log = "2026-08-07 19:52:30 ERROR 连接超时"
m = re.search(r"(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2}:\d{2})\s+(\w+)", log)
print(m.group(1))   # 2026-08-07   日期
print(m.group(2))   # 19:52:30     时间
print(m.group(3))   # ERROR        级别

# 命名分组:用名字代替编号,可读性更好
m2 = re.search(r"(?P<date>\d{4}-\d{2}-\d{2})", log)
print(m2.group("date"))

模式里 \s+ 匹配一个或多个空白作为分隔。命名分组的语法是 (?P<名字>模式),取用 group("名字")。当分组超过三四个时,命名分组能救你的命——编号分组一旦中间加了个括号,后面的编号全部顺移,极易出错。

分组还有两个常用补充:group(0) 或 group() 返回整个匹配;不带编号的 group() 取全部。想一次拿到所有分组,用 m.groups() 返回元组。非捕获分组 (?:模式) 也值得认识:它只用来"打包"而不产生编号,比如 (?:ab)+ 匹配连续的 ab——这样不会污染后面的分组编号。实战中"提取"永远比"匹配"更常用,因为我们要的往往不是整段,而是其中的日期、金额、用户名。

5. 实战:校验手机号与邮箱

把前面学的串起来,写两个真实校验函数。注意三点:用 re.compile 预编译(反复调用时省去解析开销)、^ $ 锚定整串、返回布尔值:

import re

MOBILE = re.compile(r"^1[3-9]\d{9}$")
EMAIL = re.compile(r"^[\w.+-]+@[\w-]+\.[\w.-]+$")

def check_mobile(s: str) -> bool:
    return bool(MOBILE.match(s))

def check_email(s: str) -> bool:
    return bool(EMAIL.match(s))

print(check_mobile("13812345678"))     # True
print(check_mobile("23812345678"))     # False(2 开头)
print(check_mobile("1381234567"))      # False(少一位)
print(check_email("a.b+c@example.com"))  # True
print(check_email("a@b@c.com"))        # False

手机号规则:1 开头、第二位 [3-9]、后面 9 位数字。邮箱用 [\w.+-]+ 允许用户名里出现点、加号、连字符。match 配合 ^ 有点重复(它本来就从开头匹配),但 $ 必不可少——它保证"匹配到结尾",防止 13812345678999 这种超长输入被判为合法。

校验类正则有个心法:先想清楚"什么算非法",再写模式。比如手机号,^1\d{10}$ 能挡住大多数错误,但 [3-9] 进一步排除了 12、16 开头的无效号段;邮箱更复杂,业界公认"完美的邮箱正则不存在",够用即可。另外别忘了 str.strip() 预处理:用户输入的 " 13812345678 " 前后带空格,不先去掉空格,再好的正则也会误判。校验类代码务必配套单元测试,把合法、非法、边界三种输入都覆盖到。

6. 性能陷阱:贪心与回溯

正则默认是贪心的:* 和 + 会尽量多吃字符,直到吃不下再往回吐。这在处理 HTML 标签时经常出问题:

import re

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

# 贪心:一次吞到最后一个 >,结果只匹配到一整个大块
print(re.findall(r"<.*>", html))

# 非贪心:加 ? 变成"尽量少吃",逐个标签匹配
print(re.findall(r"<.*?>", html))

# finditer:大文本里逐个取匹配,避免一次生成大列表
for m in re.finditer(r"<.*?>", html):
    print(m.span(), m.group())

第一行输出只有一个元素——从第一个 < 一路吃到最后一个 >;第二行加了 ? 后变成非贪心,正确拆出四个标签。更危险的坑是灾难性回溯:模式如 (a+)+$ 遇到长串不匹配文本时,引擎会指数级地尝试各种组合,把程序卡死。经验法则:嵌套的量词(量词套量词)要警惕;解析 HTML/JSON 这种结构化文本,优先用 html.parser 或 json 模块,别硬写正则。

两个让正则"可维护"的技巧收尾。第一,复杂模式用 re.VERBOSE(或写法 re.X)开启宽松模式:允许在模式里写空格和 # 注释,把一行天书拆成带说明的多行,自己三个月后回来看还能读懂。第二,re.compile 之后把模式对象命名成常量(如 MOBILE),放在模块顶部,模式本身就是文档。写正则的终极心法:宁可多花十分钟拆解成几个小模式逐步验证,也别憋一个"一次到位"的巨无霸——调试复杂正则的时间,永远比想象中长。

7. 总结与练习

回顾全文:五个核心函数 match/search/findall/sub/split 各司其职;字符类与量词构成模式骨架;分组负责提取子片段;锚点保证整串校验;贪心与回溯是性能大坑。工具层面,re.compile 预编译、finditer 流式处理,是工程代码的标配。

💡 正则的调试心法:先在脑子里拆成"我要匹配什么结构",再逐块拼模式,最后用锚点封边。写复杂正则前,先在 re 的交互式环境里小步验证,别一口气憋大招。