目 录CONTENT

文章目录

Python(四十) 正则表达式专题教程

Python(四十) 正则表达式专题教程

目录

  1. 正则表达式的核心定义
  2. 基础语法与使用方法
  3. 注意事项与避坑指南
  4. 教学配套内容

一、正则表达式的核心定义

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

  1. 编写正则表达式,匹配一行文字中所有的标点符号(提示:标点符号既不是 \w 也不是 \s,但 . 匹配太宽了——想想用什么组合?)
  2. 编写正则表达式,判断一个字符串是否全部由数字组成

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

  1. 写一个正则表达式,匹配恰好 6 位数字的邮编
  2. 写一个正则表达式,匹配至少 8 位的密码(只含字母数字和下划线)
  3. 写一个正则,匹配 Python 代码中的整数常量(可能有负号,但不能有前导零的 0 本身除外,如 -1230456

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

  1. 写一个正则,用 ^$ 验证一个字符串是否恰好是 6 位纯数字
  2. 解释为什么 re.search(r"\bpython\b", "python123") 返回 None
  3. 写一个正则,匹配以大写字母开头、以问号结尾的句子

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

  1. 用字符集写一个正则,匹配合法的十六进制颜色值(如 #FF8800#abc
  2. 用分组提取 URL 中的协议(http/https)和域名两部分
  3. | 写一个正则,匹配日期格式 YYYY-MM-DDYYYY/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 = 查找并替换,正则版的 replace
  • compile = 先编译后使用,高频场景效率高

课堂小练习 5

  1. re.findall 从一段文本中提取所有以 # 开头的标签(如 #Python#学习
  2. re.sub 将文本中所有的金额数字(如 100元50.5元)替换为 [金额]
  3. 编写一个函数,用 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

  1. 用命名分组写一个正则,解析 URL https://www.example.com:8080/path?key=value,提取协议、域名、端口、路径四部分
  2. 用反向引用写一个正则,匹配 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>"   # 明确说"除了 < 之外的字符",精确且快

编写正则的性能原则

  1. 不要嵌套量词,如 (a+)+(.*)*
  2. 尽量用精确的字符集代替 .,如用 [^"] 代替 .*?
  3. 复杂场景拆成多步正则,而不是一味堆在一个正则里

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

  1. 写一段代码对比贪婪和非贪婪提取多行代码注释 /* ... */ 的差异
  2. 把第三部分的手机号正则改为 re.VERBOSE 格式,加上注释
  3. 写出一个可能导致灾难性回溯的正则,并给出安全的替代写法

四、教学配套内容

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 可读性

学习建议

  1. 多动手,每个元字符都在 Python 里跑一遍看看效果
  2. 遇到实际文本处理需求时,尝试"能不用正则就不用正则,必须用时写清楚"
  3. 复杂正则一定要加注释,推荐用 re.VERBOSE 模式
  4. 网上有很多正则在线测试工具(如 regex101.com),初学时多用来调试自己的表达式

正则表达式是程序员的基本功,刚开始学可能觉得记不住那么多符号,但随着练习的增加,你会慢慢形成"肌肉记忆"。记住:没有人能一次性写出完美的正则——大家都是写出来、跑一跑、改一改,逐步完善的。

0
博主关闭了当前页面的评论