1
2
3
4
5
6
7
作者:李晓辉

联系方式:

1. 微信:Lxh_Chat

2. 邮箱:939958092@qq.com
  1. Python 系列(一) 从零开始认识 Python,入门其实没那么难
  2. Python 系列(二) 学 Python 的第一步,把开发环境搭建好
  3. Python 系列(三) 基础语法避坑指南,缩进、大小写、续行与注释
  4. Python 系列(四) Python 输入输出详解,print()、input() 与常见避坑指南
  5. Python 系列(五) 数值类型与运算符详解,整除、浮点精度与进制转换实战
  6. Python 系列(六) 字符串处理实战,查找、替换、切片与日志分析
  7. Python 系列(七) 类型转换详解,int()、float()、str() 与金额计算避坑
  8. Python 系列(八) Python 流程控制入门,掌握 if、elif 和 else
  9. Python 系列(九) Python 逻辑运算符详解,and、or、not 一篇搞懂
  10. Python 系列(十) while、for 循环实战,彻底搞懂 range、break 与 continue
  11. Python 系列(十一) list、tuple、set、dict,到底该怎么选?
  12. Python 系列(十二) List 列表实战,搞定数据增删改查与批量处理
  13. Python 系列(十三) Tuple 元组实战,搞懂不可变特性与 enumerate() 遍历
  14. Python 系列(十四) Set 集合实战,搞定数据去重与集合运算
  15. Python 系列(十五) Dict 字典详解,掌握快速查找与键值操作
  16. Python 系列(十六) sort() 和 sorted() 详解,掌握数据排序技巧
  17. Python 系列(十七) 自定义函数入门,让重复代码一次编写多次使用
  18. Python 系列(十八) 函数参数详解,搞懂位置参数、关键字参数和默认参数
  19. Python 系列(十九) 从 *args 到 **kwargs,让函数调用更灵活
  20. Python 系列(二十) 变量作用域是什么?一文搞懂 global 和 nonlocal
  21. Python 系列(二十一) 搞懂变量、对象和引用,理解函数参数传递机制
  22. Python 系列(二十二) map() 和 filter() 详解,轻松实现批量数据处理与筛选
  23. Python 系列(二十三) 嵌套函数与 lambda 表达式详解,搞懂作用域与闭包
  24. Python 系列(二十四) 搞懂递归函数,从基线条件到实际应用
  25. Python 系列(二十五) 聊聊模块与 import,让代码告别重复编写
  26. Python 系列(二十六) Python 模块入门,从 import 到模块复用
  27. Python 系列(二十七) Python dir() 函数详解,快速查看模块中的函数与属性
  28. Python 系列(二十八) 不会用 Python 标准库?教你快速查找模块和函数
  29. Python 系列(二十九) 搞懂 Python sys 模块,让脚本更适合自动化运维
  30. Python 系列(三十) 为什么 0.1 + 0.2 不等于 0.3?彻底搞懂 Decimal
  31. Python 系列(三十一) 搞懂 datetime 日期时间,轻松处理时区与时间计算
  32. Python 系列(三十二) 面向对象编程入门,从零理解类、对象、封装与继承
  33. Python 系列(三十三) 什么是类和对象?零基础理解 Python class
  34. Python 系列(三十四) Python 属性详解,搞懂类属性、实例属性和 setter
  35. Python 系列(三十五) 学会特殊方法,让对象打印出你想要的内容
  36. Python 系列(三十六) 多个对象如何共享数据?彻底搞懂类变量
  37. Python 系列(三十七) 搞懂父类、子类和方法重写,真正理解继承与多态
  38. Python 系列(三十八) 如何判断数据类型?三个内置函数一次讲明白
  39. Python 系列(三十九) 搞懂异常模型与常见异常类型,读懂程序报错
  40. Python 系列(四十) 从零学会异常处理,掌握 try、except、else 和 finally
  41. Python 系列(四十一) ValueError、TypeError 到底是什么?一文搞懂常见异常
  42. Python 系列(四十二) ValueError、TypeError 到底是什么?一文搞懂常见异常
  43. Python 系列(四十三) 别只会处理异常!学会自定义异常,让程序更容易维护
  44. Python 系列(四十四) 从零认识 assert 断言,让程序学会检查自己
  45. Python 系列(四十五) 使用 sys 模块处理标准输入与输出
  46. Python 系列(四十六) 使用 open() 读取和写入文件
  47. Python 系列(四十七) 学会读写文本文件,让程序保存运行结果
  48. Python 系列(四十八) 搞懂 bytes、bytearray 和二进制读写
  49. Python 系列(四十九) 使用 seek() 和 tell() 灵活控制文件读取位置
  50. Python 系列(五十) 掌握 os 和 os.path,实现文件与目录自动化管理
  51. Python 系列(五十一) 掌握列表推导式,轻松实现列表生成与数据筛选
  52. Python 系列(五十二) 掌握字典推导式,让字典创建更简单
  53. Python 系列(五十三) 从零理解生成器,轻松处理大量数据
  54. Python 系列(五十五) 使用 zip() 同时遍历多个集合
  55. Python 系列(五十六) 使用 re 模块进行正则表达式匹配
  56. Python 系列(五十七) 理解正则表达式的字符类和量词
  57. Python 系列(五十八) 搞懂正则表达式中的分组与量词
  58. Python 系列(五十九) 学会正则捕获组,轻松提取文本数据
  59. Python 系列(六十) 掌握 re 模块的常用函数
  60. Python 系列(六十一) JSON 是什么?语法规则、数据类型与校验方法
  61. Python 系列(六十二) 使用 pdb 逐步调试程序,定位代码问题
  62. Python 系列(六十三) 在交互式 Python shell 中启动调试器
  63. Python 系列(六十四) 从零开始学习 pdb 命令行调试
  64. Python 系列(六十五) 使用 pdb 设置断点并检查程序执行上下文

前面我们学习了正则表达式中的字符类和量词。

例如,使用 \d+ 可以匹配一个或多个连续的数字:

1
2
3
4
5
import re

result = re.search(r'\d+', 'server123')

print(result.group())

运行结果:

1
123

这里的 \d 表示匹配一个数字,+ 表示前面的内容可以出现一次或多次。因此,\d+ 可以匹配连续出现的多个数字。

但是,如果我们希望重复匹配的不是一个字符,而是一段完整的内容,该怎么办呢?

例如,下面这段文本中,ab 连续出现了三次:

1
ababab

如果希望使用一个量词来表示 ab 可以重复出现,就需要学习正则表达式中的分组。

为什么需要分组

先来看一个例子:

1
2
3
4
5
import re

result = re.search(r'ab+', 'abbb')

print(result.group())

运行结果:

1
abbb

为什么匹配结果是 abbb,而不是只匹配一个 ab 呢?

这是因为,量词默认只作用于它前面的那个字符或正则表达式元素。

在 ab+ 中:

  • a:匹配一个字母 a。
  • b:匹配一个字母 b。
  • +:表示前面的 b 可以出现一次或多次。

所以,ab+ 可以匹配 ab、abb、abbb 等内容。

但是,如果我们希望 ab 这两个字符作为一个整体重复出现,就需要使用圆括号将它们括起来。

使用括号创建分组

在正则表达式中,圆括号 () 可以把一段表达式组合成一个整体。

例如:

1
2
3
4
5
import re

result = re.search(r'(ab)+', 'abab')

print(result.group())

运行结果:

1
abab

这里的 (ab)+ 可以拆成两部分理解:

  • (ab):将字母 a 和 b 组合成一个整体。
  • +:表示这个整体可以连续出现一次或多次。

因此,(ab)+ 可以匹配以下内容:

待匹配字符串是否能找到匹配实际匹配结果
ab是ab
abab是abab
ababab是ababab
a否无匹配结果
aba是ab

这里需要特别解释最后一行。

正则表达式 (ab)+ 要求找到一个或多个连续的 ab。字符串 aba 的开头有一个完整的 ab,所以 re.search() 能够找到匹配结果 ab。

但是,最后一个 a 不属于这次匹配的内容。匹配成功不代表整个字符串都符合正则表达式。

如果希望整个字符串必须由一个或多个完整的 ab 组成,可以使用 re.fullmatch():

1
2
3
4
5
6
7
import re

result1 = re.fullmatch(r'(ab)+', 'abab')
result2 = re.fullmatch(r'(ab)+', 'aba')

print(result1.group())
print(result2)

运行结果:

1
2
abab
None

第一个字符串完全由两个 ab 组成,因此匹配成功。

第二个字符串最后多出了一个 a,不能由一个或多个完整的 ab 组成,因此匹配失败,返回 None。

这也说明了两个方法的区别:

  • re.search():在字符串中查找符合条件的部分。
  • re.fullmatch():要求整个字符串都符合正则表达式。

理解分组与量词的工作过程

我们可以把 (ab)+ 理解为:先匹配一个完整的 ab,然后继续尝试匹配下一个 ab,直到无法继续匹配为止。

假设待匹配的字符串是:

1
ababab

匹配过程可以简化为下面的流程:

flowchart TD
    A["读取正则表达式 (ab)+"] --> B["将 ab 组合成一个分组"]
    B --> C["尝试匹配一个完整的 ab"]
    C --> D{"是否匹配成功?"}
    D -->|是| E["完成一次分组匹配"]
    E --> F{"后面还有完整的 ab 吗?"}
    F -->|有| E
    F -->|没有| G["结束匹配并返回结果"]
    D -->|否| H["没有满足要求的匹配"]

对于 ababab,可以把匹配过程理解为:

  1. 匹配第一个 ab。
  2. 匹配第二个 ab。
  3. 匹配第三个 ab。
  4. 后面已经没有完整的 ab,结束匹配。

最终,整个表达式匹配到的内容是 ababab。

这就是分组与量词配合使用的核心作用:让量词作用于括号中的整个表达式,而不是只作用于最后一个字符。

区分字符量词和分组量词

下面通过一个例子,对比 ab+ 和 (ab)+ 的区别:

1
2
3
4
5
6
7
8
9
import re

text = 'ababab'

result1 = re.search(r'ab+', text)
result2 = re.search(r'(ab)+', text)

print(result1.group())
print(result2.group())

运行结果:

1
2
ab
ababab

为什么两个表达式得到的结果不同?

对于 ab+:

  • 首先匹配一个 a。
  • 接着匹配一个或多个连续的 b。
  • 字符串开头是 ab,但后面紧跟的是 a,而不是 b,因此匹配到 ab 就结束了。

对于 (ab)+:

  • 将 ab 作为一个整体。
  • 连续匹配三个 ab。
  • 最终得到 ababab。

再来看问号 ? 的区别:

1
2
3
4
5
6
7
import re

result1 = re.search(r'ab?', 'a')
result2 = re.search(r'(ab)?', 'a')

print(repr(result1.group()))
print(repr(result2.group()))

运行结果:

1
2
'a'
''

第一种表达式 ab? 中,? 只作用于 b,表示 b 可以出现零次或一次。因此,即使字符串中没有 b,也可以匹配到 a。

第二种表达式 (ab)? 中,? 作用于整个分组,表示整个 ab 可以出现零次或一次。

由于零次出现也是允许的,re.search() 可以在字符串开头找到一个长度为零的匹配,因此 result2.group() 返回空字符串 ''。

理解空匹配:为什么没有字符也能匹配成功

初学者可能会觉得奇怪:既然字符串是 a,没有完整的 ab,为什么 (ab)? 还能匹配成功?

关键在于,? 表示前面的分组可以出现零次或一次。

所谓出现零次,就是这次匹配不需要匹配到 ab 中的任何字符。因此,Python 可以在字符串开头找到一个长度为零的匹配。

我们可以通过下面的代码观察:

1
2
3
4
5
6
7
8
import re

result = re.search(r'(ab)?', 'a')

print(result is None)
print(repr(result.group()))
print(result.start())
print(result.end())

运行结果:

1
2
3
4
False
''
0
0

逐行解释:

  • result is None 输出 False,说明 Python 找到了匹配对象。
  • repr(result.group()) 输出 '',说明这次匹配得到的是空字符串。
  • result.start() 输出 0,表示匹配从字符串的起始位置开始。
  • result.end() 输出 0,表示匹配结束的位置仍然是 0。

开始位置和结束位置相同,说明这次匹配没有消耗任何字符。

这里需要区分三个概念:

表达形式含义
None没有找到匹配对象
''找到了匹配对象,但匹配内容为空字符串
print('')输出空字符串,因此屏幕上只会出现一个空行

例如:

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

result1 = re.search(r'z+', 'abc')
result2 = re.search(r'(ab)?', 'a')

print(result1)
print(result2 is None)
print(repr(result2.group()))
print(result2.group())
print('下一行内容')

运行结果:

1
2
3
4
5
None
False
''

下一行内容

第四条输出对应一个空行。这是因为 result2.group() 返回了空字符串,而 print() 输出空字符串后仍然会换行。

因此,屏幕上出现空行,不代表匹配失败。判断是否找到匹配对象,应检查结果是否为 None。

分组可以与哪些量词组合使用

分组可以与多种量词组合使用。

量词含义示例
*分组可以出现零次或多次(ab)*
+分组可以出现一次或多次(ab)+
?分组可以出现零次或一次(ab)?
{3}分组必须连续出现三次(ab){3}
{2,4}分组连续出现两到四次(ab){2,4}

例如,我们希望匹配恰好连续出现三次的 ab:

1
2
3
4
5
import re

result = re.fullmatch(r'(ab){3}', 'ababab')

print(result.group())

运行结果:

1
ababab

如果字符串中只有两次 ab,就无法满足要求:

1
2
3
4
5
import re

result = re.fullmatch(r'(ab){3}', 'abab')

print(result)

运行结果:

1
None

None 表示没有找到符合要求的完整匹配。

这里使用 re.fullmatch(),是为了要求整个字符串都必须由三组 ab 组成,而不是只要其中某一部分满足条件就算成功。

在 Linux 运维场景中使用分组

假设我们正在分析 Linux 服务器日志,发现某条日志中连续出现了多个 ERROR 标记:

1
ERRORERRORERROR disk failure

如果希望提取开头连续重复的 ERROR 标记,可以这样写:

1
2
3
4
5
6
7
8
import re

log = 'ERRORERRORERROR disk failure'

result = re.match(r'(ERROR)+', log)

if result:
print(result.group())

运行结果:

1
ERRORERRORERROR

这里使用 re.match(),因为我们只需要从日志开头开始匹配。

正则表达式 (ERROR)+ 表示:

  • (ERROR):将整个 ERROR 单词组合成一个分组。
  • +:要求这个分组连续匹配一次或多次。

如果写成 ERROR+,量词就只作用于最后一个 R,含义会完全不同。

例如:

1
2
3
4
5
6
7
8
import re

log = 'ERRORRR disk failure'

result = re.match(r'ERROR+', log)

if result:
print(result.group())

运行结果:

1
ERRORRR

这是因为 ERROR+ 中的 + 只修饰最后一个 R,允许 R 连续出现多次。

因此,当我们需要重复匹配一个单词、一段字符串或者一组字符时,应该先使用括号组合表达式,再根据需要选择量词。

分组与捕获分组有什么区别

到这里,我们需要区分两个相关但不同的概念:分组用于组合表达式,捕获分组用于保存分组匹配到的内容。

普通圆括号 () 既可以将表达式组合成一个整体,也会创建捕获分组。这两种作用经常同时出现,但解决的问题不同。

使用分组控制重复匹配

例如:

1
2
3
4
5
import re

result = re.fullmatch(r'(ab){3}', 'ababab')

print(result.group())

运行结果:

1
ababab

这里使用 (ab){3},是为了把 ab 作为一个整体,并要求它连续出现三次。

括号明确了量词的作用范围,这是分组在这个例子中的主要用途。

使用捕获分组提取内容

普通圆括号还会创建捕获分组。捕获分组可以保存对应部分匹配到的内容,以便后续提取。

例如,我们想从服务器监控记录中提取 CPU 使用率和内存使用率:

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

text = 'CPU=75% MEMORY=90%'

result = re.search(r'CPU=(\d+)% MEMORY=(\d+)%', text)

if result:
print(result.group(0))
print(result.group(1))
print(result.group(2))

运行结果:

1
2
3
CPU=75% MEMORY=90%
75
90

这段表达式可以拆成几个部分:

  • CPU=:匹配固定文本 CPU=。
  • (\d+):匹配一个或多个数字,并创建第一个捕获分组。
  • % MEMORY=:匹配固定文本 % MEMORY=。
  • (\d+):匹配一个或多个数字,并创建第二个捕获分组。
  • %:匹配百分号。

这里:

  • group(0):返回整个正则表达式匹配到的内容。
  • group(1):返回第一个捕获分组中的内容,即 75。
  • group(2):返回第二个捕获分组中的内容,即 90。

如果只是想控制一段表达式重复匹配,重点是分组与量词的组合。

如果还需要从匹配结果中提取某个字段,就需要关注捕获分组。

重复匹配时,捕获分组会保存什么

再来看一个容易产生误解的例子:

1
2
3
4
5
6
import re

result = re.search(r'(ab)+', 'ababab')

print(result.group(0))
print(result.group(1))

运行结果:

1
2
ababab
ab

为什么 group(0) 得到 ababab,而 group(1) 只有 ab?

原因是:

  • group(0) 返回整个正则表达式匹配到的内容。
  • group(1) 对应第一个捕获分组 (ab)。
  • 这个捕获分组在重复匹配过程中多次成功匹配,但普通捕获分组只保留最后一次捕获的内容。

因此,group(1) 不会自动把所有重复匹配的结果保存成列表。

可以把它们理解为:

表达形式作用
(ab)+将 ab 组合成一个分组,并要求它出现一次或多次
group(0)获取整个正则表达式匹配到的内容
group(1)获取第一个捕获分组最后一次捕获的内容

普通圆括号可以同时承担组合表达式和捕获内容这两种作用,但两者不是同一个概念。

动手练习:观察分组与捕获分组

理解概念之后,最好亲自运行几段代码,观察不同表达式产生的结果。

练习一:比较 ab+ 和 (ab)+

请运行下面的代码,预测两次输出结果,再执行程序验证。

1
2
3
4
5
6
7
8
9
import re

text = 'ababab'

result1 = re.search(r'ab+', text)
result2 = re.search(r'(ab)+', text)

print('表达式一:', result1.group())
print('表达式二:', result2.group())

运行结果:

1
2
表达式一: ab
表达式二: ababab

思考一下:

  • 为什么第一个表达式只匹配到 ab?
  • 为什么第二个表达式可以匹配到整个 ababab?

参考答案:第一个表达式的 + 只作用于 b;第二个表达式的 + 作用于整个 ab 分组。

练习二:观察重复分组的捕获结果

运行下面的代码:

1
2
3
4
5
6
import re

result = re.search(r'(ab)+', 'ababab')

print('完整匹配:', result.group(0))
print('第一个捕获分组:', result.group(1))

运行结果:

1
2
完整匹配: ababab
第一个捕获分组: ab

思考一下:为什么完整匹配结果是 ababab,而捕获分组只有 ab?

参考答案:group(0) 返回整个表达式匹配到的内容;group(1) 返回第一个捕获分组最后一次捕获的内容。重复捕获分组不会自动收集每一次匹配的结果。

练习三:提取监控数据中的两个数值

现在有一条简化的监控记录:

1
CPU=65% MEMORY=82%

请使用两个捕获分组,分别提取 CPU 和内存使用率。

可以先尝试自己编写代码,再对照下面的答案:

1
2
3
4
5
6
7
8
9
import re

text = 'CPU=65% MEMORY=82%'

result = re.search(r'CPU=(\d+)% MEMORY=(\d+)%', text)

if result:
print('CPU 使用率:', result.group(1))
print('内存使用率:', result.group(2))

运行结果:

1
2
CPU 使用率: 65
内存使用率: 82

这里的两个 (\d+) 分别创建了第一个和第二个捕获分组。

正则表达式负责找到符合格式的文本,捕获分组则帮助我们单独提取其中的数值。

注意,这里提取出来的 65 和 82 仍然是字符串。如果后续需要进行数值比较,还可以使用 int() 将它们转换为整数。

练习四:区分匹配失败与空匹配

运行下面的代码:

1
2
3
4
5
6
7
8
import re

result1 = re.search(r'z+', 'abc')
result2 = re.search(r'(ab)?', 'a')

print(result1 is None)
print(result2 is None)
print(repr(result2.group()))

运行结果:

1
2
3
True
False
''

请回答:

  • 为什么 result1 is None 的结果是 True?
  • 为什么 result2 is None 的结果是 False?
  • 为什么 result2.group() 返回 ''?

参考答案:

z+ 要求匹配一个或多个连续的 z,而字符串 abc 中没有 z,所以没有匹配结果。

(ab)? 允许分组出现零次或一次,因此即使没有匹配到 ab,也可以在字符串开头得到一个空匹配对象。这个匹配对象存在,但它匹配到的内容是空字符串。

这说明,判断是否找到匹配对象与判断匹配到的内容是否为空,是两个不同的问题。

写在最后

通过本文,我们学习了正则表达式中的分组、组量词和捕获分组。

需要记住以下知识:

  • 圆括号 () 可以将一段正则表达式组合成一个整体。
  • 量词默认作用于它前面的元素;使用括号可以让量词作用于整个分组。
  • (ab)+ 表示 ab 这个整体连续出现一次或多次,而 ab+ 表示 a 后面跟着一个或多个 b。
  • re.search() 可以只匹配字符串中的一部分,因此 re.search(r'(ab)+', 'aba') 得到的是 ab,不是整个 aba。
  • None 表示没有匹配对象,'' 表示匹配成功但结果为空字符串,两者不能混为一谈。
  • 普通圆括号既可以组合表达式,也会创建捕获分组。
  • group(0) 返回整个匹配结果,group(1) 返回第一个捕获分组的内容。
  • 重复捕获分组时,普通捕获分组只保留最后一次捕获的内容,不会自动收集所有重复结果。

下一篇,我们将继续学习正则表达式中的捕获分组,了解如何使用分组编号提取不同字段,以及如何更灵活地处理匹配结果。