或与优先级:| 的坑、分支顺序影响结果

|:优先级最低的连接词

竖线 | 表示"左右任选其一",它是正则里的"或"。但它有一条铁律必须刻进肌肉记忆:| 的优先级是所有运算符里最低的,它会把左右两边能切多远切多远,直到整个表达式的边界或括号为止。换句话说,^ab|cd$ 读作"(^ab)或(cd$)",而不是"^ 作用于 ab|cd 后再接 $"。

import re
# 本意:整串必须是 ab 或 cd —— 结果两条分支各自带锚
print(bool(re.search(r"^ab|cd$", "abx")))
print(bool(re.search(r"^ab|cd$", "xxcd")))
# 输出:True(^ab 命中了 abx 的开头)
# 输出:True(cd$ 命中了 xxcd 的结尾)

两条"校验"全被绕过,因为锚点只各自管住了自己所在的分支。正确写法是用括号把分支包成一个整体:

import re
print(bool(re.search(r"^(?:ab|cd)$", "abx")))
print(bool(re.search(r"^(?:ab|cd)$", "ab")))
# 输出:False
# 输出:True

记住这条装配口诀:先括号成组,再锚点封口。凡是"整串必须匹配若干选项之一"的校验,^(?:...|...)$ 是标准骨架。

分支顺序:多数引擎"先到先得"

第二个深坑:JavaScript、Python、Java、PCRE 都采用"最左优先"(leftmost-first)策略——引擎按分支书写顺序逐个尝试,第一个能匹配成功的分支立刻胜出,哪怕后面有分支能匹配得更长。这会直接影响提取结果:

import re
# 短分支在前:category 被拦腰截断
print(re.findall(r"cat|category", "这是 category 与 cat"))
# 输出:['cat', 'cat']
# 长分支在前:完整匹配
print(re.findall(r"category|cat", "这是 category 与 cat"))
# 输出:['category', 'cat']

同一组选项,仅仅调换顺序,结果从"残缺"变"完整"。防御手法有二:把长分支写在前(或按长度降序排列),或者用边界断言锁定结尾 cat\b——后者语义更明确:你要的就是整词。

顺带一提,POSIX 标准的引擎(如 grep 默认引擎)采用"最左最长"(leftmost-longest)策略,哪个分支匹配得长就选哪个,书写顺序不影响结果。这是方言差异,别把 JS 的习惯带进 grep,反之亦然。

一个能自己跑的顺序陷阱实验

"cat|category 吃亏"听着像玩具案例?换成真实技术名词,同一个陷阱立刻现形。实验素材:文本里只出现一个 JavaScript,两种分支写法,输出对比:

import re
print(re.findall(r"JavaScript|Java", "JavaScript"))
# 输出:['JavaScript']   ← 长分支在前:完整命中
print(re.findall(r"Java|JavaScript", "JavaScript"))
# 输出:['Java']         ← 短分支在前:被拦腰截断

第二行的 Java 分支排在前面,引擎在 Java 四个字符处尝试成功就收工——Script 四个字母留在原地没人管。如果你的下游逻辑是"命中 JavaScript 就跳过、命中 Java 就替换为 Kotlin",顺序错了会把每一处 JavaScript 都改坏成 KotlinScript。

修复手段有两个,适用场景不同。一是重排分支,长的在前,最省事;二是否定先行封口,让短分支主动让路:

import re
print(re.findall(r"Java(?!Script)", "JavaScript"))
# 输出:[]

Java(?!Script) 在 JavaScript 里试到 Java 后张望发现后面跟着 Script,立即放弃——就算它排在长分支前面也不会误伤。什么时候用第二种?分支来自用户配置或数据库、你无法控制书写顺序时,封口断言是唯一保险;自己手写的静态正则,直接重排就好。顺手记一个同类陷阱清单:win|windows、int|integer、test|testing——凡是英文里"短词是长词前缀"的词对,都踩同一个坑。

与量词、括号的组合规则

把优先级从高到低排个队,排查歧义时按这个顺序"断句":

优先级 元素 例子 直觉
1 括号/分组 (ab) 牢不可破的原子
2 量词 a+ 只粘住紧邻单元
3 连接(并列书写) abc 依次排列
4 或 | a|b 切到最远才罢休

用这套优先级读 https?://[\w.-]+|www\.\S+:第一分支是"http 加可选 s 加 :// 加域名",第二分支是"www. 加非空白"——两个完整方案并列,中间没有任何粘连。而 ab*|c 意味着"若干个 b 前面有个 a"或"c",* 只属于 b。读别人正则时先按这张表断句,一半的"看不懂"会当场消失。

实战:协议头识别器

提取文本里的 URL 开头,兼容 http/https/ftp 三种协议,顺带处理"裸 www"形态:

import re
text = "官网 https://shop.example.com 与 http://old.example.com,另有 www.demo.cn"
print(re.findall(r"(?:https?|ftp)://[\w.-]+|www\.[\w.-]+", text))
# 输出:['https://shop.example.com', 'http://old.example.com', 'www.demo.cn']

三个细节都是本章知识点:(?:https?|ftp) 用非捕获组约束 | 的势力范围;s? 的量词只作用于 s;分支顺序无所谓,因为三个选项互不为前缀。分支互不为前缀时顺序自由,存在前缀关系时必须长的在前——这是检查分支顺序的一条快速自查法。

经验之谈:分支数量超过三个时,建议按"概率从高到低"重排并加注释(配合下一章的 verbose 模式)。理由有二:常见输入走前面的分支,平均匹配开销更低;后人在维护时,一眼能看到主路径是什么、兜底路径是什么,这比性能收益更值钱。

两个高频应用:白名单与分支顺序自查

应用一:扩展名白名单。上传校验的骨架,分支 + 结尾锚:

import re
ALLOWED = re.compile(r"\.(?:png|jpe?g|gif)$")
files = ["a.png", "b.bmp", "c.gif", "d.jpg", "e.jpeg"]
print([f for f in files if ALLOWED.search(f)])
# 输出:['a.png', 'c.gif', 'd.jpg', 'e.jpeg']

jpe?g 用可选量词把 jpg/jpeg 两个分支合成一个,$ 钉死结尾——.pngx 这类尾巴被天然拒绝。注意 bmp 不在名单里被排除,e.jpeg 被 jpe?g 正确收编。

应用二:分支顺序自查法。判断现有分支要不要排序,只需回答一个问题:任何一个分支是否是另一个分支的前缀? 是,就必须长的在前(或加 \b/$ 锁边);否,顺序随意。cat|category 回答"是"所以要修;https?|ftp 回答"否"所以安全。把这个判断养成习惯,分支顺序 bug 会从你的代码里绝迹。

自测一题:^a|ab$ 能匹配 "xab" 吗?(能——ab$ 分支命中结尾,两个分支各自带锚正是 | 低优先级的体现;想表达"以 a 开头且以 ab 结尾",必须用括号把分支包进整体或拆成两次判断。)

两个高频疑问

问:| 能嵌套吗? 能,(a|b(c|d)) 完全合法,内层 | 的势力范围由括号决定。但嵌套分支的可读性衰减极快,两层以上就该考虑拆分或改写为字符组——单字符的多选一律降级为 [...],是分支瘦身的第一手段。

问:分支顺序影响性能吗? 影响,且影响方式很直观:最左优先策略下,每个不命中的分支都是一次完整的尝试。把命中率最高的分支放最前、把字符组能表达的合并成字符组,两条措施能让高频路径的开销显著下降。日志、网关这类高频匹配场景,值得一做。

import re
print(re.findall(r"(?:error|warn|info)[:-]", "ERROR:1 info:2 warn:3", re.I))
# 输出:['ERROR:', 'info:', 'warn:']

三个分支互不为前缀,顺序无关紧要;[:-] 的后缀锚定让匹配落在完整字段上。若实际日志里 info 占九成,把它的分支提到最前就是白捡的性能——分支排序永远要对齐真实数据分布,而不是按字母序偷懒。

参考资源

本章由 WorkBuddy 原创撰写,示例均经实际验证。