AI 技术博客
返回首页
Python 基础 16 分钟阅读

正则表达式基础:元字符与字符类

Python正则表达式元字符字符类re 模块

引言

第五章的前三篇把「字符串」本身的功夫练完了:检索、判断、变换(第一篇),切片与步长(第二篇),编码与 Unicode(第三篇)。它们共同的特点是「精确」——子串是写死的、边界是算好的。可现实里我们面对的往往是「一类」文本:日志里的时间戳格式各异、配置文件里的 key=value 写法不统一、中文数字英文混在一行。为这一类问题,正则表达式登场。

正则表达式(regular expression,简称正则或 regex)是用元字符和字面量拼成的一条「模式」(pattern),用一条模式描述一类字符串,然后对文本做匹配、提取与替换。它是文本处理的通用语言:日志分析、配置文件解析、代码搜索、数据清洗都离不开它。Python 把它封装在标准库 re 模块里,接下来三篇是连续的正则课程:本篇解决「匹配哪些」(元字符、字符类、量词与贪婪直觉),下一篇解决「提取哪部分」(分组、捕获与回溯引用),再下一篇把 re 模块的 API 全部过一遍(match/search/findall/sub 等),第八、九篇再用日志解析与文本工具链实战收尾。

读完本篇你应当已掌握前三篇的内容:字符串方法(find、startswith 等)、切片与负索引、str 与 bytes 的编码关系。本篇所有例子的运行环境是 Python 3.10+(本机以 3.12 实测)。

概念与原理

从字面匹配到模式匹配

字符串方法的问题在于「必须知道要找什么」。'abc' in 'xabcx' 能判断包含,但「找出所有连续数字」这种需求,方法层只能写循环逐个字符去试。正则的思路反了过来:不描述具体内容,而是描述「形状」。连续数字的形状是「一个或多个数字」,写成 \d+;一个版本号的形状是「数字、点、数字」,写成 \d+\.\d+。有了形状,一个 findall 就能把文本里所有符合形状的子串捞出来,这正是字符串方法做不到的——它们只能处理写死的字面量。

有限状态机的直觉

要理解正则的行为,一个有用且严谨的心智模型是有限状态机(finite state machine,FSM):引擎把模式编译成一张「状态转移图」,从起点出发,逐个读入文本字符,按当前状态决定走向哪条边;当某条路径走到接受状态且文本恰好到达要求的位置时,匹配成功。正则里的每个字符、每个量词,都对应图上的一段走法。

Python 的 re 采用带回溯(backtracking)的引擎:遇到可分支的选择时,先沿一条路走到底,失败再退回岔路口试另一条。这解释了正则最著名的两个现象——贪婪量词「先尽量多吃,失败再往回吐」,本质就是回溯中的退让;以及某些模式在极端输入下会异常缓慢(反复试探同一条死路,即灾难性回溯,第 9 篇实战时再谈)。心里装着这张图,下面的元字符表就不是死记硬背:每个元字符只是状态转移图上的一种走法,量词则是「这条边走几次」的约定。

元字符全表

正则的语法由元字符(metacharacter)撑起,Python 的普通模式里一共 14 个特殊符号:

元字符名称作用
.点匹配除换行外的任意字符
^脱字符锚定字符串开头
$美元符锚定字符串结尾(允许结尾恰好一个换行)
*星号前一项出现 0 次或多次
+加号前一项出现 1 次或多次
?问号前一项出现 0 次或 1 次
{}花括号前一项的出现次数,如 {2}、{2,}、{2,4}
[]方括号字符类:匹配括号内任意一个字符
()圆括号分组:划分子表达式并捕获(下篇详解)
\反斜杠转义元字符,或引入简写(\d 等)
|竖线或:左右两侧任选其一

「锚定」是正则的基础概念:^ 和 $ 不消费字符,只检查位置。注意两点:$ 允许匹配「结尾换行符之前」的位置,实测 re.search(r'world$', 'hello world\n') 能命中;^ 默认只认字符串真正的开头,要让 ^、$ 逐行生效需要 re.MULTILINE 标志(下一篇讲 flags)。括号 () 语义较多,本篇先按下不表,其余十个先吃透。

字符类与简写

方括号 [] 定义字符类(character class),匹配「括号内任意一个字符」:[abc] 匹配 a、b、c 任意一个;[a-z] 是连续区间的写法;[^0-9] 中开头的 ^ 表示取反——匹配任意非数字字符。字符类内部有自己的转义规矩:点、星、加号、问号、括号、花括号、$ 这些「类外元字符」在类内统统变成普通字符([.] 就是字面量点);真正需要小心的是 ]、-、^、\ 四个——- 放在开头或写成 \- 才是字面量,^ 放在开头表示取反,] 与 \ 要转义。实测验证:r'[a\-c]+' 匹配 'a-b-c' 得到 ['a-', '-c'],可见这个类确实是「a、连字符、c」三个成员。

简写(shorthand)是最常用的字符类速记:\d 是「数字」,\w 是「词字符」,\s 是「空白」,大写形式 \D、\W、\S 恰好是各自的反面。重要:Python 3 中这些简写默认按 Unicode 语义工作——\w 不止 [a-zA-Z0-9_],还包括汉字等 Unicode 词字符;\d 不止 [0-9],还包括阿拉伯-印度数字字符(如 ٣);\s 也匹配全角空格(U+3000)。这三点下一节逐一实测。若需要严格的 ASCII 行为,用 re.ASCII 标志(详见下篇 flags 一节,此处先留印象)。

量词:次数与贪婪/懒惰的直觉

*、+、?、{} 统称量词(quantifier),规定「前一项」的出现次数,默认都是贪婪(greedy)的:在能匹配的前提下尽量多吞。贪婪的机器实现就是回溯——先按最大次数吞,后续匹配失败时逐步吐出一个再重试。懒惰(lazy)量词是给量词直接加问号:*?、+?、??、{m,n}?,先按最少次数匹配,不够再追加。一个直觉:贪婪像「一次拿满再退让」,懒惰像「先拿一点再试探」。

以 r'<.+>' 匹配 '<a><b>' 为例(实测见下文):贪婪的 + 先吞到字符串末尾,再回溯吐出 >,最终整个 '<a><b>' 成为一次匹配;换成懒惰的 r'<.+?>',则在第一个 > 处停下,得到 '<a>' 和 '<b>' 两次匹配。同一输入、同一量词,只差一个问号,结果完全不同——这是正则最常见的坑之一。

先记住:re.escape 保护字面量

本篇最实用的工程提醒先写在这里:当你想匹配「字面内容」(用户输入的搜索词、带点的文件名、带括号的目录名)时,先把它们交给 re.escape()。re.escape 会把模式里所有元字符转义为字面量:实测 re.escape('a.b+') 返回 'a\\.b\\+'(Python 3.7 起只转义特殊字符,不再转义所有标点)。否则 'a.b+' 里的点会匹配任意字符、加号会变成「重复」,得到的匹配结果与直觉完全不符。后面凡遇到「把变量拼进模式」的地方,都先过一遍 re.escape。

操作与实现

第一步:找出所有数字

先用最小例子感受「模式匹配」。findall 返回所有匹配的子串:

import re

text = '订单 12 号,金额 45.6 元'
print(re.findall(r'\d+', text))
['12', '45', '6']

一段中文、数字、小数点混排的文本,一条 \d+ 就把三个数字全部捞出。注意 45.6 被拆成 45 和 6——点不是数字;想要整段「带小数金额」,模式得写成 \d+\.\d+,这正是「元字符要么转义、要么用类」的实例。

元字符逐项实测

把元字符表过一遍,边看输出边对照:

import re

s = 'cat 2026'
print(re.findall(r'^cat', s))               # 锚定开头
print(re.findall(r'2026$', s))              # 锚定结尾
print(re.findall(r'c.t', s))                # . 匹配任意字符(除换行)
print(re.findall(r'ca?t', 'cat ct caat'))   # ? :0 次或 1 次
print(re.findall(r'ca+t', 'cat caat ct'))   # + :至少 1 次
print(re.findall(r'ca*t', 'ct cat caat'))   # * :0 次或多次
print(re.findall(r'\d{2}', '1 23 456'))     # {2} :恰好 2 个数字
print(re.findall(r'cat|dog', 'hotdog cat')) # | :或
print(re.search(r'c(a|o)t', 'cat cot cut').group(0))
['cat']
['2026']
['cat']
['cat', 'ct']
['cat', 'caat']
['ct', 'cat', 'caat']
['23', '45']
['dog', 'cat']
cat

细节与表一一对应:{2} 在 456 上贪心取前两个 45;| 的作用范围是它两侧的整段模式,r'cat|dog' 匹配「cat」或「dog」两个词;r'c(a|o)t' 里的括号把 | 的范围圈在 a 与 o 之间——这里 search 只给出第一个命中,最后一行 .group(0) 是「整个匹配」(分组的故事留给下篇)。反斜杠转义实例:r'\d\.\d' 匹配 '版本 1.2 和 3.4' 得到 ['1.2', '3.4'],点被转义成字面量,否则 1x2 也会被吞。

字符类与简写实测:中文与 \w 的关系

做本篇最重要的验证——\w、\d 与中文、全角字符的关系:

import re

s = '我爱 Python 编程'
print(re.findall(r'\w+', s))               # 默认:\w 按 Unicode 词字符
print(re.findall(r'\w+', s, re.ASCII))     # re.ASCII:只认 ASCII 词字符
print(re.findall(r'\d+', '编号 12 和 ٣٤'))  # \d 把 ٣٤ 也算数字
print(re.findall(r'\S+', '中文\u3000空格')) # \s 族类也认全角空格
['我爱', 'Python', '编程']
['Python']
['12', '٣٤']
['中文', '空格']

结论明确(Python 3.12 实测):\w 默认把中文当「词字符」,一次 findall 得到 ['我爱', 'Python', '编程'];加上 re.ASCII 后中文被排除,只剩 ['Python']。\d 同理,连 ٣(U+0663)都算数字,只有显式写 [0-9] 才是纯 ASCII。\S 把全角空格(U+3000)当作空白切分文本。工程结论:凡「只要字母数字」的需求,自觉带上 re.ASCII 或写成 [0-9a-zA-Z_],否则中文文本会被一路吞到底——处理用户输入、文件名时尤其危险。

字符类的取反与区间:

import re

print(re.findall(r'[a-z]+', 'Hello World'))  # 只看小写
print(re.findall(r'[^0-9]+', 'a1b22c'))      # 非数字连串
print(re.findall(r'[a\-c]+', 'a-b-c'))       # 类内 - 转义后是字面量
print(re.findall(r'[.]+', 'a.b..c'))         # 类内 . 是字面量
['ello', 'orld']
['a', 'b', 'c']
['a-', '-c']
['.', '..']

贪婪与懒惰对照

同一段输入做贪婪/懒惰对照实验:

import re

print(re.findall(r'<.+>', '<a><b>'))    # 贪婪:吞到最后一个 >
print(re.findall(r'<.+?>', '<a><b>'))   # 懒惰:见到第一个 > 就停
print(re.findall(r'\d+', '123456'))     # 贪婪的 +
print(re.findall(r'\d+?', '123456'))    # 懒惰的 +? :每次只拿 1 个
['<a><b>']
['<a>', '<b>']
['123456']
['1', '2', '3', '4', '5', '6']

贪婪吃满、懒惰保底。差异在抓取 HTML 标签这类「整体匹配」场景中决定成败;下篇的分组与回溯引用会进一步利用这个特性。

字面量保护实战

把 re.escape 用在真实场景——搜索一段「看起来像模式」的用户输入:

import re

keyword = 'a.b+'                         # 用户输入的搜索词,含元字符
pattern = re.compile(re.escape(keyword))
print(pattern.pattern)                   # 转义后的模式
print(pattern.findall('ca.b+x 与 aXb+')) # 只命中字面 a.b+
a\.b\+
['a.b+']

未经转义时,'a.b+' 会匹配「aXb」(点吞 X、加号重复 b)这类完全无关的文本;转义后它只能命中字面的 'a.b+'。「把变量拼进正则」的第一原则就是先 re.escape。

易错点与陷阱

1. \w、\d 默认吃 Unicode:中文会被 \w 吞走

最常踩的坑:想匹配「单词」用 \w+,中文也进来了(实测 ['我爱', 'Python', '编程']);想匹配「纯数字」,\d+ 把 ٣ 这种阿拉伯-印度数字也算数。判断「是否是 ASCII 字母数字」必须显式:re.ASCII 标志,或 [0-9a-zA-Z_] 字符类。

2. 点号不匹配换行

. 的官方定义是「除换行 \n 外的任意字符」。跨行文本配 r'.+' 只能拿到一行的匹配(实测 'a\nb\nc' 得到 ['a', 'b', 'c']),很多人面对多行日志「明明有内容却匹配不到」就是栽在这里。解法:明确写出 [\s\S](任何字符,含空白与换行),或使用 re.DOTALL 标志(下篇讲),或分行处理。

3. 元字符当字面量用

匹配 IP 地址写 r'1.2.3.4'?点会匹配任意字符,'1a2b3c4' 也命中。把文件名 report(2026).txt 直接写进模式,圆括号会被当成分组括号。凡是字面量,先 re.escape 再拼进模式——这是本篇想让你带走的习惯。

4. 贪婪的「超吃」与零长度匹配

贪婪的 + 在 '<a><b>' 上吃掉整个串(实测),要用懒惰量词纠正。另一个隐蔽行为:量词允许「0 次」时,匹配结果可能包含空串——r'a*' 匹配 'baa' 时 findall 返回 ['', 'aa', ''](实测),开头结尾各有一个零长度匹配。处理时要意识到这类空串的存在:需要时过滤掉,或改用 + 保证至少一个字符。

小结

本篇建立了正则的第一个心智模型:引擎是带回溯的有限状态机,元字符是状态图上的走法。你掌握了 11 个元字符(. ^ $ * + ? {} [] () \ |)、字符类与简写 \d \w \s(及其大写否定)、量词的贪婪与懒惰两幅面孔,并实测了三个关键事实:\w 默认匹配中文、\d 匹配非 ASCII 数字、. 不匹配换行。字面量保护用 re.escape。下一篇把括号 () 拆开:分组、编号、命名分组、回溯引用,以及编译正则与四种 flags。

练习与思考题

  1. 用一条 findall 从 'price: 3.14, count: 42, ratio: 0.5' 中提取所有小数(提示:\d+\.\d+),程序输出应为 ['3.14', '0.5']。
  2. 把用户输入的搜索词 'v2.0-final' 用 re.escape 保护后拼进模式,在 '下载 v2.0-final 与 v2X0Xfinal' 中搜索,解释为什么只有前一个被命中。
  3. 手工推演 r'<.+>' 在 '<a><b>' 上的匹配过程:+ 先吞到哪里、回溯几次、为什么结果是整个串;再推演 r'<.+?>' 为什么恰好停在每个 > 上。
  4. 思考题:r'\d+' 与 r'\d+?' 对 '123456' 的 findall 结果分别是 ['123456'] 与 ['1','2','3','4','5','6'](实测)。懒惰量词为什么在 findall 里会「逐个交出」数字?提示:懒惰每次按最少次数匹配,而 findall 完成一次匹配后从头继续寻找下一个。