🔤 Python 正则表达式完全指南:从零到精通
本文基于我们之前解析耳温枪日志的真实场景,系统讲解 Python 正则表达式的每一个知识点。学完本文,你将能应对 90% 以上的文本匹配需求。
目录
〇、正则表达式是什么?为什么需要它?
正则表达式(Regular Expression,简称 regex)是一种描述字符串模式的”迷你语言”。 打个比方:
1
2
3
4
5
6
7
8
9
10
| 你要在一个 10 万行的日志文件中,找出所有类似这样的行:
<ma>i=23,O=3706
<ma>i=105,O=2891
<ma>i=7,O=4023
用普通字符串方法:
line.startswith('<ma>') and 'i=' in line and 'O=' in line ...
→ 写一长串 if-else,容易漏,难维护
用正则表达式:
r'^<ma>i=(\d+),O=(\d+)$'
→ 一行搞定,还能直接提取数字
|
核心价值:精确匹配 + 自动提取。 —
一、最快速的上手:re 模块的四个核心函数
Python 通过内置的 re 模块使用正则表达式。你只需要记住 4 个函数:
| 函数 | 作用 | 返回值 |
|---|
re.match() | 从字符串 开头 匹配 | Match 对象 或 None |
re.search() | 在字符串中 搜索第一个 匹配 | Match 对象 或 None |
re.findall() | 找出 所有 匹配的子串 | 列表 |
re.finditer() | 找出 所有 匹配(返回迭代器) | 迭代器(每个元素是 Match) |
还有一个 工厂函数,推荐始终使用:
1
2
3
| pattern = re.compile(r'你的正则')
# 之后用 pattern.match() / pattern.search() / pattern.findall()
# 比 re.match(r'...', text) 更高效(只编译一次)
|
1.1 re.match() — 从开头匹配
1
2
3
4
5
6
7
8
9
| import re
pattern = re.compile(r'hello')
# ✅ 匹配成功:字符串以 hello 开头
result = pattern.match('hello world')
print(result) # <re.Match object; span=(0, 5), match='hello'>
print(result.group()) # 'hello'
# ❌ 匹配失败:hello 不在开头
result = pattern.match('say hello')
print(result) # None
|
1.2 re.search() — 搜索任意位置
1
2
3
4
| pattern = re.compile(r'hello')
# ✅ 搜索成功:hello 在字符串中间也行
result = pattern.search('say hello world')
print(result.group()) # 'hello'
|
match vs search 的区别:match 只看开头,search 扫描全文。 在解析日志等逐行处理场景中,match 更安全(确保没有意外匹配)。
1.3 re.findall() — 找出所有匹配
1
2
3
4
| text = "价格100元,折扣50元,税费10元"
# 找出所有连续数字
result = re.findall(r'\d+', text)
print(result) # ['100', '50', '10']
|
1.4 re.finditer() — 所有匹配(带位置信息)
1
2
3
4
5
6
7
| text = "价格100元,折扣50元,税费10元"
for match in re.finditer(r'\d+', text):
print(f"找到: {match.group()}, 位置: {match.span()}")
# 输出:
# 找到: 100, 位置: (2, 5)
# 找到: 50, 位置: (9, 11)
# 找到: 10, 位置: (14, 16)
|
二、匹配单个字符:普通字符与元字符
正则表达式中的字符分为两类:
2.1 普通字符 — 直接匹配自身
1
| re.findall(r'abc', 'abc abc xabc') # ['abc', 'abc', 'abc']
|
字母、数字、大部分标点都是普通字符,写什么匹配什么。
2.2 元字符 — 有特殊含义的字符(必须背熟)
正则中有 12 个元字符,它们不是匹配自身,而是代表某种规则:
1
| . ^ $ * + ? { } [ ] \ |
|
⚠️ 当你要匹配这些字符本身时,必须用 \ 转义。
1
2
3
4
| # 我想匹配字面量 "a.b"(点号是元字符)
re.findall(r'a\.b', 'a.b aXb a3b') # ['a.b'] ← 只有 a.b 匹配
# 如果不转义,. 代表任意字符
re.findall(r'a.b', 'a.b aXb a3b') # ['a.b', 'aXb', 'a3b'] ← 全部匹配
|
2.3 最常用的单字符元字符:.(点号)
. 匹配 除换行符 \n 之外的任意单个字符。
1
| re.findall(r'a.b', 'aXb a1b a\nb') # ['aXb', 'a1b'] ← \n 不匹配
|
如果想让 . 也匹配换行符,使用 re.DOTALL 标志:
1
| re.findall(r'a.b', 'a\nb', re.DOTALL) # ['a\nb']
|
2.4 转义序列 — 匹配特定类型的字符
这些是 \ 开头的特殊组合,每个匹配一类字符:
| 表达式 | 含义 | 等价写法 |
|---|
\d | 任意一个数字 (0-9) | [0-9] |
\D | 任意一个非数字 | [^0-9] |
\w | 任意一个”单词字符” (字母、数字、下划线) | [a-zA-Z0-9_] |
\W | 任意一个非单词字符 | [^a-zA-Z0-9_] |
\s | 任意一个空白字符 (空格、Tab、换行) | [ \t\r\n\f\v] |
\S | 任意一个非空白字符 | [^ \t\r\n\f\v] |
\b | 单词边界 | (无等价写法) |
1
2
3
4
| text = "abc 123 _xyz 价格@100"
re.findall(r'\d+', text) # ['123', '100'] ← 所有数字串
re.findall(r'\w+', text) # ['abc', '123', '_xyz'] ← 所有单词
re.findall(r'\S+', text) # ['abc', '123', '_xyz', '价格@100'] ← 所有非空白
|
三、量词:控制匹配次数
量词放在 字符或分组 后面,表示”前面的内容重复多少次”。
3.1 四个基本量词
| 量词 | 含义 | 示例 | 匹配 |
|---|
* | 0 次或多次 | ab*c | ac, abc, abbc, abbbc… |
+ | 1 次或多次(至少1次) | ab+c | abc, abbc, abbbc… 但不匹配 ac |
? | 0 次或 1 次(可选) | ab?c | ac, abc |
{n} | 恰好 n 次 | ab{3}c | abbbc(恰好3个b) |
{n,} | 至少 n 次 | ab{2,}c | abbc, abbbc, abbbbc… |
{n,m} | n 到 m 次 | ab{2,4}c | abbc, abbbc, abbbbc |
1
2
3
4
5
6
| # 提取连续数字(1个或多个数字)
re.findall(r'\d+', 'a12b345c6') # ['12', '345', '6']
# 提取连续数字(0个或多个)→ 注意空串
re.findall(r'\d*', 'a12b345c') # ['', '12', '', '345', '', '']
# 匹配 3 到 5 位数的数字
re.findall(r'\d{3,5}', '1234567') # ['12345'] ← 贪婪匹配,优先匹配最多的
|
3.2 贪婪 vs 非贪婪(重要!)
默认情况下,量词是 贪婪模式(Greedy):尽可能多地匹配。 在量词后面加 ? 变为 非贪婪模式(Lazy):尽可能少地匹配。
1
2
3
4
5
6
7
| html = '<div>内容1</div><div>内容2</div>'
# 贪婪:.* 匹配到尽可能远的 </div>
re.findall(r'<div>.*</div>', html)
# ['<div>内容1</div><div>内容2</div>'] ← 一次性吞掉了两个 div!
# 非贪婪:.*? 匹配到最近的 </div> 就停
re.findall(r'<div>.*?</div>', html)
# ['<div>内容1</div>', '<div>内容2</div>'] ← 正确!分别匹配两个
|
口诀:加 ? 就是非贪婪,”见好就收”。 —
四、边界锚点:定位匹配位置
锚点不匹配任何字符,而是匹配一个 位置。
| 锚点 | 含义 | 示例 |
|---|
^ | 字符串 开头 | ^hello → 匹配以 hello 开头的行 |
$ | 字符串 结尾 | world$ → 匹配以 world 结尾的行 |
\b | 单词边界 | \bcat\b → 匹配 cat 但不匹配 catalog |
4.1 ^ 和 $ — 行首行尾
1
2
3
4
5
6
7
| text = "hello world\nhello python\nhi world"
# 匹配以 hello 开头的行
re.findall(r'^hello.*$', text, re.MULTILINE)
# ['hello world', 'hello python']
# 匹配以 world 结尾的行
re.findall(r'.*world$', text, re.MULTILINE)
# ['hello world', 'hi world']
|
关键:^ 和 $ 默认匹配整个字符串的首尾。 如果要匹配每一行的首尾,必须加 re.MULTILINE 标志。
4.2 \b — 单词边界
单词边界 = 单词字符与非单词字符之间的位置。
1
2
3
4
5
6
7
| text = "the cat scattered the catalog"
# 匹配独立的单词 cat
re.findall(r'\bcat\b', text)
# ['cat'] ← 只有第一个 cat 匹配,scattered 和 catalog 中的不匹配
# 不加 \b,匹配所有包含 cat 的
re.findall(r'cat', text)
# ['cat', 'cat', 'cat'] ← scattered 中也有 cat
|
五、字符集与范围:匹配”某类字符”
5.1 [...] — 字符集
方括号内的字符,匹配其中任意一个。
1
2
3
4
5
6
| # 匹配 a、b、c 中的任意一个
re.findall(r'[abc]', 'xaybzc') # ['a', 'b', 'c']
# 匹配任意数字
re.findall(r'[0123456789]', 'a1b2c') # ['1', '2']
# 匹配任意小写字母
re.findall(r'[a-z]', 'Hello World 123') # ['e', 'l', 'l', 'o', 'o', 'r', 'l', 'd']
|
5.2 范围写法 — -
1
2
3
4
| re.findall(r'[a-z]+', 'Hello World') # ['ello', 'orld'] ← 小写字母串
re.findall(r'[A-Z]+', 'Hello World') # ['H', 'W'] ← 大写字母串
re.findall(r'[0-9]+', 'abc 123 def 456') # ['123', '456'] ← 数字串
re.findall(r'[a-zA-Z]+', 'Hello World') # ['Hello', 'World'] # 所有字母串
|
5.3 取反 — [^...]
方括号内第一个字符是 ^,表示 不匹配这些字符。
1
2
3
4
| # 匹配所有非数字字符
re.findall(r'[^0-9]+', 'abc123def456') # ['abc', 'def']
# 匹配所有非元音字母
re.findall(r'[^aeiou]+', 'hello') # ['h', 'll']
|
⚠️ [^...] 中的 ^ 和行首锚点 ^ 是 完全不同的东西。方括号内的 ^ 表示取反,方括号外的 ^ 表示行首。
5.4 - 在字符集中的特殊处理
- 在 [...] 内表示范围,但如果需要匹配字面量 -,有两种方式:
1
2
3
4
5
| # 把 - 放在开头或结尾
re.findall(r'[-abc]', 'a-b') # ['a', '-', 'b']
re.findall(r'[abc-]', 'a-b') # ['a', '-', 'b']
# 或者用 \ 转义
re.findall(r'[a\-z]', 'a-z') # ['a', '-', 'z']
|
六、分组与捕获:提取你想要的数据
这是正则表达式 最强大的功能,没有之一。
6.1 捕获组 ( ... )
用圆括号把一部分正则包起来,匹配成功后可以通过 .group() 提取。
1
2
3
4
5
6
7
| pattern = re.compile(r'(\d{4})-(\d{2})-(\d{2})')
m = pattern.match('2024-01-15')
print(m.group(0)) # '2024-01-15' ← 整个匹配(group(0) 永远是整体)
print(m.group(1)) # '2024' ← 第1个括号
print(m.group(2)) # '01' ← 第2个括号
print(m.group(3)) # '15' ← 第3个括号
print(m.groups()) # ('2024', '01', '15') ← 所有分组组成的元组
|
6.2 命名捕获组 (?P<name> ... )
给分组取个名字,用名字提取,代码可读性拉满。
1
2
3
4
5
6
| pattern = re.compile(r'(?P<year>\d{4})-(?P<month>\d{2})-(?P<day>\d{2})')
m = pattern.match('2024-01-15')
print(m.group('year')) # '2024'
print(m.group('month')) # '01'
print(m.group('day')) # '15'
print(m.groupdict()) # {'year': '2024', 'month': '01', 'day': '15'}
|
6.3 findall 与分组的特殊行为
findall 遇到分组时,行为会发生变化——它只返回分组内容,不返回整体。
1
2
3
4
5
6
7
8
9
| # 无分组:返回整个匹配
re.findall(r'\d{4}-\d{2}-\d{2}', '日期: 2024-01-15 和 2023-12-01')
# ['2024-01-15', '2023-12-01']
# 有1个分组:返回该分组的内容
re.findall(r'(\d{4})-\d{2}-\d{2}', '日期: 2024-01-15 和 2023-12-01')
# ['2024', '2023'] ← 只返回年份!
# 有2个分组:返回元组列表
re.findall(r'(\d{4})-(\d{2})-\d{2}', '日期: 2024-01-15 和 2023-12-01')
# [('2024', '01'), ('2023', '12')]
|
记忆规则:findall 有分组时,返回的是分组内容(元组),不是整个匹配。
6.4 分组的引用
在正则内部引用: 用 \1, \2 引用之前匹配的内容。
1
2
3
| # 匹配重复的单词
re.findall(r'\b(\w+)\s+\1\b', 'the the cat cat dog')
# ['the', 'cat'] ← the the 和 cat cat 各匹配一次
|
在替换字符串中引用: 用 \g<1> 或 \1。
1
2
3
| # 把 "姓,名" 格式转为 "名 姓"
re.sub(r'(\w+),(\w+)', r'\2 \1', 'Smith,John; White,Tom')
# 'John Smith; Tom White'
|
七、特殊结构:或、非捕获、断言
7.1 或 | — 匹配多种模式之一
1
2
3
| # 匹配 cat 或 dog
re.findall(r'cat|dog', 'I have a cat and a dog')
# ['cat', 'dog']
|
⚠️ | 的优先级非常低! 注意加括号控制范围:
1
2
3
4
5
| # ❌ 错误:意思是 "cata" 或 "b"
r'cata|b'
# ✅ 正确:意思是 "cat" 或 "dog",后面都跟 "food"
r'(cat|dog)food'
|
7.2 非捕获组 (?: ... )
有时候你只想”分组”但不”捕获”(不需要通过 group() 提取)。用 (?:...) 可以提升性能,避免干扰 findall 的返回结果。
1
2
3
4
| # 捕获组:findall 只返回分组内容
re.findall(r'(cat|dog)s?', 'cats and dogs') # ['cat', 'dog']
# 非捕获组:findall 返回整个匹配
re.findall(r'(?:cat|dog)s?', 'cats and dogs') # ['cats', 'dogs']
|
7.3 零宽断言(Lookaround)
断言不消耗字符,只检查某个位置的前后是否满足条件。这是正则表达式的 高级技能。
| 断言 | 含义 | 示例 |
|---|
(?=...) | 正向前瞻:后面跟着… | \d+(?=px) → 匹配 100px 中的 100(但不含 px) |
(?!...) | 负向前瞻:后面不跟着… | \d+(?!px) → 匹配 100em 中的 100(100 后面不是 px) |
(?<=...) | 正向后顾:前面是… | (?<=\$)\d+ → 匹配 $100 中的 100(但不含 $) |
(?<!...) | 负向后顾:前面不是… | (?<!\$)\d+ → 匹配 100(前面没有 $) |
1
2
3
4
5
6
7
8
9
10
11
12
13
| # 正向前瞻:提取单位为 px 的数字
text = "width:100px height:200em margin:50px"
re.findall(r'\d+(?=px)', text)
# ['100', '50'] ← 只匹配 px 前面的数字
# 正向后顾:提取美元金额(不含 $ 符号)
text = "$100 spent, €50 remaining"
re.findall(r'(?<=\$)\d+', text)
# ['100'] ← 只匹配 $ 后面的数字
# 组合使用:提取 "key=value" 中的 value(value 不含引号)
text = 'name="Alice" age=25 city="Beijing"'
re.findall(r'(?<=\=)[^=]+(?=$|\s)', text)
# 没办法完美做到,换一种思路
|
Python 的后顾断言 (?<=...) 要求必须是固定长度的,不能写 (?<=\d+),但可以写 (?<=\d{3})。
八、实战回顾:耳温枪日志正则逐字拆解
回到我们之前的真实场景,现在你应该能完全看懂这三条正则了:
正则 1:^\s*<m\.>n=(\d+),o=(\d+)$
1
2
3
4
5
6
7
8
9
10
| ^\s*<m\.>n=(\d+),o=(\d+)$
│ │ │ │ │ │ │
│ │ │ │ │ │ └── $ 行尾锚点
│ │ │ │ │ └───────── (\d+) 捕获组2:匹配 o 的值(1个或多个数字)
│ │ │ │ └────────────── ,o= 匹配字面量
│ │ │ └───────────────── (\d+) 捕获组1:匹配 n 的值
│ │ └────────────────────── \. 转义的点号,匹配字面量 "."
│ └───────────────────────── <m.> 匹配字面量(< 和 > 是普通字符)
└──────────────────────────── \s* 匹配 0个或多个空白(兼容行首空格)
^ 行首锚点
|
正则 2:^<ma>i=(\d+),O=(\d+)$
1
2
3
4
5
6
| ^<ma>i=(\d+),O=(\d+)$
│ │ │ │
│ │ │ └── (\d+) 捕获组2:大写 O 的值
│ │ └──────── ,O= 大写 O,与小写 o 区分
│ └────────────── (\d+) 捕获组1:i 的值
└──────────────────── <ma> 无需转义(无特殊字符)
|
正则 3:^<me>o=([\d.]+),x=(\d+)$
1
2
3
4
5
6
7
| ^<me>o=([\d.]+),x=(\d+)$
│ │ │ │
│ │ │ └── (\d+) 捕获组2:x 的值(整数)
│ │ └──────── ,x= 字面量
│ └─────────────── ([\d.]+) 捕获组1:o 的值
│ [\d.] = 数字或小数点,兼容 int 和 float
└──────────────────────── <me> 字面量
|
九、常见陷阱与最佳实践
陷阱 1:忘记转义 .(最高频错误)
1
2
3
4
5
| # ❌ . 匹配任意字符,"a.b" 和 "aXb" 都会被匹配
re.match(r'a.b', 'aXb') # ✅ 匹配成功(但可能不是你想要的)
# ✅ 转义后才匹配字面量点号
re.match(r'a\.b', 'aXb') # ❌ 不匹配
re.match(r'a\.b', 'a.b') # ✅ 匹配成功
|
陷阱 2:findall 有分组时行为变化
1
2
3
| re.findall(r'<(\w+)>', '<div><p>hello</p></div>')
# 你可能期望: ['<div>', '<p>', '</p>', '</div>']
# 实际返回: ['div', 'p', '/p', '/div'] ← 只有括号内的内容!
|
解决方案:用 finditer 代替 findall
1
2
| [m.group(0) for m in re.finditer(r'<(\w+)>', '<div><p>hello</p></div>')]
# ['<div>', '<p>', '</p>', '</div>'] ← group(0) 是整体匹配
|
陷阱 3:\b 对中文的行为
1
2
3
4
5
| # \b 是基于 \w(ASCII 字母数字下划线)的边界
# 中文不是 \w,所以 \b 在中文前后不生效
re.findall(r'\b价格\b', '价格100元') # [] ← 匹配不到!
# 中文场景建议直接用 ^ $ 或自定义边界
re.findall(r'价格', '价格100元') # ['价格']
|
最佳实践清单
| # | 实践 | 原因 |
|---|
| 1 | 始终使用 re.compile() | 只编译一次,循环中性能差 10 倍以上 |
| 2 | 始终使用原始字符串 r'...' | 避免 Python 转义和正则转义冲突 |
| 3 | 多用命名分组 (?P<name>...) | 代码可读性好,维护方便 |
| 4 | 不确定时用 re.findall() 测试 | 先看匹配了什么,再加分组提取 |
| 5 | 能不加 .* 就不加 | .* 太宽泛,容易过度匹配,尽量精确 |
| 6 | 需要匹配字面量特殊字符时记得转义 | . * + ? \ ( ) [ ] { } \| ^ $ |
| 7 | 正则太长时,拆成多行加注释 | re.VERBOSE 标志允许写注释 |
用 re.VERBOSE 写可读正则
1
2
3
4
5
6
7
8
9
10
| pattern = re.compile(r"""
^\s* # 行首,允许前导空白
<m\.> # 字面量 <m.>
n=(\d+) # 捕获 n 的值
, # 逗号分隔符
o=(\d+) # 捕获 o 的值
$ # 行尾
""", re.VERBOSE)
# 等价于:r'^\s*<m\.>n=(\d+),o=(\d+)$'
# 但可读性天壤之别!
|
十、速查表
元字符速查
| 符号 | 含义 |
|---|
. | 任意单个字符(除 \n) |
^ | 行首 |
$ | 行尾 |
\d | 数字 [0-9] |
\D | 非数字 [^0-9] |
\w | 单词字符 [a-zA-Z0-9_] |
\W | 非单词字符 |
\s | 空白字符 |
\S | 非空白字符 |
\b | 单词边界 |
量词速查
| 符号 | 含义 |
|---|
* | 0 次或多次 |
+ | 1 次或多次 |
? | 0 次或 1 次 |
{n} | 恰好 n 次 |
{n,} | 至少 n 次 |
{n,m} | n 到 m 次 |
*? +? ?? | 非贪婪版本 |
分组速查
| 符号 | 含义 |
|---|
(...) | 捕获组 |
(?:...) | 非捕获组 |
(?P<name>...) | 命名捕获组 |
\1 \2 | 引用第 N 个分组 |
(?!...) | 负向前瞻 |
(?=...) | 正向前瞻 |
(?<!...) | 负向后顾 |
(?<=...) | 正向后顾 |
re 模块函数速查
| 函数 | 用途 |
|---|
re.compile(r'...') | 编译正则对象 |
pattern.match(text) | 从开头匹配 |
pattern.search(text) | 全文搜索第一个 |
pattern.findall(text) | 找出所有匹配(返回列表) |
pattern.finditer(text) | 找出所有匹配(返回迭代器) |
pattern.sub(r'...', text) | 替换所有匹配 |
pattern.split(text) | 按正则分割字符串 |
学完这篇,你已经掌握了 Python 正则表达式的核心知识。 剩下的就是多练——遇到复杂的匹配需求时,先用 re.findall() 在 IPython 里调试,确认匹配结果正确后再写进代码。如果遇到拿不准的正则,推荐使用 regex101.com 在线调试,它会逐段解释你的正则含义。