正则表达式
正则表达式(Regular Expression,简称 Regex 或 Regexp)是用于匹配字符串中字符组合的模式。它就像是文本处理的“瑞士军刀”。在很多高级编程语言中,正则表达式都被作为一种极其重要且强大的文本处理工具。虽然它看似晦涩难懂,但只要掌握了它的核心规律,你就能轻松应对各种复杂的文本搜索、提取和替换任务。
Python 通过内置的 re 模块提供了对正则表达式的完美支持。在本章中,我们将由浅入深,先学习正则表达式的匹配语法,再 掌握 Python 中 re 模块的实战用法。
为什么需要正则表达式?
在学习正则表达式之前,我们先来看看它能解决什么问题。
假设我们有一段文本,需要从中找出所有的电话号码。电话号码的格式可能是 010-12345678 或 021-87654321,即“3位区号-8位电话”。
如果只用我们之前学过的字符串方法,代码可能会非常冗长且脆弱:
text = "请拨打电话:010-12345678 或者 021-87654321。"
# 如果仅用普通的字符串方法,我们需要非常繁琐地切片、判断长度、
# 判断“-”的位置、判断各 个部分是否全是数字等,且极易出错。
而如果使用正则表达式,我们只需要写一行简洁的模式:r"\d{3}-\d{8}",然后调用 re.findall() 即可!
正则表达式的基础语法
正则表达式由普通字符(如字母 a 到 z)以及特殊字符(称为“元字符”,Metacharacters)组成。元字符赋予了正则表达式超强的模式描述能力。
下面是正则表达式中最常用的一些语法规则:
1. 字符匹配(Character Classes)
| 元字符 | 描述 | 示例与解释 |
|---|---|---|
. | 匹配除换行符之外的任意单个字符。 | p.t 可匹配 pat, pet, p-t, p9t 等。 |
[abc] | 字符集:匹配方括号中的任意一个字符。 | p[ae]t 仅匹配 pat 和 pet。 |
[^abc] | 排除字符集:匹配不在方括号中的任意一个字符。 | p[^ae]t 匹配 pot,但不匹配 pat 或 pet。 |
[a-z] | 字符范围:匹配指定范围内的任意单个字符。 | [0-9] 匹配任意数字,[a-zA-Z] 匹配任意英文字母。 |
2. 预定义字符集(Shorthands)
为了方便书写,正则表达式为常用的字符集提供了快捷方式(转义字符):
| 快捷键 | 对应字符集 | 描述 |
|---|---|---|
\d | [0-9] | 匹配任意数字(d 代表 decimal)。 |
\D | [^0-9] | 匹配任意非数字。 |
\w | [a-zA-Z0-9_] | 匹配任意字母、数字或下划线(w 代表 word)。在 Python 3 中默认也包括中文字符。 |
\W | [^a-zA-Z0-9_] | 匹配任意非字母、数字或下划线的字符。 |
\s | [ \t\n\r\f\v] | 匹配任意空白字符(包括空格、制表符、换行符等,s 代表 space)。 |
\S | [^ \t\n\r\f\v] | 匹配任意非空白字符。 |
3. 位置锚定(Anchors)
锚定符不匹配具体的字符,而是匹配字符之间的位置或字符串的边界:
| 元字符 | 描述 | 示例 |
|---|---|---|
^ | 匹配字符串的开头。如果开启了多行模式,也匹配每行的开头。 | ^Hello 匹配以 "Hello" 开头的字符串。 |
$ | 匹配字符串的结尾。如果开启了多行模式,也匹配每行的结尾。 | world$ 匹配以 "world" 结尾 of 字符串。 |
\b | 匹配一个单词边界(即单词字符与非单词字符之间交界的位置)。 | \bcat\b 只匹配独立的单词 "cat",不匹配 "category" 或 "copycat"。 |
4. 量词(Quantifiers)
量词用于指定前一个字符或字符集需要重复出现的次数:
| 元字符 | 描述 | 示例与解释 |
|---|---|---|
* | 重复 0 次或多次(等价于 {0,})。 | ab* 匹配 a, ab, abb, abbb 等。 |
+ | 重复 1 次或多次(等价于 {1,})。 | ab+ 匹配 ab, abb,但不匹配 a。 |
? | 重复 0 次或 1 次(即该字符是可选的,等价于 {0,1})。 | colors? 匹配 color 或 colors。 |
{n} | 正好重复 n 次。 | \d{6} 匹配连续的 6 位数字(如邮政编码)。 |
{n,} | 重复 至少 n 次。 | \d{3,} 匹配 3 位及以上的连续数字。 |
{n,m} | 重复 n 到 m 次。 | \d{3,5} 匹配 3 位、4 位或 5 位连续数字。 |
[!WARNING] 贪婪与非贪婪(Greedy vs. Non-greedy)
默认情况下,所有的量词都是贪婪的,即它们会尽可能多地匹配字符。 比如用模式
a.*b去匹配字符串"a1b2b3b"时,它会匹配整个"a1b2b3b"(直到最后一个b)。 如果我们想让它非贪婪(懒惰),即尽可能少地匹配,只需在量词后面加上一个问号?。 比如用模式a.*?b去匹配同一个字符串,它只会匹配到第一个b,即"a1b"。
5. 分组与分支选择
- 分组
(...):使用圆括号可以将多个字符组合成一个单元,作为一个整体应用量词。此外,括号还会“捕获”匹配到的子字符串,方便后续提取。例如(ab)+可以匹配ab,abab,ababab等。 - 分支选择
|:相当于逻辑“或”(OR),用于匹配多个可选模式之一。例如cat|dog可以匹配cat或dog。通常与括号结合使用,如I like (apples|bananas).。
Python 的 re 模块实战
掌握了正则表达式的基本语法后,我们来看看如何在 Python 代码中使用它们。
原始字符串(Raw Strings)的救赎
在 Python 中写正则表达式时,强烈建议使用原始字符串(以 r 开头的字符串),例如 r"\d{3}-\d{8}"。
为什么呢?因为在普通字符串中,反斜杠 \ 具有转义含义(例如 \n 代表换行,\t 代表制表符)。如果不用原始字符串,为了表示正则里的 \d,你必须写成 "\\d";为了匹配一个字面上的反斜杠 \,你甚至得写成 "\\\\"(四斜杠地狱!)。
而使用 r"...",Python 会原封不动地将反斜杠传递给正则引擎,让代码清爽得多:
# 推荐写法
pattern = r"\d+"
1. 查找第一个匹配:re.search() 与 re.match()
这两个函数都用于在字符串中寻找匹配,但有核心区别:
re.match():**必须从字符串的开头(索引 0)**开始匹配,如果开头不匹配,直接返回None。re.search():在整个字符串中扫描,寻找第一个匹配项。
它们在匹配成功时都会返回一个 Match 对象,未找到时返回 None。
import re
text = "Python is 100% awesome, 99% useful!"
# match 必须从开头匹配
match_result = re.match(r"\d+", text)
print(f"re.match 结果: {match_result}") # 输出: None,因为开头是 "Python" 而非数字
# search 在整个字符串中寻找第一个匹配
search_result = re.search(r"\d+", text)
print(f"re.search 结果: {search_result}") # 返回 Match 对象
if search_result:
# 使用 Match 对象的 group() 获取匹配到的文本
print(f"匹配到的内容: {search_result.group()}") # 输出: 100
# 获取匹配文本在原字符串中的起止索引
print(f"匹配位置: {search_result.span()}") # 输出: (10, 13)
2. 找出所有匹配项:re.findall() 与 re.finditer()
re.findall():找出字符串中所有满足模式的匹配项,并以**列表(list)**形式返回匹配到的文本。re.finditer():返回一个迭代器(iterator),每次迭代产生一个 Match 对象。在处理超长文本时,使用re.finditer可以节省大量内存。
import re
text = "Python is 100% awesome, 99% useful, and 0% boring!"
# findall 返回纯文本列表
numbers = re.findall(r"\d+", text)
print(f"findall 结果: {numbers}") # 输出: ['100', '99', '0']
# finditer 返回 Match 对象的迭代器,能获取每个匹配的具体位置
for m in re.finditer(r"\d+", text):
print(f"找到数字: {m.group()},位置: {m.span()}")
3. 强大的文本替换:re.sub()
re.sub(pattern, repl, string) 用于将字符串中匹配到模式的部分替换为指定的文本 repl。
import re
text = "我的手机号是 138-1234-5678,他的手机号是 139-8765-4321。"
# 示例 1:将手机号中的连字符替换为空格
new_text = re.sub(r"-", " ", text)
print(new_text) # 输出: 我的手机号是 138 1234 5678,他的手机号是 139 8765 4321。
# 示例 2:手机号脱敏(隐藏中间四位)
# 我们用圆括号将手机号分成三组:前三位、中间四位、后四位
# \1, \2, \3 分别代表第一、二、三组捕获的内容。我们在替换时将第二组替换为 ****
secure_text = re.sub(r"(\d{3})-(\d{4})-(\d{4})", r"\1-****-\3", text)
print(secure_text) # 输出: 我的手机号是 138-****-5678,他的手机号是 139-****-4321。
[!TIP]
re.sub的替换项repl不仅可以是一个字符串,还可以是一个函数! 该函数会接收一个 Match 对象作为参数,并需要返回一个用于替换的字符串。这在需要动态计算替换内容的场景极其强大:# 动态替换:将文本中所有的数字都翻倍text = "小明有 5 个苹果和 12 个香蕉。"doubled_text = re.sub(r"\d+", lambda m: str(int(m.group()) * 2), text)print(doubled_text) # 输出: 小明有 10 个苹果和 24 个香蕉。
4. 字符串分割:re.split()
Python 自带的 str.split() 只能按照固定的单一字符或字符串分割,而 re.split() 可以按照复杂的正则表达式模式来分割字符串。
import re
text = "苹果,香蕉;橘子 西瓜\t哈密瓜"
# 按照逗号、分号、或任意个空格(包括制表符)分割
fruits = re.split(r"[,;]|\s+", text)
print(fruits) # 输出: ['苹果', '香蕉', '橘子', '西瓜', '哈密瓜']
编译正则表达式:re.compile()
在实际开发中,如果同一个正则表达式需要在一个循环或程序中被使用成千上万次,频繁解析正则模式会消耗宝贵的 CPU 时间。
此时,我们可以使用 re.compile() 将正则表达式模式提前编译为一个特殊的 Pattern 对象。编译后的对象可以直接调用 search, findall, sub 等方法,从而极达到优化运行效率的目的。
import re
# 编译正则表达式
phone_pattern = re.compile(r"\b1[3-9]\d{9}\b")
# 直接在 Pattern 对象上调用方法,不需要再次传入模式参数
text1 = "联系方式:13800138000"
text2 = "客服热线:18911112222"
print(phone_pattern.search(text1).group()) # 输出: 13800138000
print(phone_pattern.search(text2).group()) # 输出: 18911112222
分组捕获与匹配对象的深入探究
当正则表达式中包含圆括号 (...) 时,它们在完成匹配的同时,也会捕获对应的子文本。我们可以通过 Match 对象的特有方法轻松提取它们:
group(0)或group():返回整个匹配到的文本。group(n):返回第 n 个括号分组捕获的文本(1-indexed)。groups():以元组形式返回所有括号分组捕获的内容。
import re
email = "user.name@example.com"
# 分组 1: 用户名 (\w+\.?\w+)
# 分组 2: 域名 (\w+\.\w+)
pattern = r"^([\w\.]+)@([\w\.]+)$"
match = re.match(pattern, email)
if match:
print(f"完整邮箱: {match.group(0)}") # 输出: user.name@example.com
print(f"用户名: {match.group(1)}") # 输出: user.name
print(f"域名: {match.group(2)}") # 输出: example.com
print(f"全部分组: {match.groups()}") # 输出: ('user.name', 'example.com')
[!NOTE] 非捕获分组
(?:...)有时候,我们使用圆括号仅仅是为了把某些字符归为一组应用量词(例如
(?:abc)+表示 abc 重复出现),而并不需要捕获它、提取它或在groups()中返回它。 此时,只要在左括号后面加上?:,就可以定义一个非捕获分组。这在优化性能和清晰处理分组索引时非常有用。
正则表达式的匹配标志(Flags)
Python 的 re 模块允许我们传入可选的“标志参数”来调整匹配的行为。多个标志可以通过按位或运算符 | 组合使用:
| 标志名称 | 简写 | 描述 |
|---|---|---|
re.IGNORECASE | re.I | 忽略大小写进行匹配。 |
re.MULTILINE | re.M | 多行模式。使得 ^ 匹配字符串开头和每一行的开头,$ 匹配字符串结尾和每一行的结尾。 |
re.DOTALL | re.S | 点任意匹配模式。使得 . 元字符也可以匹配包括换行符 \n 在内的任意字符。 |
import re
# 1. 忽略大小写
text = "Python python PYTHON"
print(re.findall(r"python", text, re.IGNORECASE)) # 输出: ['Python', 'python', 'PYTHON']
# 2. 点任意匹配模式 (DOTALL)
html = "<div>第一行\n第二行</div>"
# 默认情况下,. 无法跨越换行符,匹配失败
print(re.search(r"<div>.*</div>", html)) # 输出: None
# 加上 re.S,. 就可以匹配换行符了
print(re.search(r"<div>.*</div>", html, re.S).group())
# 输出: <div>第一行\n第二行</div>
练习题
通过亲手编写和测试正则表达式,你的理解会更加深刻。试着完成以下练习:
- 基本验证:编写一个正则表达式,判断输入的字符串是否是合法的中国邮政编码(6位数字且首位不为 0)。
- 提取信息:给定一段包含 HTML 标签的文本,编写程序提取出其中所有的链接地址(即
href="..."内部的 URL)。 例如从<a href="https://google.com">Google</a>中提取出https://google.com。 - 文本脱敏:编写程序,使用
re.sub将文本中所有出现的身份证号(18位数字,或者前17位数字+最后一位是X/x)的中间8位(代表出生年月日的部分)替换为********。 - 日志解析:假设有一行 Web 服务器日志:
192.168.1.100 - - [28/May/2026:12:34:56 +0800] "GET /index.html HTTP/1.1" 200 4523请编写正则表达式和代码,从中分别提取出:IP 地址、请求时间、请求方法(如 GET/POST)、请求路径(如 /index.html)、HTTP 状态码(如 200)。 - 词频统计:编写程序,使用
re.split分割一篇英文文章中的全部单词(排除逗号、句号、感叹号、双引号及空格),并统计出现次数最高的前 3 个单词。