正则表达式(一)

前言

  • 正则分两篇:这一篇讲应用场景、使用原则、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
    9
    import 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
    3
    match 命中: 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
    5
    import re

    print('match 只要求开头:', re.match('abc', 'abcd').group())
    print('fullmatch 要求整串:', re.fullmatch('abc', 'abcd'))
    print('fullmatch 整串相符:', re.fullmatch('abc', 'abc').group())

    输出:

    1
    2
    3
    match 只要求开头: abc
    fullmatch 要求整串: None
    fullmatch 整串相符: abc
  • findall:全部取出,要的是内容

    1
    2
    3
    4
    5
    6
    import 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
    4
    import re

    for m in re.finditer(r'\d+', 'a1b22c333'): # 逐个给 Match 对象,适合大文本
    print(m.group(), m.span())

    输出:

    1
    2
    3
    1 (1, 2)
    22 (3, 5)
    333 (6, 9)
  • compile:把解析和匹配拆成两步

    1
    2
    3
    4
    5
    6
    7
    import 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
2
3
4
5
6
7
8
9
10
11
import re

m = re.search(r'(\d{3})(\d{3})(\d{4})', '地址 4107821995xxxxxxxx')
print('group() :', m.group())
print('group(0) :', m.group(0))
print('group(1) :', m.group(1))
print('group(1,3):', m.group(1, 3))
print('groups() :', m.groups())
print('m[2] :', m[2])
print('span():', m.span(), '| start:', m.start(), '| end:', m.end())
print('lastindex :', m.lastindex, '| lastgroup:', m.lastgroup)

输出:

1
2
3
4
5
6
7
8
group()   : 4107821995
group(0) : 4107821995
group(1) : 410
group(1,3): ('410', '1995')
groups() : ('410', '782', '1995')
m[2] : 782
span(): (3, 13) | start: 3 | end: 13
lastindex : 3 | lastgroup: None
  • group() 等价 group(0),是整段匹配;group(1)、group(2) 按左括号出现顺序从 1 开始编号。
  • groups() 只列捕获组,(?:...) 不在里面。
  • m[1] 是 m.group(1) 的简写。
  • lastindex 是最后有值的组的编号,lastgroup 是它的名字(没命名就是 None)。

span() 是左闭右开

start 含、end 不含,和 Python 的切片规矩完全一致——这不是正则的特例,而是刻意设计:让「命中区间」能直接喂给切片。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import re

s = 'helloabc'
m = re.search('abc', s) # 命中的是下标 5、6、7
print('span:', m.span(), '| start:', m.start(), '| end:', m.end())
print('切片与 group 等价:', s[m.start():m.end()] == m.group())
print('命中长度就是 end - start:', m.end() - m.start())

g = re.search(r'(a)(bc)', 'xxabc')
print('整段 span:', g.span(), '| span(1):', g.span(1), '| span(2):', g.span(2))

o = re.search(r'(a)?b', 'b') # 可选组这次没参与匹配
print('没匹配上的组: span(1) =', o.span(1), '| group(1) =', o.group(1))

print('中文/emoji 都按码点算一个字符:', re.search('中', 'a中b').span(),
re.search('😀', 'a😀b').span(), '| len(\'a😀b\') =', len('a😀b'))

输出:

1
2
3
4
5
6
span: (5, 8) | start: 5 | end: 8
切片与 group 等价: True
命中长度就是 end - start: 3
整段 span: (2, 5) | span(1): (2, 3) | span(2): (3, 5)
没匹配上的组: span(1) = (-1, -1) | group(1) = None
中文/emoji 都按码点算一个字符: (1, 2) (1, 2) | len('a😀b') = 3
  • 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
2
3
4
5
6
import re

m = re.search(r'(?P<province>\d{3})(?P<city>\d{3})(?P<born>\d{4})', '4107821995xxxxxxxx')
print('groupdict() :', m.groupdict())
print("group('city'):", m.group('city'))
print('lastindex :', m.lastindex, '| lastgroup:', m.lastgroup)

输出:

1
2
3
groupdict()  : {'province': '410', 'city': '782', 'born': '1995'}
group('city'): 782
lastindex : 3 | lastgroup: born

元字符:单个字符

模式 描述
普通字符 一对一匹配;. ^ $ * + ? { } [ ] ( ) 这些要加反斜杠才表示自身,竖线也一样
. 除换行 \n 外的任意字符(加 re.S 后连换行也匹配)
[abc] 方括号内任意一个字符
[a-z] 区间内任意一个字符
[^abc] 除 abc 之外的任意字符(^ 在字符类里是取反,和行首的 ^ 无关)
\d \D 任意数字 / 任意非数字
\w \W 字母、数字、下划线、中文 / 它们的补集
\s \S 任意空白(空格 \t \n \r \v \f) / 任意非空白
\b \B 单词边界(零宽,不消费字符) / 非单词边界
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import re

cases = [
(r'a.c', 'abc a*c a\nc'),
(r'[abc]+', 'xxbyac'),
(r'[^abc]+', 'xxbyac'),
(r'[a-z]+', 'abc123DEF'),
(r'\d+', 'a12b3'),
(r'\D+', 'a12b3'),
(r'\w+', '中文 _ab 12 -xy'),
(r'\W+', '中文 _ab 12 -xy'),
(r'\s+', 'a \t\nb'),
(r'\S+', 'a \tb'),
]

for pat, text in cases:
print('{:<10} {!r:<22} -> {!r}'.format(pat, text, re.findall(pat, text)))

输出:

1
2
3
4
5
6
7
8
9
10
a.c        'abc a*c a\nc'         -> ['abc', 'a*c']
[abc]+ 'xxbyac' -> ['b', 'ac']
[^abc]+ 'xxbyac' -> ['xx', 'y']
[a-z]+ 'abc123DEF' -> ['abc']
\d+ 'a12b3' -> ['12', '3']
\D+ 'a12b3' -> ['a', 'b']
\w+ '中文 _ab 12 -xy' -> ['中文', '_ab', '12', 'xy']
\W+ '中文 _ab 12 -xy' -> [' ', ' ', ' -']
\s+ 'a \t\nb' -> [' \t\n']
\S+ 'a \tb' -> ['a', 'b']

\b 最容易看错,它匹配的是位置:

1
2
3
4
5
import re

# \b 匹配的是「单词边界」这个位置,本身不消费字符
print(re.findall(r'\bpy\b', 'py python py.py'))
print(re.findall(r'\Bpy', 'py python'))

输出:

1
2
['py', 'py', 'py']
[]

第一行有三个 py:串首、. 前后都是边界。第二行 \Bpy 一个都没有,因为 py 和 python 里的 py 前面都是边界,不满足「非边界」。

\w 和中文:

  • 字符串模式下 \w 按 Unicode 判定,中文算「字」。
  • 要 ASCII 规则就显式加 re.A。
  • bytes 模式则永远按字节 ASCII 判。
1
2
3
4
5
6
import re

print('str 模式默认按 Unicode:', re.findall(r'\w+', '中文 _ab 12 -xy'))
print('加 re.A 退回 ASCII :', re.findall(r'\w+', '中文 ab', re.A))
print('bytes 模式遇到中文字节:', re.findall(rb'\w+', '中文'.encode()))
print('bytes 模式 ASCII :', re.findall(rb'\w+', b'a1 b'))

输出:

1
2
3
4
str 模式默认按 Unicode: ['中文', '_ab', '12', 'xy']
加 re.A 退回 ASCII : ['ab']
bytes 模式遇到中文字节: []
bytes 模式 ASCII : [b'a1', b'b']

bytes 那行返回空列表,因为「中文」的 UTF-8 字节里没有 \w 认可的 ASCII 字母数字——这是「用 bytes 模式处理文本」最常见的静默失败。

元字符:次数与贪婪

模式 描述
* 前一项 0 次或多次
+ 前一项 1 次或多次
? 前一项 0 次或 1 次
{m} 恰好 m 次
{m,} 至少 m 次
{m,n} m 到 n 次,默认尽量多(贪婪)
{m,n}? m 到 n 次,尽量少(非贪婪)
1
2
3
4
5
6
7
8
9
10
import re

print('ab* :', re.findall(r'ab*', 'a ab abb abbb'))
print('ab+ :', re.findall(r'ab+', 'a ab abb'))
print('ab? :', re.findall(r'ab?', 'a ab abb'))
print('a{3} :', re.findall(r'a{3}', 'aa aaa aaaa'))
print('a{2,} :', re.findall(r'a{2,}', 'aa aaaa'))
print('a{1,3} :', re.findall(r'a{1,3}', 'aaaa'))
print('a{1,3}?:', re.findall(r'a{1,3}?', 'aaaa'))
print("a* 在 'bab' 里:", re.findall(r'a*', 'bab'))

输出:

1
2
3
4
5
6
7
8
ab*    : ['a', 'ab', 'abb', 'abbb']
ab+ : ['ab', 'abb']
ab? : ['a', 'ab', 'ab']
a{3} : ['aaa', 'aaa']
a{2,} : ['aa', 'aaaa']
a{1,3} : ['aaa', 'a']
a{1,3}?: ['a', 'a', 'a', 'a']
a* 在 'bab' 里: ['', 'a', '', '']

注意最后一行:a* 在 bab 里返回 ['', 'a', '', '']——量词允许 0 次时,每个位置都能匹配到空。这也是 re.sub(r'a*', '-', 'bab') 会插进好几个 - 的原因。

量词只盯紧挨着它的前一项:ab* 里的 * 管的是 b,不是 ab,所以它匹配的是「一个 a 后面跟 0 个或多个 b」;要让 ab 整体重复,得先分组再用量词,写成 (?:ab)*。

1
2
3
4
5
import re

print('ab* :', re.findall(r'ab*', 'a ab abb abbb'))
print('(?:ab)+ :', re.findall(r'(?:ab)+', 'a ab ab ab'))
print('a*b :', re.findall(r'a*b', 'b ab aab aaab'))

输出:

1
2
3
ab*     : ['a', 'ab', 'abb', 'abbb']
(?:ab)+ : ['ab', 'ab', 'ab']
a*b : ['b', 'ab', 'aab', 'aaab']

贪婪与非贪婪的直观差别:

1
2
3
4
5
6
import re

print('贪婪 a.*c :', re.search(r'a.*c', 'abcadc').group())
print('非贪婪 a.*?c:', re.search(r'a.*?c', 'abcadc').group())
print('贪婪 <.*> :', re.match(r'<.*>', '<a><b>').group())
print('非贪婪 <.*?>:', re.match(r'<.*?>', '<a><b>').group())

输出:

1
2
3
4
贪婪  a.*c : abcadc
非贪婪 a.*?c: abc
贪婪 <.*> : <a><b>
非贪婪 <.*?>: <a>

元字符:边界

模式 描述
^ 串首;加 re.M 后是每行行首
$ 串尾或结尾换行之前;加 re.M 后是每行行尾
\A 永远是串首
\Z 永远是真正的串尾(不给结尾换行留余地)
\b \B 单词边界 / 非单词边界
1
2
3
4
5
6
7
import re

text = 'abc\nabc\n'
print('不带 re.M 的 ^ :', re.findall(r'^abc', text))
print('带 re.M 的 ^ :', re.findall(r'^abc', text, re.M))
print('带 re.M 的 $ :', re.findall(r'abc$', text, re.M))
print('\\A 只认整串开头:', re.findall(r'\Aabc', text, re.M))

输出:

1
2
3
4
不带 re.M 的 ^ : ['abc']
带 re.M 的 ^ : ['abc', 'abc']
带 re.M 的 $ : ['abc', 'abc']
\A 只认整串开头: ['abc']

$ 那个「结尾换行之前」是经典坑:校验用户输入时末尾多一个 \n 照样通过。

1
2
3
4
import re

print("abc$ 能匹配 'abc'+结尾换行吗:", bool(re.search(r'abc$', 'abc\n')))
print("abc\\Z 能匹配 'abc'+结尾换行吗:", bool(re.search(r'abc\Z', 'abc\n')))

输出:

1
2
abc$  能匹配 'abc'+结尾换行吗: True
abc\Z 能匹配 'abc'+结尾换行吗: False

所以做整串校验别用 ^...$,用 re.fullmatch();实在要用锚点就用 \A...\Z。

分组

模式 描述
a|b 或;优先级最低,^ab|cd$ 的意思是「^ab 或者 cd$」
(re) 既确定优先级,又是一个捕获组
(?:re) 只确定优先级,不捕获(不进 groups(),也不占编号)
(?P<name>re) 命名捕获组,用 group('name') / groupdict() 取
1
2
3
4
5
6
7
import re

print("ab|cd 在 'abcd' 里:", re.findall(r'ab|cd', 'abcd'))
m = re.search(r'a(hello|world)d', 'ahellod')
print('整体:', m.group(), '| 分组:', m.groups())
m2 = re.search(r'(a)(?:b)(c)', 'abc')
print('(?:...) 不进 groups:', m2.groups())

输出:

1
2
3
ab|cd 在 'abcd' 里: ['ab', 'cd']
整体: ahellod | 分组: ('hello',)
(?:...) 不进 groups: ('a', 'c')

反向引用(\1、(?P=name))在《正则表达式(二)》里连着 sub、split 一起讲。

断言(零宽)

断言只判断「这个位置前后是什么」,不消耗字符,所以 findall 的结果里不会出现断言那部分。

断言 语法 含义
正向先行 X(?=Y) X 后面必须紧跟 Y,Y 不算进结果
负向先行 X(?!Y) X 后面不能跟 Y
正向后行 (?<=Y)X X 前面必须是 Y
负向后行 (?<!Y)X X 前面不是 Y
1
2
3
4
5
6
7
8
import re

print('apple(?= pie) :', re.findall(r'apple(?= pie)', 'apple pie'))
print('apple(?! pie) :', re.findall(r'apple(?! pie)', 'apple pie and apple tart'))
print('(?<=apple )pie :', re.findall(r'(?<=apple )pie', 'apple pie and banana pie'))
print('(?<!apple )pie :', re.findall(r'(?<!apple )pie', 'apple pie and banana pie'))
print('\\w+(?=\\?) :', re.findall(r'\w+(?=\?)', 'who? what?'))
print('(?<=\\$)\\d+ :', re.findall(r'(?<=\$)[0-9]+', 'a $12 b $30'))

输出:

1
2
3
4
5
6
apple(?= pie)  : ['apple']
apple(?! pie) : ['apple']
(?<=apple )pie : ['pie']
(?<!apple )pie : ['pie']
\w+(?=\?) : ['who', 'what']
(?<=\$)\d+ : ['12', '30']

后行断言有个硬限制:宽度必须固定。(?<=ab|abc) 这种长度不等的分支连编译都过不去:

1
2
3
4
5
6
import re

try:
re.findall(r'(?<=ab|abc)x', 'abcx')
except re.error as e:
print('re.error:', e)

输出:

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import re

print('re.I == re.IGNORECASE:', re.I == re.IGNORECASE)
print('re.U 是默认行为 :', re.findall(r'\w', '中文a', re.U))
try:
re.findall(r'\w', '中文a', re.L)
except ValueError as e:
print('re.L + str 模式:', e)
print('re.L + bytes 模式 :', re.findall(rb'\w', b'ab', re.L))
print('re.T 还在吗:', hasattr(re, 'T'))
pat = r'''
\d+ # 整数部分
\s*-\s* # 中间的连字符
(\w+) # 名字
'''
print('re.X 可以分行写注释 :', re.findall(pat, '3 - x, 10 - y', re.X))

输出:

1
2
3
4
5
6
re.I == re.IGNORECASE: True
re.U 是默认行为 : ['中', '文', 'a']
re.L + str 模式: cannot use LOCALE flag with a str pattern
re.L + bytes 模式 : [b'a', b'b']
re.T 还在吗: False
re.X 可以分行写注释 : ['x', 'y']

多个标志用 | 组合,例如 re.compile(pat, re.I | re.M)。也可以写在模式内部 (?i)、(?s),但内联标志必须出现在模式开头(Python 3.11 起违反就是 error)。

转义:两层解释器

正则在 Python 里要过两道解析:先被 Python 字符串解析一次,再被正则引擎解析一次。所以想匹配一个字面上的反斜杠,得写 r'\\'(原始字符串里两个反斜杠,引擎收到一个)。

1
2
3
4
5
6
7
8
9
10
11
import re

print(r"写法一 r'\d+' ->", re.findall(r'\d+', 'a1b'))
print(r"写法二 '\\d+' ->", re.findall('\\d+', 'a1b'))
print("写法三 '\d+' ->", end=' ')
try:
print(re.findall('\d+', 'a1b'))
except re.error as e:
print('re.error:', e)
print('re.escape 结果 :', repr(re.escape('a.b*c 中文')))
print('拿它去匹配字面 a.b :', re.findall(re.escape('a.b'), 'a.b ab'))

输出:

1
2
3
4
5
6
7
<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.
<string>:7: 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'\d+' -> ['1']
写法二 '\\d+' -> ['1']
写法三 '\d+' -> ['1']
re.escape 结果 : 'a\\.b\\*c\\ 中文'
拿它去匹配字面 a.b : ['a.b']

三条习惯:

  • 模式一律写原始字符串 r'...',少一层歧义,也少数一半反斜杠。
  • 要匹配用户给的字符串(文件名、URL 片段)先过 re.escape()。
  • 字符类里的 - 只有夹在两个字符中间才是区间,[.-+] 会报「bad character range」;把 - 放到首尾即可。
1
2
3
4
5
6
7
8
import re

try:
re.findall(r'[.-+]', 'a.b-c+d')
except re.error as e:
print('re.error:', e)
print('把 - 放到末尾就没事:', re.findall(r'[.+-]', 'a.b-c+d'))
print('字符类里 . 不用转义:', re.findall(r'[.-]', 'a.b-c+d'))

输出:

1
2
3
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
2
3
4
5
6
7
8
9
import re

print('裸模式 a.b :', re.findall('a.b', 'a.b axb ab'))
print('escape 后 :', re.findall(re.escape('a.b'), 'a.b axb ab'))
print('escape 做了什么 :', repr(re.escape('a.b*c (1+2) 中文')))
print('对正则本身 escape:', repr(re.escape(r'\d+')))
print(' 再去匹配 a12 :', re.findall(re.escape(r'\d+'), 'a12'))
print(' 匹配字面 \\d+ :', re.findall(re.escape(r'\d+'), r'x\d+y'))
print('搜索场景也一样 :', re.search(re.escape('1.5.3'), 'v1.5.3 final').group())

输出:

1
2
3
4
5
6
7
裸模式 a.b      : ['a.b', 'axb']
escape 后 : ['a.b']
escape 做了什么 : 'a\\.b\\*c\\ \\(1\\+2\\)\\ 中文'
对正则本身 escape: '\\\\d\\+'
再去匹配 a12 : []
匹配字面 \d+ : ['\\d+']
搜索场景也一样 : 1.5.3

实战:校验邮箱与拆 URL

  • 邮箱(够用版)

    1
    2
    3
    4
    5
    import 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
    7
    import 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
2
3
4
5
import re

print(re.split(r'\d+', '正则1其实不难2但是学完发现自己写不出来3'))
print(re.split(r'(\d+)', 'a1b22c')) # 分隔符加了括号,会留在结果里
print(re.split(r'\d+', 'a1b22c3', maxsplit=1))

输出:

1
2
3
['正则', '其实不难', '但是学完发现自己写不出来', '']
['a', '1', 'b', '22', 'c']
['a', 'b22c3']
1
2
3
4
5
6
7
import re

print(re.sub(r'\d', '#', 'a1b2c3'))
print(re.sub(r'\d', '#', 'a1b2c3', count=2))
print(re.sub(r'(\w)(\w)', r'\2\1', 'ab cd')) # 组内互换
print(re.sub(r'(\d)', r'x\g<1>y', '12')) # 推荐 \g<1>,避免 \12 歧义
print(re.subn(r'\d', '#', 'a1b2')) # 顺带给出替换次数

输出:

1
2
3
4
5
a#b#c#
a#b#c3
ba dc
x1yx2y
('a#b#', 2)

引用捕获组有两种写法,坑都集中在「组号后面紧跟数字」和「引用了不存在的组」上:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import re

print('两个组时 \\12 会被当成第 12 组:', end=' ')
try:
print(re.sub(r'(\d)(\d)', r'\12', '12'))
except re.error as e:
print('re.error:', e)
print('换成 \\g<1>2 才是「第1组 + 字面量2」:', re.sub(r'(\d)(\d)', r'\g<1>2', '12'))
print('只有一个组却引用 \\2:', end=' ')
try:
print(re.sub(r'(\d)', r'x\1yx\2y', '12'))
except re.error as e:
print('re.error:', e)
print('\\1 后面不是数字时没问题:', re.sub(r'(\d)', r'x\1y', '12'))

输出:

1
2
3
4
两个组时 \12 会被当成第 12 组: re.error: invalid group reference 12 at position 1
换成 \g<1>2 才是「第1组 + 字面量2」: 12
只有一个组却引用 \2: re.error: invalid group reference 2 at position 6
\1 后面不是数字时没问题: x1yx2y
  • \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)在本篇「转义」和「实战」两节都有对应实现,可以照着重写一遍对照。

参考

本文结束 感谢您的阅读
正在加载今日诗词....