正则写错从来不报错 —— 它只是安静地匹配到不该匹配的东西。下面这 5 个坑,共同点是「看起来对、跑起来错」。
坑 1:\b 在符号之间并不存在
这是最反直觉的一个。
\b 表示「词边界」,它的定义是:一侧是字类字符(\w:字母、数字、下划线),另一侧不是。关键在于 —— 两个符号之间不存在边界。
我解析 ls -l 风格的输出时想匹配权限字段,于是写了:
m = re.match(r'^([-dl][rwx-]{9})\b\s+(.+)$', line)
# ↑ 这里永远不会匹配成功
因为 -rw-r--r-- 的结尾是 -(非字类字符),紧跟着的又是空格(非字类字符)—— \b 要求的「一侧是字类字符」根本不成立。
解法:改用前瞻断言,把「后面是空白或行尾」明确写出来:
m = re.match(r'^([-dl][rwx-]{9})(?=\s|$)\s+(.+)$', line)
(?=\s|$) 是零宽断言:只要求「后面是空白或行尾」,不消耗任何字符。
教训:\b 表达的是「字类与非字类的交界」,不是「符号串结束了」。当你处理的是由符号组成的字段(权限、哈希、版本号、ID、路径),用 (?=...) 而不是 \b。
坑 2:字符类里的 - 是个位置敏感的字符
在 [...] 内部,- 只有放在首或尾才是字面量,放在中间表示范围:
r'[a-z]' # a 到 z 的范围
r'[-az]' # 字面量 -、a、z
r'[az-]' # 同上:- 在末尾
r'[a\-z]' # 显式转义,也是字面量
r'[a-z-]' # 范围 a-z,外加字面量 -
真正危险的是 [\w-] 这种写法:它在 Python 里能跑,但意图模糊 —— 后人想加个字符时很容易改错位置,把一个字面量变成范围。
习惯写法:把 - 固定在末尾,或者显式转义。
坑 3:贪婪匹配的「吃过头」
.* 是贪婪的:它先一路吃到行尾,再往回退。用「两个分隔符之间的内容」时,它会匹配到最后一个分隔符:
s = 'a=1; b=2; c=3'
re.search(r'a=(.*);', s).group(1) # '1; b=2' ← 吃过头了
两种修法,推荐后一种:
re.search(r'a=(.*?);', s).group(1) # 非贪婪:'1'
re.search(r'a=([^;]*);', s).group(1) # 排除式:'1'
[^;]* 通常比 .*? 更好:它不需要回溯,在长文本上性能差异明显,而且意图明确 —— 「一直吃到遇到分隔符为止」。
经验法则:能用「排除式字符类」就別用「懒惰量词」。
坑 4:把用户输入直接拼进模式
pattern = r'^' + user_input + r'$' # 危险
如果输入里含有 .、*、(、| 等元字符,轻则匹配错误,重则构造出灾难性回溯的模式(ReDoS)—— 一段几十字符的输入就能让匹配耗时爆炸。
解法:
pattern = r'^' + re.escape(user_input) + r'$'
更硬的规则:只要模式里带变量,就先假设它是不可信输入。能用 str.startswith / in / == 表达的,就不要用正则。
坑 5:\w、\d、. 的默认语义和直觉不一样
三个反直觉点:
| 写法 | 直觉 | 实际(Python 3 默认) |
|---|---|---|
\w | 英文字母、数字、下划线 | 包含中文等 Unicode 字母 |
\d | 0-9 | 包含全角数字等其它数字字符 |
. | 任意字符 | 不匹配换行符 |
后果举例:用 \w+ 提取「用户名」,会把中文一起吞进去;用 . 跨行匹配,永远失败。
解法:把语义显式写出来。
re.findall(r'\w+', text, re.ASCII) # 退回 ASCII 语义
re.DOTALL # 让 . 也匹配换行
re.MULTILINE # 让 ^ $ 匹配每一行的首尾
习惯:用到 ^ $ 就明确是否要 re.MULTILINE;用到 . 就明确是否要 re.DOTALL。默认值经常不是你想要的。
附:调试正则的三个小技巧
- 小步验证。不要一次写完整个模式。先写「能匹配到那一行」,再逐段往两边加,每一步都验证。
- 把命中内容包起来看边界。
findall只给你结果,看不出边界对不对:
for m in re.finditer(pattern, text):
print('<%s>' % m.group(0))
两侧的 <> 会暴露多余的空格和符号。
- 失败时先打印输入原文。模式返回 0 条结果时,先用
repr(text)打出输入 —— 相当一部分「正则不工作」其实是输入里有多余空格、不可见字符或编码问题,和正则没关系。
小结
正则的难点不在语法,而在边界:\b 在哪里成立、贪婪匹配在哪里停下、\w 到底包含什么。
遇到诡异结果时,先回到一个问题:这个位置到底有没有我假设的那个边界?
把假设写成显式断言((?=...)、[^;]、re.ASCII),比记住更多语法管用。