Python(四十) 正则表达式专题教程
目录
一、正则表达式的核心定义
1.1 用快递分拣理解正则表达式
假设你在一家快递中转站工作,每天面对成千上万的包裹。你的任务是根据包裹上的"收货地址"把东西分到不同的笼车里——北京的放一堆,上海的放一堆,广东的放一堆。
你怎么做?你会先扫一眼地址栏,看到"北京市"三个字就扔到北京那一堆,看到"上海市"就扔到上海那一堆。你不需要读完整条地址,只需要找关键词。
正则表达式(Regular Expression,简称 regex)本质上就是一套"文本分拣规则"。 它是一串特殊的字符组合,用来描述"我要找什么样的文字"。你把规则交给计算机,计算机就能在一大堆文本里快速找到符合规则的字符串。
用更技术化的话说:正则表达式是一种模式匹配工具,你定义一种"模式"(pattern),然后让计算机去文本里搜索、提取或替换所有符合这种模式的内容。
1.2 正则表达式能解决的现实问题
下面这些场景,用正则表达式能轻松搞定:
| 场景 | 例子 | 传统做法 | 用正则 |
|---|---|---|---|
| 格式验证 | 判断用户输入的手机号是否合法 | 写一堆 if-else 逐个字符判断 | 一行正则搞定 |
| 信息提取 | 从服务器日志里捞出所有 IP 地址 | 人工一行行翻,眼睛看花 | 一个 findall 全提取 |
| 批量替换 | 把文档里所有手机号替换成 *** 脱敏 |
手动找到再改,容易漏 | re.sub 一秒替换 |
| 文本清洗 | 删除爬虫抓到的网页里的 HTML 标签 | 写解析器处理 | 正则匹配标签一键清除 |
| 内容拆分 | 按标点符号把一段话拆成句子 | 用 split 只能处理一种符号 |
正则完美匹配多种标点 |
1.3 re 模块
正则表达式本身是一套通用的语法标准(各编程语言大同小异)。在 Python 中使用正则表达式,靠的是内置的 re 模块。
不需要 pip install 任何东西,re 是 Python 标准库的一部分,直接导入就能用:
import re
# 你的第一个正则:检查一段文字里有没有"Python"
text = "我正在学Python编程"
result = re.search(r"Python", text)
print(result) # <re.Match object; span=(4, 10), match='Python'>
上面的代码做了什么?
r"Python"是一个最简单的正则表达式——它只匹配"Python"这个精确的字符串re.search()在text里从左到右扫描,找到Python后返回一个匹配对象- 匹配对象告诉我们:找到了,它在位置 4~10(字符串下标从 0 开始)
小提示:正则表达式前的
r表示"原生字符串"。后面第三部分会详细解释为什么几乎总是要加这个r。
二、基础语法与使用方法
2.1 匹配单个字符的元字符
"元字符"(metacharacter)是正则表达式里的特殊符号,它们不代表自己,而是代表"某一类字符"。这是正则最基础也最重要的概念。
. — 匹配任意单个字符(换行符除外)
点号就像一个"占位符",表示"这里可以是任何字符"。
import re
# 匹配 "a" 和 "c" 之间夹任意一个字符
print(re.findall(r"a.c", "abc a+c a.c a我c")) # ['abc', 'a+c', 'a.c', 'a我c']
# 解释:a.c 模式匹配 a + 任意1个字符 + c,所以上面4组全部匹配
# 点号不匹配换行符 \n
text = "a\nc"
print(re.findall(r"a.c", text)) # [] 空列表,因为中间是换行符
\d — 匹配任意一个数字(0-9)
可以理解为 digit 的缩写。它的反义版本是 \D,匹配非数字。
# \d 匹配数字
print(re.findall(r"\d", "我有3个苹果和5个橘子")) # ['3', '5']
# \d+ 匹配连续的数字
print(re.findall(r"\d+", "我的电话是13800138000")) # ['13800138000']
# \D 匹配非数字
print(re.findall(r"\D+", "abc123xyz456")) # ['abc', 'xyz']
\w — 匹配字母、数字、下划线
可以理解为 word 的缩写,匹配"单词里允许出现的字符"。反义版本是 \W。
# \w 匹配单个"单词字符"
print(re.findall(r"\w", "user_name@123")) # ['u','s','e','r','_','n','a','m','e','1','2','3']
# 注意:@ 符号没有被匹配,因为它不属于 \w
# \W 匹配"非单词字符"
print(re.findall(r"\W", "user_name@123")) # ['@']
\s — 匹配空白字符
可以理解为 space 的缩写。匹配空格、Tab(\t)、换行(\n)、回车(\r)等。反义版本是 \S。
text = "Hello\tWorld\nPython 很棒"
# \s+ 匹配连续空白
print(re.split(r"\s+", text)) # ['Hello', 'World', 'Python', '很棒']
# \S+ 匹配连续的非空白(即每个"词")
print(re.findall(r"\S+", text)) # ['Hello', 'World', 'Python', '很棒']
元字符速查表
| 元字符 | 含义 | 反义版本 | 反义含义 |
|---|---|---|---|
. |
任意字符(除换行) | — | — |
\d |
数字 0-9 | \D |
非数字 |
\w |
字母、数字、下划线 | \W |
非单词字符 |
\s |
空白字符(空格/Tab/换行) | \S |
非空白字符 |
课堂小练习 1
- 编写正则表达式,匹配一行文字中所有的标点符号(提示:标点符号既不是
\w也不是\s,但.匹配太宽了——想想用什么组合?)- 编写正则表达式,判断一个字符串是否全部由数字组成
2.2 匹配数量的元字符
知道了"匹配哪种字符"还不够,你还需要告诉正则"匹配几个"。数量元字符(也叫量词)就是干这个的。
* — 匹配 0 次或多次
"可以有,也可以没有,有几个都行"。
# 匹配 "ab"、"aab"、"aaab"……以及单独的 "b"(a出现0次)
print(re.findall(r"a*b", "b ab aab aaab aaaacb"))
# ['b', 'ab', 'aab', 'aaab', 'aaaab']
# 注意 "aaaacb" 中的 "aaaacb" 不匹配 a*b,因为 c 打断了
# 实际上匹配到了 aaaab(a*a 中 a* 匹配 aaaa,b 匹配 b)
+ — 匹配 1 次或多次
"至少要有一个,上不封顶"。
# 匹配至少一个数字
print(re.findall(r"\d+", "价格99元,数量5个")) # ['99', '5']
# * 和 + 的区别
text = "abc 123"
print(re.findall(r"\d*", text)) # ['', '', '', '', '123', '']
# \d* 在非数字位置匹配了空字符串(0次)
print(re.findall(r"\d+", text)) # ['123']
# \d+ 要求至少1个数字,空字符串不匹配
? — 匹配 0 次或 1 次
"可有可无,最多一个"。
# 匹配 "color" 或 "colour"(u可有可无)
print(re.findall(r"colou?r", "color colour colouur"))
# ['color', 'colour']
# "colouur" 不匹配,因为有两个 u
{n} — 精确匹配 n 次
"不多不少,就要 n 个"。
# 匹配恰好 3 个数字
print(re.findall(r"\d{3}", "123 4567 89 012"))
# ['123', '456', '012']
# "4567" 中匹配了 "456"(前3个),"7" 没匹配
{n,m} — 匹配 n 到 m 次
"最少 n 个,最多 m 个"。
# 匹配 3 到 4 个数字
print(re.findall(r"\d{3,4}", "12 123 1234 12345"))
# ['123', '1234', '1234']
# "12" 不够3个,"12345" 取了前4个 "1234"
{n,} — 匹配至少 n 次
{2,} 等价于"至少有 2 个"。
print(re.findall(r"\d{2,}", "1 12 123 1234"))
# ['12', '123', '1234']
生活中的实用案例:匹配手机号
# 手机号:1 开头 + 总共 11 位数字
phone_regex = r"1\d{10}"
text = "我的电话是13912345678,另一个是15888889999"
print(re.findall(phone_regex, text))
# ['13912345678', '15888889999']
生活中的实用案例:匹配邮箱前缀
# 邮箱格式:用户名@域名
# 用户名:字母数字下划线,3-20位
username_regex = r"\w{3,20}"
text = "联系我:admin@example.com 或 support_2024@company.cn"
# 我们先提取用户名部分(@前面的)
print(re.findall(username_regex, text))
课堂小练习 2
- 写一个正则表达式,匹配恰好 6 位数字的邮编
- 写一个正则表达式,匹配至少 8 位的密码(只含字母数字和下划线)
- 写一个正则,匹配 Python 代码中的整数常量(可能有负号,但不能有前导零的 0 本身除外,如
-123、0、456)
2.3 匹配位置的元字符
前面的元字符匹配的是"内容",而位置元字符匹配的是"位置"——它不消耗字符,只检查当前位置是否满足条件。
^ — 匹配字符串开头
# 以数字开头
print(re.search(r"^\d+", "123abc")) # 匹配到 '123'
print(re.search(r"^\d+", "abc123")) # None,因为不是数字开头
# 实用场景:验证字符串是否以 http 开头
texts = ["https://www.example.com", "这是一段包含https://的字"]
for t in texts:
if re.search(r"^https?://", t):
print(f"'{t}' 是一个网址开头 ✓")
else:
print(f"'{t}' 不是网址开头 ✗")
# 输出:
# 'https://www.example.com' 是一个网址开头 ✓
# '这是一段包含https://的字' 不是网址开头 ✗
$ — 匹配字符串结尾
# 以 .com 结尾
print(re.search(r"\.com$", "www.example.com")) # 匹配到 '.com'
print(re.search(r"\.com$", "example.com/url")) # None,/url 在结尾
# 实用场景:判断文件是否是 Python 文件
filename = "script.py"
if re.search(r"\.py$", filename):
print(f"{filename} 是 Python 文件 ✓")
\b — 匹配单词边界
"单词边界"是指 \w 和 \W 之间的位置。
# 匹配独立的 "cat" 而不是 "category" 里的 "cat"
print(re.findall(r"\bcat\b", "cat category catfish mycat cat"))
# ['cat', 'cat']
# 只有两个独立的 "cat" 被匹配,"category"、"catfish"、"mycat" 都不算
手机号验证的改进版:位置 + 数量 + 内容
# 严格要求:整个字符串恰好是一个合法的手机号
# 只允许 13x/15x/18x/19x/17x 这些真实号段
phone_regex = r"^1[3-9]\d{9}$"
test_numbers = [
"13912345678", # 合法
"1391234567", # 10位,不合法
"139123456789", # 12位,不合法
"23912345678", # 2开头,不合法
"abc13912345678", # 有字母,不合法
"13912345678abc", # 后面有字母,不合法
]
for num in test_numbers:
if re.match(phone_regex, num):
print(f"✓ {num} 是合法手机号")
else:
print(f"✗ {num} 不是合法手机号")
输出:
✓ 13912345678 是合法手机号
✗ 1391234567 不是合法手机号
✗ 139123456789 不是合法手机号
✗ 23912345678 不是合法手机号
✗ abc13912345678 不是合法手机号
✗ 13912345678abc 不是合法手机号
课堂小练习 3
- 写一个正则,用
^和$验证一个字符串是否恰好是 6 位纯数字- 解释为什么
re.search(r"\bpython\b", "python123")返回 None- 写一个正则,匹配以大写字母开头、以问号结尾的句子
2.4 逻辑与分组元字符
[] — 字符集
方括号定义一个"字符集",匹配其中任意一个字符。
# [abc] 匹配 a 或 b 或 c 中的任意一个
print(re.findall(r"[abc]", "apple banana cherry"))
# ['a', 'a', 'b', 'a', 'a', 'a', 'c']
# [a-z] 匹配任意小写字母
print(re.findall(r"[a-z]", "Hello World 123"))
# ['e', 'l', 'l', 'o', 'o', 'r', 'l', 'd']
# [0-9A-Fa-f] 匹配十六进制数字
print(re.findall(r"[0-9A-Fa-f]+", "颜色 #FF8800 和 #abc"))
# ['FF8800', 'abc']
# [^...] 取反:匹配不在字符集中的字符
print(re.findall(r"[^0-9]", "abc123xyz"))
# ['a', 'b', 'c', 'x', 'y', 'z']
| — 或逻辑
# 匹配 "http" 或 "https"
print(re.findall(r"https?://", "http://a.com https://b.com"))
# ['http://', 'https://']
# 匹配多种文件扩展名
print(re.findall(r"\.(py|java|js)$", "script.py")) # [('py',)]
print(re.findall(r"\.(py|java|js)$", "script.java")) # [('java',)]
print(re.findall(r"\.(py|java|js)$", "script.cpp")) # []
() — 分组捕获
括号有两个作用:(1) 把一部分正则当作整体;(2) 把匹配到的内容"捕获"下来单独取用。
作用一:当作整体
# 匹配重复出现的 "ab"
print(re.findall(r"(ab)+", "ab abab ababab"))
# ['ab', 'ab', 'ab']
# r"(ab)+" 把 "ab" 当成整体,匹配 1 个或多个 "ab"
作用二:提取内容
# 提取邮箱的用户名和域名
email = "admin@example.com"
match = re.search(r"(\w+)@(\w+\.\w+)", email)
if match:
print(f"用户名: {match.group(1)}") # 用户名: admin
print(f"域名: {match.group(2)}") # 域名: example.com
print(f"完整: {match.group(0)}") # 完整: admin@example.com
| group 编号 | 内容 |
|---|---|
group(0) |
整个匹配结果 |
group(1) |
第一个括号捕获的内容 |
group(2) |
第二个括号捕获的内容 |
课堂小练习 4
- 用字符集写一个正则,匹配合法的十六进制颜色值(如
#FF8800、#abc)- 用分组提取 URL 中的协议(http/https)和域名两部分
- 用
|写一个正则,匹配日期格式YYYY-MM-DD或YYYY/MM/DD
2.5 re 模块核心方法实操
Python 的 re 模块提供了几个核心方法,各司其职。
re.match() — 从字符串开头匹配
import re
# match 必须从字符串的第一个字符开始匹配
result = re.match(r"\d+", "123abc")
print(result.group()) # 123
result = re.match(r"\d+", "abc123")
print(result) # None — 因为开头是字母,不是数字
# 典型用途:判断字符串是否以某种模式开头
def starts_with_number(s):
return bool(re.match(r"\d", s))
print(starts_with_number("123abc")) # True
print(starts_with_number("abc123")) # False
re.search() — 扫描整个字符串,返回第一个匹配
text = "客服电话:400-888-9999,投诉电话:400-666-7777"
# search 在全文查找第一个匹配
result = re.search(r"\d{3,4}-\d{3,4}-\d{4}", text)
if result:
print(f"找到号码: {result.group()}") # 找到号码: 400-888-9999
print(f"位置: {result.start()}-{result.end()}") # 位置: 5-18
# 典型用途:在长文本中找第一个符合模式的内容
log = "2024-01-15 10:30:25 ERROR: 数据库连接失败"
match = re.search(r"ERROR|WARN|INFO", log)
print(f"日志级别: {match.group()}") # 日志级别: ERROR
re.findall() — 返回所有匹配的列表
text = """
联系方式:
张三 zhang@company.com
李四 li-si@school.edu.cn
王五 wangwu@gmail.com
"""
# 提取所有邮箱
emails = re.findall(r"[\w\-]+@[\w\-]+\.\w+(?:\.\w+)?", text)
print(emails)
# ['zhang@company.com', 'li-si@school.edu.cn', 'wangwu@gmail.com']
# 典型用途:批量提取信息
html = "<ul><li>苹果</li><li>香蕉</li><li>橘子</li></ul>"
fruits = re.findall(r"<li>(.*?)</li>", html)
print(fruits) # ['苹果', '香蕉', '橘子']
re.sub() — 正则替换
text = "我的手机号是13800138000,他的手机号是13912345678"
# 把手机号替换为 ***
masked = re.sub(r"1\d{10}", "***", text)
print(masked)
# 我的手机号是***,他的手机号是***
# 用捕获组做更灵活的替换:把日期格式从 YYYY-MM-DD 改成 DD/MM/YYYY
date_text = "会议日期:2024-03-15 到 2024-06-01"
new_text = re.sub(r"(\d{4})-(\d{2})-(\d{2})", r"\3/\2/\1", date_text)
print(new_text)
# 会议日期:15/03/2024 到 01/06/2024
# \3 引用第三个捕获组(日),\2引用第二个(月),\1引用第一个(年)
re.compile() — 预编译正则
如果你需要对成千上万条文本反复使用同一个正则表达式,预编译能显著提速。
import re, time
# 场景:检查100万条日志中哪些行的级别是ERROR
pattern = re.compile(r"\bERROR\b")
# 性能测试:预编译 vs 每次编译
texts = ["INFO: 一切正常"] * 50000 + ["ERROR: 磁盘已满"] * 50000
start = time.time()
for t in texts:
re.search(r"\bERROR\b", t) # 每次都重新编译
print(f"未预编译: {time.time() - start:.3f}秒")
start = time.time()
for t in texts:
pattern.search(t) # 使用预编译对象
print(f"预编译: {time.time() - start:.3f}秒")
记忆口诀:
match= 从头开始,必须开局就对上search= 全篇搜索,找到第一个就停findall= 全篇搜索,一个不落全捞出来sub= 查找并替换,正则版的 replacecompile= 先编译后使用,高频场景效率高
课堂小练习 5
- 用
re.findall从一段文本中提取所有以#开头的标签(如#Python、#学习)- 用
re.sub将文本中所有的金额数字(如100元、50.5元)替换为[金额]- 编写一个函数,用
re.search判断一个字符串是否为合法的 IPv4 地址格式
2.6 分组捕获的进阶用法
命名分组
给每个捕获组起个名字,代码更可读:
# 日志格式:2024-01-15 10:30:25 [ERROR] 数据库连接失败 - 超时
log = "2024-01-15 10:30:25 [ERROR] 数据库连接失败 - 超时"
pattern = r"^(?P<date>\d{4}-\d{2}-\d{2})\s+(?P<time>\d{2}:\d{2}:\d{2})\s+\[(?P<level>\w+)\]\s+(?P<message>.+)"
match = re.search(pattern, log)
if match:
print(f"日期: {match.group('date')}") # 日期: 2024-01-15
print(f"时间: {match.group('time')}") # 时间: 10:30:25
print(f"级别: {match.group('level')}") # 级别: ERROR
print(f"内容: {match.group('message')}") # 内容: 数据库连接失败 - 超时
# 还可以直接转字典!
print(match.groupdict())
# {'date': '2024-01-15', 'time': '10:30:25', 'level': 'ERROR', 'message': '数据库连接失败 - 超时'}
命名分组的语法:(?P<名字>正则模式),之后用 group('名字') 取值。
反向引用
反向引用让你在正则表达式内部引用前面捕获的内容。这对于匹配"前后一致"的模式非常有用。
# 匹配 HTML 中的配对标签:开始标签和结束标签的名字必须一样
html = "<div>外层内容</div> <span>内层内容</span>"
# \1 引用第一个捕获组匹配到的内容
tags = re.findall(r"<(\w+)>.*?</\1>", html)
print(tags) # ['div', 'span']
# 不写反向引用的错误示范:
wrong = re.findall(r"<(\w+)>.*?</\w+>", "<div>内容</span>")
print(wrong) # ['div'] ← 错误!<div> 配 </span> 也被匹配了
# 更实用的例子:匹配重复出现的单词
text = "I love love Python, it is is great"
duplicates = re.findall(r"\b(\w+)\s+\1\b", text)
print(duplicates) # ['love', 'is']
# 解释:\1 要求后面的单词和前面 \b(\w+) 匹配到的完全一样
非捕获分组
有时候你只需要用括号把一组正则为整体,但不需要捕获内容。用 (?:...) 代替 (...) 即可。
# 提取域名,但不需要捕获 .com/.cn/.org 部分
text = "访问 example.com 或 test.cn"
domains = re.findall(r"(\w+)\.(?:com|cn|org)", text)
print(domains) # ['example', 'test']
# 如果用 (...) 而非 (?:...),会多出不需要的捕获组
课堂小练习 6
- 用命名分组写一个正则,解析 URL
https://www.example.com:8080/path?key=value,提取协议、域名、端口、路径四部分- 用反向引用写一个正则,匹配 HTML 中成对的标题标签(
<h1>...</h1>、<h2>...</h2>)
三、注意事项与避坑指南
3.1 转义字符的坑
这是新手最容易踩的坑。问题的根源在于:Python 字符串本身有转义规则,正则表达式也有自己的转义规则。
# 错误示范:匹配 C:\test.txt
import re
# 这样写是错的!
path_pattern = "\test"
# Python 字符串中 \t 被转义成了 Tab 字符,正则收到的实际是 <Tab>est
# 正确写法:用原生字符串 r''
path_pattern = r"\\test"
# r'' 告诉 Python:"别转义,原样保留"
Windows 路径匹配的正确姿势:
# 匹配 C:\Program Files\Python\python.exe
path = r"C:\Program Files\Python\python.exe"
# 正确:用 r'' 原始字符串
match = re.search(r"C:\\Program Files\\Python\\.+", path)
print(match.group()) # C:\Program Files\Python\python.exe
黄金法则:正则表达式几乎永远用 r'' 包裹。
# 对比
print(re.search("\d+", "123")) # 能运行,但不推荐(\d 恰好在 Python 和非正则转义里都没有特殊含义)
print(re.search(r"\d+", "123")) # 推荐写法
# 下面这个不写 r 就会翻车:
print(re.search("\bword\b", "a word here")) # \b 在 Python 字符串里是退格符!
print(re.search(r"\bword\b", "a word here")) # 正确,\b 被原样传给正则引擎
3.2 贪婪匹配 vs 非贪婪匹配
默认情况下,*、+、? 等量词都是贪婪的——它们会尽可能多地匹配。
# 经典陷阱:提取 HTML 里的内容
html = "<div>第一个区块</div><div>第二个区块</div>"
# 贪婪匹配(默认):一把抓到底
greedy = re.findall(r"<div>(.*)</div>", html)
print(greedy) # ['第一个区块</div><div>第二个区块']
# 天呐,把中间的一整段全吞了!
# 非贪婪匹配(加 ?):见好就收
lazy = re.findall(r"<div>(.*?)</div>", html)
print(lazy) # ['第一个区块', '第二个区块']
# 完美!每个 div 的内容单独提取出来了
量词贪婪/非贪婪对照表:
| 贪婪 | 非贪婪 | 含义 |
|---|---|---|
* |
*? |
0或多次 |
+ |
+? |
1或多次 |
? |
?? |
0或1次 |
{n,m} |
{n,m}? |
n到m次 |
记忆技巧:非贪婪就是在量词后面加 ?,可以理解为"少一点,再少一点"。
# 另一个常见例子:提取引号里的内容
text = '"hello" and "world"'
print(re.findall(r'"(.*)"', text)) # ['hello" and "world'] ← 贪婪,全吞了
print(re.findall(r'"(.*?)"', text)) # ['hello', 'world'] ← 非贪婪,正确
3.3 性能问题
灾难性回溯
某些看似简单的正则可能让程序卡死。罪魁祸首是"灾难性回溯"(Catastrophic Backtracking)。
# 危险示范:不要写这种正则!
import re, time
# (a+)+b 当匹配 "aaaaaaaaaaaaaaaaaaaaaaac" 时
# 正则引擎会尝试无数种 a 的组合方式,导致指数级耗时
dangerous_pattern = r"(a+)+b"
text = "a" * 25 + "c"
start = time.time()
result = re.search(dangerous_pattern, text)
print(f"耗时: {time.time() - start:.2f}秒") # 可能十几秒甚至更久
# 结果: None(因为最后是 c 不是 b,但正则花了巨大代价才发现不匹配)
避免方法:
# 方案1:简化,去掉无谓的嵌套量词
safe_pattern = r"a+b" # 直接一个 + 就够,不需要套 (a+)+
# 方案2:用更精确的限定,而不是无脑的 .*
bad = r"<div>(.*)</div>" # 如果文本很长,.* 性能差
good = r"<div>([^<]*)</div>" # 明确说"除了 < 之外的字符",精确且快
编写正则的性能原则:
- 不要嵌套量词,如
(a+)+、(.*)* - 尽量用精确的字符集代替
.,如用[^"]代替.*? - 复杂场景拆成多步正则,而不是一味堆在一个正则里
3.4 匹配准确性的问题
宁可精确,不要宽泛。 宽泛的正则会匹配到你不想匹配的东西。
# 反面教材:用 \d{11} 匹配手机号
text = "身份证号:320106199001011234,手机号:13912345678"
# 错误的宽泛匹配
bad = re.findall(r"\d{11}", text)
print(bad) # ['32010619900', '13912345678']
# 身份证的前11位也被匹配了!这显然不是手机号
# 正确做法:加约束条件
good = re.findall(r"1[3-9]\d{9}", text) # 要求1开头,第二位3-9
print(good) # ['13912345678'] ← 只有真的手机号
# 更好的做法:结合位置元字符
better = re.findall(r"\b1[3-9]\d{9}\b", text) # 加单词边界
实战对比:邮箱匹配的从粗到精:
text = "联系:admin@co.uk 或 test_user@company.com.cn 或 假@邮箱"
# Level 1:太宽泛
print(re.findall(r"\S+@\S+", text))
# ['admin@co.uk', 'test_user@company.com.cn', '假@邮箱']
# Level 2:加一些约束
print(re.findall(r"\w+@\w+\.\w+", text))
# ['test_user@company.com'] ← 漏了 .cn 和 .co.uk
# Level 3:比较完善
good = re.findall(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}", text)
print(good)
# ['admin@co.uk', 'test_user@company.com.cn']
3.5 可读性维护:re.VERBOSE
复杂正则像天书,别说是别人,过一周自己都看不懂。re.VERBOSE 允许你写带注释和空行的正则。
import re
# 不用 VERBOSE:一团乱码
email_regex_tight = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
# 用 VERBOSE:清晰易读
email_regex = re.compile(r"""
^ # 字符串开头
[a-zA-Z0-9._%+-]+ # 用户名:字母数字 + ._%+-
@ # 必须有一个 @
[a-zA-Z0-9.-]+ # 域名主体:字母数字 . -
\. # 必须有一个点
[a-zA-Z]{2,} # 顶级域名:至少 2 个字母
$ # 字符串结尾
""", re.VERBOSE)
test_emails = [
"user@example.com",
"test.user+tag@company.co.uk",
"invalid@email",
"@no-username.com",
]
for email in test_emails:
if email_regex.match(email):
print(f"✓ {email}")
else:
print(f"✗ {email}")
还可以组合多个 flag:
# re.VERBOSE | re.IGNORECASE:注释 + 忽略大小写
pattern = re.compile(r"""
http[s]? # 协议
:// # ://
[\w.-]+ # 域名
""", re.VERBOSE | re.IGNORECASE)
课堂小练习 7
- 写一段代码对比贪婪和非贪婪提取多行代码注释
/* ... */的差异- 把第三部分的手机号正则改为
re.VERBOSE格式,加上注释- 写出一个可能导致灾难性回溯的正则,并给出安全的替代写法
四、教学配套内容
4.1 课堂练习答案参考
以下给出前面各练习的参考答案。
练习 1 — 匹配标点符号:
# 标点符号:既不是 \w 也不是 \s
text = "Hello, world! How are you?"
punct = re.findall(r"[^\w\s]", text)
print(punct) # [',', '!', '?']
判断全数字:
def is_all_digits(s):
return bool(re.match(r"^\d+$", s))
print(is_all_digits("12345")) # True
print(is_all_digits("123a45")) # False
练习 2 — 6位邮编:
print(bool(re.match(r"^\d{6}$", "210000"))) # True
print(bool(re.match(r"^\d{6}$", "21000"))) # False
至少8位密码:
print(bool(re.match(r"^\w{8,}$", "secure_pass123"))) # True
print(bool(re.match(r"^\w{8,}$", "short"))) # False
练习 3 — 以大写字母开头、以问号结尾:
pattern = r"^[A-Z].*\?$"
print(bool(re.match(pattern, "How are you?"))) # True
print(bool(re.match(pattern, "how are you?"))) # False(小写开头)
print(bool(re.match(pattern, "How are you."))) # False(句号结尾)
练习 4 — 十六进制颜色:
color_pattern = r"^#[0-9A-Fa-f]{3}([0-9A-Fa-f]{3})?$"
print(bool(re.match(color_pattern, "#FF8800"))) # True
print(bool(re.match(color_pattern, "#abc"))) # True
print(bool(re.match(color_pattern, "#GG0000"))) # False(G 不在十六进制范围)
提取 URL 协议和域名:
url = "https://www.example.com/path"
match = re.search(r"^(https?)://([^/]+)", url)
print(f"协议: {match.group(1)}") # 协议: https
print(f"域名: {match.group(2)}") # 域名: www.example.com
练习 5 — 提取标签:
text = "今天学习了 #Python 和 #正则表达式,收获很大 #编程"
tags = re.findall(r"#([\w\u4e00-\u9fff]+)", text)
print(tags) # ['Python', '正则表达式', '编程']
判断 IPv4:
def is_valid_ipv4(s):
pattern = r"^((25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)\.){3}(25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)$"
return bool(re.match(pattern, s))
print(is_valid_ipv4("192.168.1.1")) # True
print(is_valid_ipv4("256.1.1.1")) # False(256 超出范围)
print(is_valid_ipv4("192.168.1")) # False(少了一组)
练习 6 — URL 解析(命名分组):
url = "https://www.example.com:8080/path?key=value"
pattern = r"^(?P<protocol>https?)://(?P<domain>[^:/]+)(?::(?P<port>\d+))?(?P<path>/[^?]*)?"
match = re.search(pattern, url)
print(match.groupdict())
# {'protocol': 'https', 'domain': 'www.example.com', 'port': '8080', 'path': '/path'}
练习 7 — 贪婪匹配注释:
code = "/* 注释1 */ int x = 1; /* 注释2 */ int y = 2;"
# 贪婪:从第一个 /* 吃到最后一个 */
print(re.findall(r"/\*.*\*/", code))
# ['/* 注释1 */ int x = 1; /* 注释2 */']
# 非贪婪:每次遇到 */ 就停
print(re.findall(r"/\*.*?\*/", code))
# ['/* 注释1 */', '/* 注释2 */']
4.2 常用正则案例汇总
以下每个案例都带完整的 Python 调用代码和运行结果。
案例 1:手机号验证
import re
def validate_phone(phone):
"""验证中国大陆手机号(13/14/15/16/17/18/19开头,共11位)"""
pattern = r"^1[3-9]\d{9}$"
return bool(re.match(pattern, phone))
# 测试
test_cases = ["13912345678", "19988887777", "12345678901", "1391234567", "abc"]
for case in test_cases:
print(f"{case:>15} → {'合法 ✓' if validate_phone(case) else '非法 ✗'}")
# 输出:
# 13912345678 → 合法 ✓
# 19988887777 → 合法 ✓
# 12345678901 → 非法 ✗
# 1391234567 → 非法 ✗
# abc → 非法 ✗
案例 2:18位身份证号
def validate_id_card(id_num):
"""
验证18位身份证号
前17位数字 + 最后1位数字或X
"""
pattern = r"^[1-9]\d{5}(19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$"
return bool(re.match(pattern, id_num))
test = ["320106199001011234", "123456789012345678", "320106199013011234"]
for case in test:
print(f"{case} → {'合法 ✓' if validate_id_card(case) else '非法 ✗'}")
# 输出:
# 320106199001011234 → 合法 ✓
# 123456789012345678 → 非法 ✗ (地区码不能以0开头)
# 320106199013011234 → 非法 ✗ (13月不存在)
案例 3:邮箱地址
def extract_emails(text):
"""从文本中提取所有邮箱地址"""
pattern = r"[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}"
return re.findall(pattern, text)
text = """
联系列表:
张三 zhangsan@company.com
李四 li.si@school.edu.cn
王五 wang-wu+tag@gmail.com
"""
emails = extract_emails(text)
for i, email in enumerate(emails, 1):
print(f"邮箱{i}: {email}")
# 输出:
# 邮箱1: zhangsan@company.com
# 邮箱2: li.si@school.edu.cn
# 邮箱3: wang-wu+tag@gmail.com
案例 4:网址 URL
def extract_urls(text):
"""提取文本中所有 URL"""
pattern = r"https?://[^\s()<>\"']+"
return re.findall(pattern, text)
text = "访问 https://www.python.org 或 http://example.com/page?id=123 了解更多"
urls = extract_urls(text)
for url in urls:
print(f"找到: {url}")
# 输出:
# 找到: https://www.python.org
# 找到: http://example.com/page?id=123
案例 5:日期格式
def parse_dates(text):
"""匹配 YYYY-MM-DD、YYYY/MM/DD、YYYY.MM.DD 三种日期格式"""
pattern = r"(\d{4})[-/.](\d{2})[-/.](\d{2})"
matches = re.finditer(pattern, text)
for m in matches:
print(f"日期: {m.group(1)}年{m.group(2)}月{m.group(3)}日")
text = "项目起始于2024-01-15,第一版发布在2024/03/20,最终版2024.06.01上线"
parse_dates(text)
# 输出:
# 日期: 2024年01月15日
# 日期: 2024年03月20日
# 日期: 2024年06月01日
案例 6:匹配所有中文
def extract_chinese(text):
"""提取文本中所有中文字符"""
pattern = r"[\u4e00-\u9fff]+"
return re.findall(pattern, text)
text = "Python是一种解释型语言,由Guido van Rossum在1989年发明。"
chinese_parts = extract_chinese(text)
print("".join(chinese_parts)) # 是一种解释型语言由在年发明
案例 7:密码强度检查
def check_password_strength(password):
"""
密码强度规则:
- 至少8位
- 包含大写字母
- 包含小写字母
- 包含数字
- 包含特殊字符
"""
checks = {
"长度≥8": bool(re.search(r".{8,}", password)),
"包含大写字母": bool(re.search(r"[A-Z]", password)),
"包含小写字母": bool(re.search(r"[a-z]", password)),
"包含数字": bool(re.search(r"\d", password)),
"包含特殊字符": bool(re.search(r"[^a-zA-Z0-9]", password)),
}
score = sum(checks.values())
return score, checks
password = "MyP@ssw0rd"
score, details = check_password_strength(password)
print(f"密码: {password}")
print(f"强度得分: {score}/5")
for item, passed in details.items():
print(f" {'✓' if passed else '✗'} {item}")
# 输出:
# 密码: MyP@ssw0rd
# 强度得分: 5/5
# ✓ 长度≥8
# ✓ 包含大写字母
# ✓ 包含小写字母
# ✓ 包含数字
# ✓ 包含特殊字符
案例 8:IP 地址提取
def extract_ips(text):
"""提取文本中的 IPv4 地址"""
pattern = r"\b(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\b"
return re.findall(pattern, text)
log = """
192.168.1.1 - - [15/Jan/2024:10:30:25] "GET /index.html" 200
10.0.0.1 - - [15/Jan/2024:10:30:26] "POST /api/data" 201
999.999.999.999 - - 这不是合法IP
"""
ips = extract_ips(log)
print(f"找到 {len(ips)} 个IP地址:")
for ip in ips:
print(f" - {ip}")
# 输出:
# 找到 2 个IP地址:
# - 192.168.1.1
# - 10.0.0.1
4.3 综合实战练习
实战1:日志分析器
import re
from collections import Counter
log_data = """
2024-01-15 10:30:25 [INFO] Server started on port 8080
2024-01-15 10:31:02 [ERROR] Database connection failed: timeout
2024-01-15 10:31:15 [WARN] Retry attempt 1/3
2024-01-15 10:31:28 [INFO] User admin logged in from 192.168.1.100
2024-01-15 10:32:01 [ERROR] File not found: /var/www/index.html
2024-01-15 10:32:10 [WARN] Disk usage at 85%
2024-01-15 10:33:00 [ERROR] Out of memory: requested 2GB
"""
def analyze_log(log_text):
"""分析日志:统计各等级的日志数量,提取所有错误信息"""
# 1. 解析每条日志
log_pattern = re.compile(r"""
(?P<date>\d{4}-\d{2}-\d{2})\s+
(?P<time>\d{2}:\d{2}:\d{2})\s+
\[(?P<level>\w+)\]\s+
(?P<message>.+)
""", re.VERBOSE)
entries = []
for line in log_text.strip().split("\n"):
match = log_pattern.search(line)
if match:
entries.append(match.groupdict())
# 2. 统计各级别数量
levels = [e['level'] for e in entries]
level_counts = Counter(levels)
print("=== 日志统计 ===")
for level in ["INFO", "WARN", "ERROR"]:
print(f" [{level}]: {level_counts.get(level, 0)} 条")
# 3. 列出所有错误
print("\n=== 错误详情 ===")
errors = [e for e in entries if e['level'] == 'ERROR']
for i, err in enumerate(errors, 1):
print(f" {i}. [{err['time']}] {err['message']}")
return entries
analyze_log(log_data)
# 输出:
# === 日志统计 ===
# [INFO]: 2 条
# [WARN]: 2 条
# [ERROR]: 3 条
#
# === 错误详情 ===
# 1. [10:31:02] Database connection failed: timeout
# 2. [10:32:01] File not found: /var/www/index.html
# 3. [10:33:00] Out of memory: requested 2GB
实战2:文本脱敏工具
def mask_sensitive(text):
"""对文本中的手机号、邮箱、身份证号进行脱敏处理"""
# 手机号脱敏:保留前3后4,中间4位替换为****
text = re.sub(
r"\b(1[3-9]\d)(\d{4})(\d{4})\b",
r"\1****\3",
text
)
# 邮箱脱敏:保留首字母和@后面,中间替换为***
text = re.sub(
r"\b([a-zA-Z0-9._%+\-])[a-zA-Z0-9._%+\-]*([a-zA-Z0-9._%+\-]@)",
r"\1***\2",
text
)
# 身份证脱敏:保留前6后4,中间替换为********
text = re.sub(
r"\b(\d{6})\d{8}(\d{4}[\dXx])\b",
r"\1********\2",
text
)
return text
original = """
客户信息:
姓名:张三
手机:13912345678
邮箱:zhangsan123@company.com
身份证:320106199001011234
"""
masked = mask_sensitive(original)
print(masked)
# 输出:
# 客户信息:
# 姓名:张三
# 手机:139****5678
# 邮箱:z***3@company.com
# 身份证:320106********1234
4.4 本章小结
回顾本章的核心内容:
| 模块 | 核心知识点 | |
|---|---|---|
| 元字符 | . \d \w \s 及其反义 \D \W \S |
|
| 量词 | * + ? {n} {n,m} {n,} |
|
| 位置 | ^ 开头、$ 结尾、\b 单词边界 |
|
| 分组 | [] 字符集、` |
或、() 捕获、(?:) 非捕获、(?P |
| re 方法 | match 从头匹配、search 搜第一个、findall 全搜、sub 替换、compile 预编译 |
|
| 避坑 | 贪婪vs非贪婪、灾难性回溯、转义字符 r''、准确匹配、re.VERBOSE 可读性 |
学习建议:
- 多动手,每个元字符都在 Python 里跑一遍看看效果
- 遇到实际文本处理需求时,尝试"能不用正则就不用正则,必须用时写清楚"
- 复杂正则一定要加注释,推荐用
re.VERBOSE模式 - 网上有很多正则在线测试工具(如 regex101.com),初学时多用来调试自己的表达式
正则表达式是程序员的基本功,刚开始学可能觉得记不住那么多符号,但随着练习的增加,你会慢慢形成"肌肉记忆"。记住:没有人能一次性写出完美的正则——大家都是写出来、跑一跑、改一改,逐步完善的。