前言
正则表达式(regular expression,简称 regex)是处理文本的通用工具:在一堆字符里找出满足某种形状的片段,或者把符合形状的片段替换成别的内容。Python 通过标准库re模块提供这套能力,不需要安装任何第三方包。
初学者最常见的误解有两个。第一,以为re.match()会在整段文字里「到处找」——其实它只从字符串开头尝试匹配,找不找得到都不看后面。第二,以为写了正则就一定比手写字符串处理快——正则有回溯(backtracking)开销,写得不好可能比直接判断还慢,具体差多少要看数据和模式,不能用固定倍数概括。
本文用一批可以直接复制进.py文件运行的实例,把「查找、提取、替换、校验」四类常见需求串起来,并在最后集中列出几个真会踩到的坑。所有示例基于 Python 3.8+ 编写,用到的特性都会标注版本。另外提醒:Python 2.7 已于 2020 年 1 月 1 日停止维护,它在re上按 bytes 语义处理字符串、还需要u"..."前缀来得到 Unicode 字符串,unicode、basestring类型在 Python 3 里也已不存在,旧教程的写法不要照搬。
一、最常用的四个动作
re模块的模块级函数(module-level function)是四个动作最直接的写法:
| 动作 | 函数 | 返回值 | 说明 |
|---|
| 找第一个 | re.search(pattern, string) | Match或None | 扫描整串,任意位置命中即可 |
| 从开头匹配 | re.match(pattern, string) | Match或None | 只从下标 0 开始 |
| 找全部 | re.findall(pattern, string) | 列表 | 返回所有不重叠匹配的文本 |
| 替换 | re.sub(pattern, repl, string) | 新字符串 | 原字符串不变,返回新串 |
用一句话记住match和search的区别:re.match("c", "abcdef")返回None,而re.search("c", "abcdef")返回一个Match对象(官方文档的search() vs. match()一节给的就是这个例子)。要在「整串完全符合」时才成功,还可以用re.fullmatch(),它需要 Python 3.4 及以上。
下面的实例覆盖手机号、邮箱、日期、中文和空白折叠:
# 适用于 Python 3.8+
import re
text = "联系邮箱:alice@example.com,手机 13800138000,备用 010-88886666"
# 找所有邮箱地址
emails = re.findall(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}", text)
print(emails) # ['alice@example.com']
# 找所有 11 位手机号:1 开头,第二位 3-9,后面再跟 9 位数字
phones = re.findall(r"1[3-9]\d{9}", text)
print(phones) # ['13800138000']
# 把空白字符折叠成单个空格
collapsed = re.sub(r"\s+", " ", text)
print(collapsed)\d在默认的 Unicode(str)模式下匹配「任意 Unicode 十进制数字」,范围比[0-9]大;在 bytes 模式或加了re.ASCII时才等价于[0-9]。上面的字符串里只有 ASCII 数字,所以\d和[0-9]结果一样,但换成含全角数字的文本时两者就会分叉。
二、分组:把一次匹配拆成几块
圆括号(...)表示一个捕获组(capturing group),匹配完成后可以用Match.group()按序号或名字取出每一段。
# 适用于 Python 3.8+
import re
line = "2024-05-01 上线,2024-06-15 回滚"
m = re.search(r"(\d{4})-(\d{2})-(\d{2})", line)
if m:
print(m.group(0)) # 2024-05-01,整个匹配
print(m.group(1)) # 2024,第一个括号
print(m.group(2)) # 05
print(m.group(3)) # 01
print(m.groups()) # ('2024', '05', '01')
print(m.span()) # (0, 10),匹配在原串里的起止下标Match.span()返回(start, end)二元组,m.group()等价于m.group(0)。除此之外还有两个常用方法:
m.groups(default=None):返回从第 1 组到最后一组的元组,没参与匹配的组用default填充。m.groupdict(default=None):只针对命名组,返回「名字到内容」的字典。命名组用(?P<name>...)写法,例如(?P<year>\d{4})。
findall对分组很敏感:没有分组时返回整段匹配文本,只有一个分组时返回该分组的内容,有多个分组时返回元组。所以想拿「整段」就一定不要随便加括号,或者改用finditer逐个处理Match对象。
# 适用于 Python 3.8+
import re
src = "宽度=20 高度=10"
print(re.findall(r"\w+=\d+", src)) # ['宽度=20', '高度=10'],无分组
print(re.findall(r"(\w+)=(\d+)", src)) # [('宽度', '20'), ('高度', '10')]
# 想要每段匹配的起止位置,用 finditer
for m in re.finditer(r"(\w+)=(\d+)", src):
print(m.group(1), m.group(2), m.span())三、替换:字符串或函数都行
re.sub(pattern, repl, string, count=0)的repl既可以是字符串,也可以是函数。是字符串时,\1、\g<name>这类反向引用(backreference)会被替换成对应分组的内容;是函数时,函数接收一个Match对象,返回什么就替换成什么。count=0(默认)表示全部替换,正数表示最多替换几次。
# 适用于 Python 3.8+
import re
text = "手机 13800138000 与 13912345678 都已登记"
# repl 用函数:把中间四位打码
def mask(m):
s = m.group(0)
return s[:3] + "****" + s[-4:]
print(re.sub(r"1[3-9]\d{9}", mask, text))
# 手机 138****8000 与 139****5678 都已登记
# repl 用字符串 + 反向引用:交换键值对顺序
kv = "name:alice"
print(re.sub(r"(\w+):(\w+)", r"\2:\1", kv)) # alice:name如果替换内容来自用户输入、本身可能含反斜杠,不要把它直接当repl用;re.escape()是为「模式」转义的,不能用在替换串上。稳妥做法是把替换逻辑写成函数,返回原样的字符串,避免\1之类被误解释。
四、提取中文与清洗
中文字符在 Unicode 里的基本区间是一到鿿(CJK 统一表意文字),配合+可以连续取出中文片段:
# 适用于 Python 3.8+
import re
s = "品牌:华为 Mate60 Pro,产地:中国深圳"
print(re.findall(r"[一-鿿]+", s)) # ['品牌', '华为', '产地', '中国深圳']
# 只保留字母、数字和下划线,去掉其它符号
raw = "a b!c#d$e%f^g&h*i"
print(re.sub(r"[^0-9A-Za-z_]", "", raw)) # abcdefghi第二个例子用「取反」字符集[^...]表达「除了这些都不要」,是清洗文本的常用套路。注意^只有在方括号内的第一个位置才表示取反;放在别处它就是普通的^字符。
常见坑点
- ❌ 用
re.match()在长文本里找任意位置的内容,结果永远None。
✅ 从任意位置找用re.search();只在开头找才用re.match()。
- ❌ 写
re.findall("(abc)", s)却发现返回的是['abc']这种分组内容、以为拿错了。
✅ 记住findall有分组时返回分组;要整段匹配就删掉多余的括号,或改用re.finditer()。
- ❌ 模式里写
"\d+"而不是r"\d+",在含\1、\b等序列时被 Python 字符串先解释一遍,行为诡异。
✅ 正则模式一律加r前缀写成r"\d+",让反斜杠原样交给re。
- ❌ 以为
.能匹配换行,用r".+"去匹配多行文本却只拿到第一行。
✅ 默认.不匹配换行;需要时加re.DOTALL(也可写成(?s))。
- ❌ 用贪心的
r"<li>(.*)</li>"去提取多个列表项,结果一次吞掉了从第一个<li>到最后一个</li>的整块。
✅ 改成懒惰的r"<li>(.*?)</li>",量词加?后「尽量少匹配」。
- ❌ 用
r"[0-9]+"与r"\d+"混着写,却期待它们在含全角数字的文本里得到同样结果。
✅ 默认 Unicode 模式下\d比[0-9]范围大;要限定 ASCII 就写[0-9]或加re.ASCII。
- ❌ 用
re.split(r"(\W+)", s)后,发现结果列表里多出了分隔符本身。
✅ 这是文档规定的行为:分隔模式里若有捕获组,分组内容会一并保留在结果中;不想要就写成非捕获组(?:...)。
- ❌ 把手机号、身份证这类正则在整段文本上直接
findall,忽略了它还会匹配到更长的数字串中间一段。
✅ 用\b或(?<!\d)、(?!\d)之类的边界断言把前后卡住,避免「切到别人一半」。
总结
| 需求 | 推荐函数 | 关键点 |
|---|
| 从任意位置找第一个 | re.search | 命中即返回Match,否则None |
| 判断整串是否符合 | re.fullmatch | 需 Python 3.4+ |
| 取全部分段 | re.finditer+group() | 比findall更好控制分组 |
| 替换 | re.sub | repl可为字符串或函数 |
| 按模式切分 | re.split | 捕获组会保留在结果里 |
把「找、取、换、切」四类动作对应到search、finditer、sub、split,再牢记模式用r"..."、match只从开头、.不跨行,大部分日常文本处理就能写对。真要抠性能,请用timeit在自己的数据上实测,而不是相信某个固定的「提速几倍」说法。