Python 正则表达式:字符串的"智能筛选器"
引言:从一堆名片中找出所有手机号
假设你有一堆名片,想快速找出所有人的手机号。你会怎么做?
- 方法 A:肉眼逐个看 — 太慢,容易漏;
- 方法 B:写代码判断 — 先找 11 位连续数字,再判断是不是 1 开头……代码写一大堆,换个格式就失效。
其实,你只需要告诉电脑一个"规则":
"找 1 开头,第二位是 3/5/7/8/9,后面跟 9 个数字的字符串"
电脑就能瞬间筛选出所有手机号。这个"规则"就是正则表达式(Regular Expression)。
正则表达式是一种描述字符串模式的迷你语言。它用一套简洁的符号系统,定义"什么样的字符串符合要求",然后让计算机自动匹配。
这一篇,我们从最基本的字符匹配讲起,逐步深入量词、分组、贪婪匹配等核心概念,最后通过大量实战案例让你真正掌握这个"字符串处理神器"。
一、为什么需要正则表达式?
1.1 没有正则时的痛苦
判断一个字符串是不是合法邮箱,不用正则的话:
def is_valid_email(s):
# 找 @
if '@' not in s:
return False
parts = s.split('@')
if len(parts) != 2:
return False
username, domain = parts
# 用户名不能为空
if not username:
return False
# 域名要有 .
if '.' not in domain:
return False
# 用户名不能含特殊字符……
# 域名后缀要是字母……
# 还有无数边界情况……
return True # 写了 50 行,还是有漏洞代码又臭又长,还未必覆盖所有情况。
1.2 用正则的优雅
import re
def is_valid_email(s):
return re.match(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$', s) is not None一行搞定!
1.3 正则表达式的核心思想
正则表达式 = 用字符描述字符
| 你想找的 | 正则表达式 | 含义 |
|---|---|---|
| 3 个数字 | \d\d\d 或 \d{3} | 数字×3 |
| 字母或数字 | \w | 单词字符 |
| 任意字符 | . | 除换行外的任意字符 |
| 0 个或多个 | * | 前面的元素重复 0~∞ 次 |
| 1 个或多个 | + | 前面的元素重复 1~∞ 次 |
二、基础语法:字符的"描述语言"
2.1 精确匹配
直接写字母/数字,就是找一模一样的:
import re
re.match(r'hello', 'hello world') # 匹配成功
re.match(r'hello', 'Hello world') # 匹配失败(区分大小写)2.2 特殊字符:预定义字符类
| 符号 | 含义 | 等价于 | 例子 |
|---|---|---|---|
\d | 数字 | [0-9] | \d\d 匹配 "42" |
\w | 字母/数字/下划线 | [a-zA-Z0-9_] | \w+ 匹配 "py3" |
\s | 空白字符(空格/Tab/换行) | [ \t\n\r\f\v] | \s+ 匹配 " " |
. | 任意字符(除换行) | — | py. 匹配 "pya", "py!" |
\D | 非数字 | [^0-9] | \D+ 匹配 "abc" |
\W | 非单词字符 | [^a-zA-Z0-9_] | \W+ 匹配 "!@#" |
\S | 非空白字符 | [^ \t\n\r\f\v] | \S+ 匹配 "hello" |
2.3 量词:控制重复次数
| 符号 | 含义 | 例子 | 匹配 |
|---|---|---|---|
* | 0 次或多次 | ab* | "a", "ab", "abb" |
+ | 1 次或多次 | ab+ | "ab", "abb"(不匹配 "a") |
? | 0 次或 1 次 | ab? | "a", "ab" |
{n} | 恰好 n 次 | \d{3} | "123" |
{n,} | 至少 n 次 | \d{3,} | "123", "1234" |
{n,m} | n 到 m 次 | \d{3,5} | "123", "12345" |
生活化理解:量词就像点外卖时的"份数选择":
*= "随便几份,不要也行"+= "至少来一份"?= "来一份或不要"{3}= "必须 3 份"{3,5}= "3 到 5 份之间"
2.4 转义字符:特殊符号的"护身符"
有些字符在正则里有特殊含义(. * + ? ^ $ [] () {} | \),如果想匹配它们本身,需要加 \ 转义:
# 匹配 "010-12345" 中的 -
re.match(r'\d{3}\-\d{3,8}', '010-12345') # ✅ 正确
re.match(r'\d{3}-\d{3,8}', '010-12345') # ❌ 错误:- 在 [] 外不是特殊字符,但建议转义
# 匹配 "3.14" 中的 .
re.match(r'3\.14', '3.14') # ✅ 正确
re.match(r'3.14', '3.14') # ⚠️ 能匹配,但 . 表示任意字符,不严谨口诀:在正则中,看到 \ 就是"后面那个字符不要特殊对待"。
三、进阶语法:更精确的控制
3.1 字符集合:[] 的魔力
[...] 表示"匹配括号内的任意一个字符":
# 匹配数字、字母或下划线
[0-9a-zA-Z_]
# 匹配元音字母
[aeiou]
# 匹配非数字(^ 在 [] 内表示取反)
[^0-9]常见组合:
| 正则 | 含义 | 例子 |
|---|---|---|
[0-9a-zA-Z_]+ | 至少一个单词字符 | "a100", "Py3000" |
[a-zA-Z_][0-9a-zA-Z_]* | Python 合法变量名 | "name", "_var", "x1" |
[a-zA-Z_][0-9a-zA-Z_]{0,19} | 1-20 位的变量名 | 限制长度 |
3.2 或运算:| 的选择
A|B 表示匹配 A 或 B:
# 匹配 Python 或 python
(P|p)ython
# 匹配多种图片格式
\.(jpg|jpeg|png|gif)$3.3 边界锚点:^ 和 $
| 符号 | 含义 | 例子 |
|---|---|---|
^ | 字符串开头 | ^\d 以数字开头 |
$ | 字符串结尾 | \d$ 以数字结尾 |
^...$ | 整行匹配 | ^py$ 只匹配 "py",不匹配 "python" |
生活化理解:^ 和 $ 就像门框——^ 是"从门口开始",$ 是"到门口结束",合起来就是"整扇门"。
3.4 一个复杂的例子
匹配带区号的电话号码(如 010-12345 或 010 - 12345):
r'^\d{3}\s*-\s*\d{3,8}$'拆解:
^— 开头\d{3}— 3 位数字(区号)\s*— 0 个或多个空格\-— 字面量-\s*— 0 个或多个空格\d{3,8}— 3 到 8 位数字$— 结尾
四、Python 的 re 模块:正则的"执行引擎"
4.1 导入与基本使用
import re
# 判断是否匹配
result = re.match(r'^\d{3}\-\d{3,8}$', '010-12345')
if result:
print('匹配成功')
else:
print('匹配失败')4.2 重要:r 前缀
Python 字符串本身也用 \ 转义,所以要用 r'...' 表示原始字符串:
# 不用 r 前缀
s = 'ABC\\-001' # Python 字符串:ABC\-001
# 传给正则:ABC\-001(\- 被转义为 -)
# 用 r 前缀
s = r'ABC\-001' # Python 字符串:ABC\-001
# 传给正则:ABC\-001(\- 保持原样,表示字面量 -)强烈建议:正则表达式一律用 r'...'。
4.3 常用函数速查表
| 函数 | 作用 | 返回值 |
|---|---|---|
re.match(pattern, string) | 从开头匹配 | Match 对象或 None |
re.search(pattern, string) | 任意位置匹配第一个 | Match 对象或 None |
re.findall(pattern, string) | 找到所有匹配 | 列表 |
re.finditer(pattern, string) | 找到所有匹配(迭代器) | Match 对象迭代器 |
re.sub(pattern, repl, string) | 替换匹配内容 | 替换后的字符串 |
re.split(pattern, string) | 切分字符串 | 列表 |
re.compile(pattern) | 预编译正则 | Pattern 对象 |
4.4 match vs search
import re
s = 'hello123world'
# match:从开头匹配
re.match(r'\d+', s) # None(开头是字母)
re.match(r'[a-z]+\d+', s) # 匹配 'hello123'
# search:任意位置找第一个
re.search(r'\d+', s) # 匹配 '123'记忆技巧:match 是"严格门卫",必须从门口开始检查;search 是"巡逻警察",到处找。
五、分组提取:从字符串中"挖宝"
5.1 分组的基本用法
用 () 把要提取的部分括起来:
import re
m = re.match(r'^(\d{3})-(\d{3,8})$', '010-12345')
m.group(0) # '010-12345' — 整个匹配
m.group(1) # '010' — 第一个括号
m.group(2) # '12345' — 第二个括号
m.groups() # ('010', '12345') — 所有分组生活化理解:分组就像快递单上的"收件人"和"电话"两个栏目——整个单子是一个匹配,但你关心的是栏目里的具体内容。
5.2 实战:解析时间
t = '19:05:30'
m = re.match(r'^(0[0-9]|1[0-9]|2[0-3]|[0-9])\:(0[0-9]|[1-5][0-9]|[0-9])\:(0[0-9]|[1-5][0-9]|[0-9])$', t)
m.groups() # ('19', '05', '30')这个正则精确匹配 00:00:00 到 23:59:59 的合法时间。
5.3 命名分组:给分组起名字
m = re.match(r'^(?P<area>\d{3})-(?P<number>\d{3,8})$', '010-12345')
m.group('area') # '010'
m.group('number') # '12345'好处:代码可读性大大提高,不用记 group(1)、group(2) 哪个是哪个。
5.4 非捕获分组:(?:...)
# 只匹配,不提取
m = re.match(r'^(?:\d{3})-(\d{3,8})$', '010-12345')
m.groups() # ('12345',) — 只有一个分组六、切分与替换:字符串的"变形术"
6.1 智能切分:re.split()
普通 split() 的局限:
'a b c'.split(' ') # ['a', 'b', '', 'c'] — 连续空格产生空字符串用正则切分:
import re
re.split(r'\s+', 'a b c') # ['a', 'b', 'c'] — 任意多个空格
re.split(r'[\s\,]+', 'a,b, c d') # ['a', 'b', 'c', 'd'] — 空格和逗号
re.split(r'[\s\,\;]+', 'a,b;; c d') # ['a', 'b', 'c', 'd'] — 空格、逗号、分号6.2 替换:re.sub()
import re
# 把所有数字替换为 #
re.sub(r'\d+', '#', 'abc123def456') # 'abc#def#'
# 把多个空格压缩为一个
re.sub(r'\s+', ' ', 'a b c') # 'a b c'
# 分组引用:交换姓和名
re.sub(r'(\w+) (\w+)', r'\2 \1', 'John Smith') # 'Smith John'\1、\2 表示引用第一个、第二个分组。
七、贪婪匹配 vs 非贪婪匹配
7.1 贪婪匹配:能吞多少吞多少
正则默认是贪婪的——尽可能多地匹配:
import re
re.match(r'^(\d+)(0*)$', '102300').groups()
# ('102300', '') — \d+ 吞掉了所有数字,包括后面的 0生活化理解:贪婪匹配就像大胃王比赛——选手看到食物就拼命往嘴里塞,直到塞不下为止。
7.2 非贪婪匹配:适可而止
在量词后加 ?,变成非贪婪(尽可能少匹配):
re.match(r'^(\d+?)(0*)$', '102300').groups()
# ('1023', '00') — \d+? 只匹配到第一个非零数字,把 00 留给 (0*)对比表:
| 贪婪 | 非贪婪 | 含义 |
|---|---|---|
* | *? | 0 次或多次,尽可能少 |
+ | +? | 1 次或多次,尽可能少 |
? | ?? | 0 次或 1 次,尽可能少 |
{n,m} | {n,m}? | n 到 m 次,尽可能少 |
7.3 实战:提取 HTML 标签内容
html = '<div>内容1</div><div>内容2</div>'
# 贪婪:匹配整个字符串
re.search(r'<div>(.*)</div>', html).group(1)
# '内容1</div><div>内容2' — 太多了!
# 非贪婪:只匹配第一个标签
re.search(r'<div>(.*?)</div>', html).group(1)
# '内容1' — 刚刚好八、预编译:提升性能的"预热"
8.1 为什么要预编译?
每次调用 re.match(),Python 都要做两件事:
- 编译正则表达式字符串 → 内部对象;
- 匹配用编译后的对象去匹配字符串。
如果一个正则要重复用几千次,编译就浪费了几千次。
8.2 预编译用法
import re
# 编译一次,重复使用
pattern = re.compile(r'^(\d{3})-(\d{3,8})$')
pattern.match('010-12345').groups() # ('010', '12345')
pattern.match('010-8086').groups() # ('010', '8086')性能对比(匹配 10 万次):
| 方式 | 大致耗时 |
|---|---|
直接 re.match() | ~0.5 秒 |
| 预编译后调用 | ~0.3 秒 |
建议:循环中反复使用的正则,务必预编译。
九、实战案例集
案例 1:验证邮箱
import re
def is_valid_email(addr):
# 用户名:字母数字._%+-,至少1位
# @:必须有
# 域名:字母数字.-,至少1个.
# 后缀:纯字母,至少2位
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
return re.match(pattern, addr) is not None
# 测试
print(is_valid_email('someone@gmail.com')) # True
print(is_valid_email('bill.gates@microsoft.com')) # True
print(is_valid_email('bob#example.com')) # False
print(is_valid_email('mr-bob@example.com')) # True案例 2:提取带名字的邮箱
def name_of_email(addr):
# 匹配 <Tom Paris> tom@voyager.org
m = re.match(r'^<?([a-zA-Z\s]+)>?\s*([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})?$', addr)
if m:
name = m.group(1).strip()
email = m.group(2)
if name and email:
return name
elif email:
# 从邮箱提取名字(@ 前的部分)
return re.match(r'^([a-zA-Z0-9._%+-]+)@', email).group(1)
return None
# 测试
print(name_of_email('<Tom Paris> tom@voyager.org')) # 'Tom Paris'
print(name_of_email('tom@voyager.org')) # 'tom'案例 3:验证手机号
def is_valid_phone(phone):
# 1 开头,第二位 3/4/5/6/7/8/9,共 11 位
pattern = r'^1[3-9]\d{9}$'
return re.match(pattern, phone) is not None
print(is_valid_phone('13800138000')) # True
print(is_valid_phone('12345678901')) # False
print(is_valid_phone('1380013800')) # False(少一位)案例 4:提取 URL 中的域名
def extract_domain(url):
m = re.search(r'https?://([^/]+)', url)
return m.group(1) if m else None
print(extract_domain('https://www.example.com/path/page.html'))
# 'www.example.com'案例 5:清理用户输入的标签
def clean_tags(input_str):
# 按空格、逗号、分号切分,去除空字符串
tags = re.split(r'[\s,;]+', input_str.strip())
return [tag for tag in tags if tag]
print(clean_tags('python, java;; c++ go'))
# ['python', 'java', 'c++', 'go']十、常见误区解析
误区 1:正则表达式可以解决所有字符串问题
真相:正则不是万能的,有些问题用正则反而更复杂。
| 适合正则 | 不适合正则 |
|---|---|
| 格式验证(邮箱、手机号) | 嵌套结构(HTML、JSON) |
| 简单提取(电话号码) | 复杂语法分析(代码解析) |
| 批量替换(清理空格) | 需要上下文判断的逻辑 |
例子:解析嵌套括号 ((a+b)*(c+d)) 用正则几乎不可能,需要用栈或递归。
误区 2:正则表达式越复杂越好
真相:复杂的正则难以阅读、难以维护、容易出错。
# 过度复杂的正则(试图用正则验证日期)
r'^(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$'
# 问题:2 月 30 日、4 月 31 日还是能通过!
# 正确做法:先用正则提取数字,再用 datetime 验证
import datetime
def is_valid_date(s):
m = re.match(r'^(\d{1,2})-(\d{1,2})$', s)
if not m:
return False
month, day = int(m.group(1)), int(m.group(2))
try:
datetime.date(2024, month, day) # 随便一年,主要验证月日
return True
except ValueError:
return False误区 3:忽略贪婪匹配导致的 bug
真相:忘记加 ? 是新手最常见的错误。
# 想提取 <tag> 里的内容
html = '<div>hello</div><div>world</div>'
# 贪婪:匹配太多
re.findall(r'<div>(.*)</div>', html)
# ['hello</div><div>world'] — 错了!
# 非贪婪:正确
re.findall(r'<div>(.*?)</div>', html)
# ['hello', 'world'] — 对了误区 4:不处理边界情况
真相:正则匹配失败返回 None,不处理会报错。
# ❌ 危险代码
m = re.match(r'\d+', 'abc')
print(m.group()) # AttributeError: 'NoneType' object has no attribute 'group'
# ✅ 安全代码
m = re.match(r'\d+', 'abc')
if m:
print(m.group())
else:
print('没有匹配')误区 5:滥用正则导致性能问题
真相:某些正则会导致灾难性回溯(catastrophic backtracking)。
# 危险:指数级回溯
re.match(r'^(a+)+$', 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaab')
# 安全:改写为等价但高效的正则
re.match(r'^a+$', 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaab')检测方法:如果正则匹配时间随着输入长度指数增长,就是灾难性回溯。
十一、正则表达式速查表
字符类
| 符号 | 含义 | 等价 |
|---|---|---|
\d | 数字 | [0-9] |
\D | 非数字 | [^0-9] |
\w | 单词字符 | [a-zA-Z0-9_] |
\W | 非单词字符 | [^a-zA-Z0-9_] |
\s | 空白字符 | [ \t\n\r\f\v] |
\S | 非空白字符 | [^ \t\n\r\f\v] |
. | 任意字符(除换行) | — |
量词
| 符号 | 含义 | 贪婪/非贪婪 |
|---|---|---|
* | 0 次或多次 | 贪婪 |
*? | 0 次或多次 | 非贪婪 |
+ | 1 次或多次 | 贪婪 |
+? | 1 次或多次 | 非贪婪 |
? | 0 次或 1 次 | 贪婪 |
?? | 0 次或 1 次 | 非贪婪 |
{n} | 恰好 n 次 | — |
{n,} | 至少 n 次 | — |
{n,m} | n 到 m 次 | — |
{n,m}? | n 到 m 次 | 非贪婪 |
边界与分组
| 符号 | 含义 |
|---|---|
^ | 字符串开头 |
$ | 字符串结尾 |
\b | 单词边界 |
(...) | 捕获分组 |
(?:...) | 非捕获分组 |
(?P<name>...) | 命名分组 |
A|B | 匹配 A 或 B |
Python re 模块常用函数
| 函数 | 用途 |
|---|---|
re.match() | 从开头匹配 |
re.search() | 搜索第一个匹配 |
re.findall() | 返回所有匹配的列表 |
re.sub() | 替换匹配内容 |
re.split() | 按正则切分 |
re.compile() | 预编译正则 |
十二、小结
| 核心知识点 | 一句话总结 |
|---|---|
| 正则表达式 | 用字符描述字符的迷你语言 |
| 基础符号 | \d 数字、\w 单词字符、. 任意字符 |
| 量词 | * 0+、+ 1+、? 0/1、{n,m} n~m |
| 字符集合 | [...] 匹配括号内任意一个 |
| 分组 | (...) 提取子串,(?:...) 只匹配不提取 |
| 锚点 | ^ 开头、$ 结尾 |
| 贪婪匹配 | 默认尽可能多,加 ? 变非贪婪 |
| re 模块 | match 开头匹配、search 搜索、findall 找全部 |
| 预编译 | re.compile() 提升重复匹配性能 |
| 适用场景 | 格式验证、简单提取、批量替换 |
| 不适用 | 嵌套结构、复杂语法分析 |
记住三句话:
- 正则表达式是描述字符串规则的语言,不是万能的;
- 默认贪婪匹配,需要精确控制时记得加
?; - 复杂问题先用正则提取,再用代码逻辑验证,正则 + 代码 > 纯正则。
参考代码
本文所有示例代码基于廖雪峰 Python 教程的正则表达式章节重新整理和扩充。