正则表达式(一)
前言
- 正则分两篇:这一篇讲应用场景、使用原则、
re的六个入口、Match 对象、元字符(单字符 / 次数 / 边界)、分组、断言、修饰符、转义;反向引用、split、sub的深入用法在《正则表达式(二)》。 - 所有示例在本机 Python 3.14.10 上实跑过,输出原样贴在代码块下面。原笔记里几处和实际行为不符的地方(
findall没匹配到返回[]而不是None、re.L在字符串模式下会直接报错、re.T已被移除)都按实测改了。 - 表格里每条规则都跑过对应用例;容易看错的边界(
$与结尾换行、\w与中文、\b是位置不是字符)单独拎出来说。
应用场景
正则擅长的是「模式」而不是「固定子串」,常见用途:
- 数据验证:邮箱、手机号、URL、IP、日期格式是否合法。
- 数据抽取:从日志里取时间戳和错误码,从网页源码里取链接。
- 文本替换:批量改格式、压缩多余空格、脱敏(手机号中间四位打星)。
- 文本分割:分隔符不固定时(按一个或多个空白、按数字)用
re.split。 - 字符串解析:解析自定义配置行、键值对。
- 语法高亮 / 词法分析:把源码切成 token。
- 自然语言处理:分句、分词前的粗切。
- 爬虫与日志分析:从大段文本里定位关键片段。
反过来,固定字符串的查找、前后缀判断、按固定分隔符切分别上正则——字符串自带的方法更快也更好读。
使用原则
- 能不用就不用:
'x' in s、s.startswith('a')、s.split(',')都比正则快,也更好懂。 - 简单化:表达式越长越难维护,嵌套量词加或运算还容易掉进灾难性回溯。
- 明确性:用锚点(
^、$、\A、\Z、\b)限定范围,避免过度匹配。 - 想清楚贪婪:
*、+、{m,n}默认尽量多匹配,要短的就加?。 - 重复用的模式要
compile:把「解析」和「匹配」拆成两步;模块级函数虽然也有缓存,但显式编译意图更清楚。 - 写注释:复杂表达式用
re.X(详细模式)分行写,能加#注释。 - 测边界:空串、只有分隔符、超长输入、非 ASCII、大小写混合。
- 别把用户输入当模式:要匹配用户给的字符串,先过一遍
re.escape()。
re 模块的五个入口
-
match/search:找一个match只从字符串开头试,search从头到尾扫,找到第一处就返回。1
2
3
4
5
6
7
8
9import re
m1 = re.match('abc', 'abchelloabc') # 开头就是 abc
m2 = re.match('abc', 'helloabc') # 开头不是 abc
s1 = re.search('abc', 'helloabc') # search 扫到任意位置
print('match 命中:', m1.group(), '| span:', m1.span())
print('match 未命中返回:', m2)
print('search 命中:', s1.group(), '| span:', s1.span())输出:
1
2
3match 命中: abc | span: (0, 3)
match 未命中返回: None
search 命中: abc | span: (5, 8)这里的
span是左闭右开的半开区间,跟切片s[0:3]一个规矩:(5, 8)表示占用 5、6、7 三个下标,end - start就是命中长度。详见下面「Match 对象」里的span()小节。 -
fullmatch:整串都要符合3.4 加入。语义比手写
^...$直白,而且不会被「$允许结尾换行」这个坑阴到(见后面「边界」一节)。1
2
3
4
5import re
print('match 只要求开头:', re.match('abc', 'abcd').group())
print('fullmatch 要求整串:', re.fullmatch('abc', 'abcd'))
print('fullmatch 整串相符:', re.fullmatch('abc', 'abc').group())输出:
1
2
3match 只要求开头: abc
fullmatch 要求整串: None
fullmatch 整串相符: abc -
findall:全部取出,要的是内容1
2
3
4
5
6import re
print('有命中:', re.findall('abc', 'abcddgasabckjjghlabc'))
print('没命中:', re.findall('abc', 'xyz'), '<- 空列表,不是 None')
print('判空写法:', bool(re.findall('abc', 'xyz')))
print('有分组时返回元组列表:', re.findall(r'(\w+)=(\d+)', 'set width=20 and height=10'))输出:
1
2
3
4有命中: ['abc', 'abc', 'abc']
没命中: [] <- 空列表,不是 None
判空写法: False
有分组时返回元组列表: [('width', '20'), ('height', '10')]两个要点:没匹配到返回空列表
[](原笔记写「没有返回 None」是错的,if not res和if res is None不是一回事);模式里只要有捕获组,返回的就是组的内容而不是整段匹配,多组就是元组列表——想拿整段请改用finditer。 -
finditer:全部取出,要的是 Match 对象需要位置信息,或者文本很大不想一次生成列表时用它。
1
2
3
4import re
for m in re.finditer(r'\d+', 'a1b22c333'): # 逐个给 Match 对象,适合大文本
print(m.group(), m.span())输出:
1
2
31 (1, 2)
22 (3, 5)
333 (6, 9) -
compile:把解析和匹配拆成两步1
2
3
4
5
6
7import re
c = re.compile('abc') # 编译一次,反复使用
print('类型:', type(c).__name__)
print('match :', c.match('abcdefghigl').group())
print('search:', c.search('xxabc').group())
print('findall:', c.findall('abcfdasdssabc'))输出:
1
2
3
4类型: Pattern
match : abc
search: abc
findall: ['abc', 'abc']编译出来的对象类型是
Pattern(isinstance(p, re.Pattern)可用),它自带match/search/fullmatch/findall/finditer/split/sub/subn,用法和模块级函数一致,只是不用再传string。
Match 对象
命中拿到 Match,未命中是 None,所以 if m: 判空即可。
1 | import re |
输出:
1 | group() : 4107821995 |
group()等价group(0),是整段匹配;group(1)、group(2)按左括号出现顺序从 1 开始编号。groups()只列捕获组,(?:...)不在里面。m[1]是m.group(1)的简写。lastindex是最后有值的组的编号,lastgroup是它的名字(没命名就是None)。
span() 是左闭右开
start 含、end 不含,和 Python 的切片规矩完全一致——这不是正则的特例,而是刻意设计:让「命中区间」能直接喂给切片。
1 | import re |
输出:
1 | span: (5, 8) | start: 5 | end: 8 |
span(n)/start(n)/end(n)取的是第 n 个捕获组的区间,不是整段匹配:xxabc里整段是(2, 5),组 1 是(2, 3),组 2 是(3, 5)——两组首尾相接,end(1) == start(2)。- 传组名也行:
m.span('city')(配合(?P<city>…))。 - 没参与匹配的可选组返回
(-1, -1),而group()是None。判空别写if m.start(1):(下标 0 是假值,会误判),要写if m.span(1) != (-1, -1):或if m.group(1) is not None:。 - 下标按 Unicode 码点计:中文、emoji 都算 1 个字符,所以
len('a😀b')是 3。但同一串在 UTF-16 的世界里有区别——JS 的s.length、某些数据库的字段长度会把😀算成 2,跨语言传偏移量时要留意。 - 想拿「命中前后的文本」直接切片即可:
s[:m.start()]是前缀,s[m.end():]是后缀,re.split/finditer拼模板时也常这么用。
命名分组更实用,groupdict() 直接给字典:
1 | import re |
输出:
1 | groupdict() : {'province': '410', 'city': '782', 'born': '1995'} |
元字符:单个字符
| 模式 | 描述 |
|---|---|
| 普通字符 | 一对一匹配;. ^ $ * + ? { } [ ] ( ) 这些要加反斜杠才表示自身,竖线也一样 |
. |
除换行 \n 外的任意字符(加 re.S 后连换行也匹配) |
[abc] |
方括号内任意一个字符 |
[a-z] |
区间内任意一个字符 |
[^abc] |
除 abc 之外的任意字符(^ 在字符类里是取反,和行首的 ^ 无关) |
\d \D |
任意数字 / 任意非数字 |
\w \W |
字母、数字、下划线、中文 / 它们的补集 |
\s \S |
任意空白(空格 \t \n \r \v \f) / 任意非空白 |
\b \B |
单词边界(零宽,不消费字符) / 非单词边界 |
1 | import re |
输出:
1 | a.c 'abc a*c a\nc' -> ['abc', 'a*c'] |
\b 最容易看错,它匹配的是位置:
1 | import re |
输出:
1 | ['py', 'py', 'py'] |
第一行有三个 py:串首、. 前后都是边界。第二行 \Bpy 一个都没有,因为 py 和 python 里的 py 前面都是边界,不满足「非边界」。
\w 和中文:
- 字符串模式下
\w按 Unicode 判定,中文算「字」。 - 要 ASCII 规则就显式加
re.A。 - bytes 模式则永远按字节 ASCII 判。
1 | import re |
输出:
1 | str 模式默认按 Unicode: ['中文', '_ab', '12', 'xy'] |
bytes 那行返回空列表,因为「中文」的 UTF-8 字节里没有 \w 认可的 ASCII 字母数字——这是「用 bytes 模式处理文本」最常见的静默失败。
元字符:次数与贪婪
| 模式 | 描述 |
|---|---|
* |
前一项 0 次或多次 |
+ |
前一项 1 次或多次 |
? |
前一项 0 次或 1 次 |
{m} |
恰好 m 次 |
{m,} |
至少 m 次 |
{m,n} |
m 到 n 次,默认尽量多(贪婪) |
{m,n}? |
m 到 n 次,尽量少(非贪婪) |
1 | import re |
输出:
1 | ab* : ['a', 'ab', 'abb', 'abbb'] |
注意最后一行:a* 在 bab 里返回 ['', 'a', '', '']——量词允许 0 次时,每个位置都能匹配到空。这也是 re.sub(r'a*', '-', 'bab') 会插进好几个 - 的原因。
量词只盯紧挨着它的前一项:ab* 里的 * 管的是 b,不是 ab,所以它匹配的是「一个 a 后面跟 0 个或多个 b」;要让 ab 整体重复,得先分组再用量词,写成 (?:ab)*。
1 | import re |
输出:
1 | ab* : ['a', 'ab', 'abb', 'abbb'] |
贪婪与非贪婪的直观差别:
1 | import re |
输出:
1 | 贪婪 a.*c : abcadc |
元字符:边界
| 模式 | 描述 |
|---|---|
^ |
串首;加 re.M 后是每行行首 |
$ |
串尾或结尾换行之前;加 re.M 后是每行行尾 |
\A |
永远是串首 |
\Z |
永远是真正的串尾(不给结尾换行留余地) |
\b \B |
单词边界 / 非单词边界 |
1 | import re |
输出:
1 | 不带 re.M 的 ^ : ['abc'] |
$ 那个「结尾换行之前」是经典坑:校验用户输入时末尾多一个 \n 照样通过。
1 | import re |
输出:
1 | abc$ 能匹配 'abc'+结尾换行吗: True |
所以做整串校验别用 ^...$,用 re.fullmatch();实在要用锚点就用 \A...\Z。
分组
| 模式 | 描述 |
|---|---|
a|b |
或;优先级最低,^ab|cd$ 的意思是「^ab 或者 cd$」 |
(re) |
既确定优先级,又是一个捕获组 |
(?:re) |
只确定优先级,不捕获(不进 groups(),也不占编号) |
(?P<name>re) |
命名捕获组,用 group('name') / groupdict() 取 |
1 | import re |
输出:
1 | ab|cd 在 'abcd' 里: ['ab', 'cd'] |
反向引用(\1、(?P=name))在《正则表达式(二)》里连着 sub、split 一起讲。
断言(零宽)
断言只判断「这个位置前后是什么」,不消耗字符,所以 findall 的结果里不会出现断言那部分。
| 断言 | 语法 | 含义 |
|---|---|---|
| 正向先行 | X(?=Y) |
X 后面必须紧跟 Y,Y 不算进结果 |
| 负向先行 | X(?!Y) |
X 后面不能跟 Y |
| 正向后行 | (?<=Y)X |
X 前面必须是 Y |
| 负向后行 | (?<!Y)X |
X 前面不是 Y |
1 | import re |
输出:
1 | apple(?= pie) : ['apple'] |
后行断言有个硬限制:宽度必须固定。(?<=ab|abc) 这种长度不等的分支连编译都过不去:
1 | import re |
输出:
1 | re.error: look-behind requires fixed-width pattern |
遇到这种需求就换写法:改用先行断言反向表达,或者先粗匹配再用切片处理。
修饰符 flags
| 修饰符 | 作用 | 3.14 实测备注 |
|---|---|---|
re.I |
忽略大小写 | 等价 re.IGNORECASE |
re.M |
多行:改变 ^ 和 $ |
|
re.S |
让 . 也匹配换行 |
等价 re.DOTALL |
re.X |
详细模式:忽略空白、允许 # 注释 |
长表达式的好帮手 |
re.A |
\w \d \s \b 按 ASCII 判定 |
等价 re.ASCII |
re.U |
按 Unicode 判定 | 字符串模式的默认行为,留着兼容 |
re.L |
按 locale 判定 | 只能配 bytes 模式,字符串模式直接 ValueError |
re.T |
老的 TEMPLATE 标志 | 已被移除,hasattr(re, 'T') 为 False |
1 | import re |
输出:
1 | re.I == re.IGNORECASE: True |
多个标志用 | 组合,例如 re.compile(pat, re.I | re.M)。也可以写在模式内部 (?i)、(?s),但内联标志必须出现在模式开头(Python 3.11 起违反就是 error)。
转义:两层解释器
正则在 Python 里要过两道解析:先被 Python 字符串解析一次,再被正则引擎解析一次。所以想匹配一个字面上的反斜杠,得写 r'\\'(原始字符串里两个反斜杠,引擎收到一个)。
1 | import re |
输出:
1 | <string>:5: SyntaxWarning: "\d" is an invalid escape sequence. Such sequences will not work in the future. Did you mean "\\d"? A raw string is also an option. |
三条习惯:
- 模式一律写原始字符串
r'...',少一层歧义,也少数一半反斜杠。 - 要匹配用户给的字符串(文件名、URL 片段)先过
re.escape()。 - 字符类里的
-只有夹在两个字符中间才是区间,[.-+]会报「bad character range」;把-放到首尾即可。
1 | import re |
输出:
1 | re.error: bad character range .-+ at position 1 |
re.escape():把「普通字符串」变成「只匹配字面量的模式」
- 它治什么病:你想搜的是文本本身,可文本里正好带了
.*+([这些正则元字符。比如re.findall('a.b', 'a.b axb ab'),那个.是「任意字符」,结果axb也算命中。 - 它做了什么:在字符串里每个正则特殊字符前面补一个反斜杠,让引擎把它们当字面量看。
re.escape('a.b*c (1+2) 中文')得到'a\\.b\\*c\\ \\(1\\+2\\)\\ 中文'——从 Python 3.7 起它只转义 ASCII 特殊字符(连空格和#&~都转义,为的是丢进re.X详细模式也不会出问题),中文、字母、数字、下划线原样保留。 - 什么时候该用:模式来自用户输入、文件名、URL 片段、日志里的固定串(像
1.5.3这种版本号,不转义就会把.当任意字符)。 - 什么时候别用:模式本来就是你手写好的正则。
re.escape(r'\d+')会把那个反斜杠也当成要转义的字符,结果变成「匹配字面上的\d+这三个字符」,正则等于被废掉了——下面例子的第 4~6 行就是这个坑。
1 | import re |
输出:
1 | 裸模式 a.b : ['a.b', 'axb'] |
实战:校验邮箱与拆 URL
-
邮箱(够用版)
1
2
3
4
5import re
email = re.compile(r'[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}')
for s in ['a@b.com', 'a.b+tag@sub.example.co', 'a@@b.com', 'ab.com', 'a@b.c']:
print('{:<26} {}'.format(repr(s), bool(email.fullmatch(s))))输出:
1
2
3
4
5'a@b.com' True
'a.b+tag@sub.example.co' True
'a@@b.com' False
'ab.com' False
'a@b.c' False这条能挡住大部分明显错误,但 RFC 5322 允许的写法比这宽得多(引号本地部分、IP 字面量域名等)。真实项目里邮箱是否有效由「发验证邮件」决定,正则只做初筛。
-
拆 URL
1
2
3
4
5
6
7import re
url = re.compile(r'(?P<scheme>https?)://(?P<host>[\w.-]+)(?P<port>:\d+)?'
r'(?P<path>/[^\s?#]*)?(?P<query>\?[^\s#]*)?')
for s in ['http://example.com', 'https://example.com:443/s?wd=python#c', 'ftp://x.com']:
m = url.search(s)
print('{:<46} {}'.format(repr(s), m.groupdict() if m else None))输出:
1
2
3'http://example.com' {'scheme': 'http', 'host': 'example.com', 'port': None, 'path': None, 'query': None}
'https://example.com:443/s?wd=python#c' {'scheme': 'https', 'host': 'example.com', 'port': ':443', 'path': '/s', 'query': '?wd=python'}
'ftp://x.com' None命名分组的优势在这里很直接:
groupdict()一次拿到 scheme / host / port / path / query。但真要解析 URL 请用urllib.parse.urlsplit()——正则适合从一段文本里把 URL 捞出来。
顺手记两个常用操作
split 和 sub 也是日常主力,先给最小样子(细节见《正则表达式(二)》):
1 | import re |
输出:
1 | ['正则', '其实不难', '但是学完发现自己写不出来', ''] |
1 | import re |
输出:
1 | a#b#c# |
引用捕获组有两种写法,坑都集中在「组号后面紧跟数字」和「引用了不存在的组」上:
1 | import re |
输出:
1 | 两个组时 \12 会被当成第 12 组: re.error: invalid group reference 12 at position 1 |
\1这种写法会尽量多吃一位数字:\12是「第 12 组」,不是「第 1 组后面跟个 2」。组不够多时直接报invalid group reference 12。\g<1>用尖括号把组号封死,后面再跟数字也不会串味:r'\g<1>2'才是「第 1 组 + 字面量 2」。- 报
invalid group reference 2通常是组数不够:r'(\d)'只有一个组,模板里却引用了\2;消息末尾的at position 6指的是这个引用在替换模板里的位置,不是原字符串的位置。
替换串里引用分组推荐 \g<1> 而不是 \1:\12 会被当成「第 12 组」,而不是「第 1 组后面跟个 2」。
写正则的思路
- 以功能为导向:先说清楚「要匹配什么、不要匹配什么」,再动手。
- 列出正例和反例:各写 3~5 个字符串,正例必须命中、反例必须不命中。
- 写一点测一点:先匹配最小骨架(比如
\d+),确认后再加分组、边界、断言。 - 最后再收紧:加锚点或换
fullmatch,把「部分匹配」变成「整串校验」。 - 原笔记留的三道练习(转义、邮箱、URL)在本篇「转义」和「实战」两节都有对应实现,可以照着重写一遍对照。