3. 正则表达式基础:元字符、量词、分组与捕获、贪婪与非贪婪模式、Python re模块实战

正则表达式,说白了就是一套用来描述文本模式的规则。我刚开始接触时也觉得它像天书,一堆符号看得人眼花。但后来我发现,只要你掌握了几个核心概念,这东西其实特别顺手。今天我们就把它拆开揉碎了讲清楚。

3.1 元字符:正则的“字母表”

元字符是正则表达式的基本构成单元。每个元字符都代表一类字符,而不是具体的某个字符。我个人习惯把它们分成几组来记:

元字符 含义 示例
. 匹配任意单个字符(除换行符) a.b 匹配 aaba1b
\d 匹配数字(0-9) \d\d 匹配 23
\w 匹配字母、数字、下划线 \w+ 匹配 hello_123
\s 匹配空白字符(空格、制表符、换行) a\sb 匹配 a b
^ 匹配字符串开头 ^hello 匹配以 hello 开头的字符串
$ 匹配字符串结尾 world$ 匹配以 world 结尾的字符串
[] 字符集,匹配括号内任意一个字符 [aeiou] 匹配任意元音字母
[^] 否定字符集,匹配不在括号内的字符 [^0-9] 匹配非数字字符
我的小技巧: 在写正则时,我习惯先用 . 占位,再逐步替换成更精确的元字符。这样调试起来快很多。

3.2 量词:控制重复次数

光有元字符还不够,你还需要告诉正则“这个字符要出现几次”。量词就是干这个的。我记得有一次在解析日志文件时,就是因为量词用错了,导致匹配结果全乱套了。

量词 含义 示例
* 匹配前一个字符 0 次或多次 ab*c 匹配 acabcabbc
+ 匹配前一个字符 1 次或多次 ab+c 匹配 abcabbc,但不匹配 ac
? 匹配前一个字符 0 次或 1 次 ab?c 匹配 acabc
{n} 匹配前一个字符恰好 n 次 \d{3} 匹配 123
{n,} 匹配前一个字符至少 n 次 \d{2,} 匹配 12123
{n,m} 匹配前一个字符 n 到 m 次 \d{2,4} 匹配 121231234
注意: 量词默认是贪婪的,它会尽可能多地匹配。这一点我们后面会详细讲。

3.3 分组与捕获:提取你想要的

分组用圆括号 () 表示。它有两个作用:一是把多个字符当成一个整体来应用量词,二是捕获匹配到的内容供后续使用。

举个例子,假设你要从日志中提取 IP 地址和端口号:

import re

log_line = "192.168.1.1:8080 - - [10/Oct/2023]"
pattern = r"(\d+\.\d+\.\d+\.\d+):(\d+)"
match = re.search(pattern, log_line)
if match:
    ip = match.group(1)   # 192.168.1.1
    port = match.group(2) # 8080
    print(f"IP: {ip}, Port: {port}")

这里 group(0) 返回整个匹配,group(1) 返回第一个括号里的内容,以此类推。我在项目中经常用这个技巧来快速提取结构化数据。

避坑指南: 我曾经在写一个日志解析脚本时,忘了给分组命名,结果代码维护起来特别痛苦。后来我改用 (?P<name>...) 命名分组,代码可读性瞬间提升。
# 命名分组示例
pattern = r"(?P<ip>\d+\.\d+\.\d+\.\d+):(?P<port>\d+)"
match = re.search(pattern, log_line)
print(match.group('ip'))   # 192.168.1.1
print(match.group('port')) # 8080

3.4 贪婪与非贪婪模式:匹配的“贪心”与“知足”

默认情况下,量词是贪婪的。它会尽可能多地匹配字符。但有时候,你希望它匹配得越少越好,这时候就要用非贪婪模式。

怎么切换?很简单,在量词后面加个 ? 就行。

模式 写法 匹配行为
贪婪 .* 尽可能多地匹配
非贪婪 .*? 尽可能少地匹配

举个例子,假设有字符串 "<div>hello</div><div>world</div>"

  • 贪婪模式 <div>.*</div> 会匹配整个字符串,因为它会一直匹配到最后一个 </div>
  • 非贪婪模式 <div>.*?</div> 只会匹配 <div>hello</div>,因为它找到第一个 </div> 就停了
我的经验: 在解析 HTML 或 XML 时,我几乎总是用非贪婪模式。否则很容易匹配到不该匹配的内容,导致解析结果出错。

3.5 Python re 模块实战

好了,理论讲完了,咱们来点实战。Python 的 re 模块提供了几个核心函数,我平时用得最多的是这四个:

函数 作用 返回值
re.search() 扫描整个字符串,返回第一个匹配 Match 对象或 None
re.match() 从字符串开头匹配 Match 对象或 None
re.findall() 返回所有匹配的列表 列表
re.sub() 替换匹配到的内容 替换后的字符串

来看一个完整的日志解析例子。假设你有这样的日志:

2023-10-10 10:23:45 ERROR [main] Connection timeout to 192.168.1.1:8080
2023-10-10 10:23:46 INFO  [worker] Retry attempt 1
2023-10-10 10:23:47 ERROR [main] Connection timeout to 192.168.1.2:9090

你想提取所有错误日志中的 IP 和端口:

import re

log_data = """
2023-10-10 10:23:45 ERROR [main] Connection timeout to 192.168.1.1:8080
2023-10-10 10:23:46 INFO  [worker] Retry attempt 1
2023-10-10 10:23:47 ERROR [main] Connection timeout to 192.168.1.2:9090
"""

# 只匹配 ERROR 级别的日志
pattern = r"ERROR.*?(\d+\.\d+\.\d+\.\d+):(\d+)"
matches = re.findall(pattern, log_data)

for ip, port in matches:
    print(f"Failed connection: {ip}:{port}")

输出结果:

Failed connection: 192.168.1.1:8080
Failed connection: 192.168.1.2:9090
避坑指南: 我曾经在写一个日志监控脚本时,直接用 re.match() 去匹配多行日志,结果死活匹配不上。后来才发现 re.match() 只从开头匹配,而我的日志每行开头都有时间戳。换成 re.search() 就搞定了。

另外,如果你要处理大量日志,建议先编译正则表达式:

# 编译正则,提高性能
pattern = re.compile(r"ERROR.*?(\d+\.\d+\.\d+\.\d+):(\d+)")
matches = pattern.findall(log_data)

嗯,这里要注意:编译后的正则对象可以重复使用,在循环中性能提升很明显。我个人习惯在脚本开头就把所有正则都编译好,后面直接调用。

好了,正则表达式的基础就讲到这里。下一章我们会把这些知识用到实际的日志分析框架中,到时候你会看到这些技巧有多实用。