零宽断言:先行 (?=) (?!)、后行 (?<=) (?<!),环视不是"看字符"

位置也是一种匹配

上一章的锚点告诉你:有四个符号只报告位置、不消费字符。环视断言(lookaround)是这一思想的完全体——它站在当前位置朝某个方向"张望",确认邻区内容符合要求后原地不动。匹配成功时它一个字符都没吃,所以常被称为零宽断言。

四种环视,方向与正反两个维度组成 2x2 矩阵:

写法 名称 含义
(?=...) 正向先行 右边必须匹配 ...
(?!...) 否定先行 右边必须不匹配 ...
(?<=...) 正向后行 左边必须匹配 ...
(?<!...) 否定后行 左边必须不匹配 ...

记忆口诀:小于号朝左看,感叹号取反。(?=) 和 (?!) 是一对,(?<=) 和 (?<!) 是一对。

先行断言:条件写在字符前面

密码强度是先行断言的招牌场景。"至少 8 位、必须同时含数字和小写字母"这类多个并列条件,用一排先行断言写出来最清晰:

import re
# 规则:至少 8 位,且同时包含数字与小写字母
pattern = r"^(?=.*\d)(?=.*[a-z]).{8,}$"
print(bool(re.search(pattern, "pass2024word")))
print(bool(re.search(pattern, "password没有数字")))
# 输出:True
# 输出:False

拆开读:(?=.*\d) 说"从开头张望,后面某处有数字";(?=.*[a-z]) 说"有小写字母";两条检查都通过后,.{8,} 才真正消费字符。每个 (?=...) 都是一个独立的资格检查,彼此不抢地盘——这正是环视表达"同时满足"的精髓。

注意第二个示例是纯 a-z 加汉字的串,没有任何数字,两条先行断言里第一条就把它拦下了。

后行断言:条件写在字符后面

想匹配"紧跟在人民币符号后的金额",后行断言从字符的左侧把关:

import re
receipt = "商品价 ¥128 优惠 30 运费 ¥8 押金 200"
print(re.findall(r"(?<=¥)\d+", receipt))
# 输出:['128', '8']

\d+ 只负责吃数字,(?<=¥) 负责确认数字前面贴着 ¥。不用环视也能做——捕获组 (¥)(\d+) 再取第二组——但环视让"匹配结果"直接就是干净的目标值,代码少一层解包。

否定后行常用来排除紧跟特定前缀的情况,比如统计温度时跳过负数:

import re
print(re.findall(r"(?<!-)\b\d+\b", "白天 28 度,夜里最低 -3 度"))
# 输出:['28']

拆解一下:-3 里 - 与 3 之间存在一个单词边界,引擎在这个缝隙尝试 \d+ 时,(?<!-) 检查缝隙左侧,发现贴着负号,立即否决——3 就这样被拦在门外,而前面空格后的 28 顺利入选。环视检查的是"断言所在位置的邻居",与 \b 这类零宽锚点叠加使用时,把两者分开逐一推演,才不会晕头转向。

后行断言有一个著名限制:Python 的 re 模块要求后行内容定长,因为引擎需要先向左退固定步数:

import re
try:
    re.compile(r"(?<=ab|abc)\d")
except re.error as e:
    print(e)
# 输出:look-behind requires fixed-width pattern

ab 两步、abc 三步,长度不一致直接编译失败。JavaScript 的后行断言允许变长,Go 的 RE2 干脆不支持——同一语法,三种方言三种命运,迁移前务必查目标语言文档。

环视不是"看字符",而是"看位置"

初学者最大的误解是把 (?=) 想象成"读取并吃掉右边字符"。请反复自测这条性质:

import re
# 在每个大写字母前插入下划线:环视只定位,不消费
print(re.sub(r"(?=[A-Z])", "_", "orderIDList"))
# 输出:order_I_D_List

(?=[A-Z]) 从不吃掉任何字母,只在大写字母前的每个缝隙上"亮灯",替换发生在缝隙处。有趣的是,用捕获组 ([A-Z]) 配替换 _\1 也能得到同样结果——但环视版在"只需要位置"的组合场景里更纯粹:它不改变匹配的边界,可以与任意后续模式自由叠加,这正是它在复杂表达式里不可替代的原因。

方言差异与铁律

  • Python re 模块:后行断言的内容必须定长,(?<=ab|abc) 直接报错;第三方 regex 模块支持变长。
  • JavaScript:ES2018 起支持后行断言,且允许变长;老浏览器环境不可用。
  • Go RE2:四种环视全不支持,语法直接编译失败。

避坑提醒:把 PCRE 正则原样搬进 Go 项目是真实事故的高发区——(?=) 在 RE2 里是语法错误,而不是"悄悄不支持"。跨语言迁移前,先在目标语言的测试里跑一遍样本集(第五章会系统讲),别相信"看起来一样"。

两个安全场景配方

配方一:强密码校验(四类字符 + 长度下限)。注册页的规则"至少 12 位,同时含大写、小写、数字、特殊符号",用一排先行断言写成分级清单:

import re
pw = r"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*]).{12,}$"
for t in ["Abcdef123456!", "abcdef123456!", "Ab1!Ab1!Ab1!"]:
    print(bool(re.search(pw, t)), t)
# 输出:True  Abcdef123456!
# 输出:False abcdef123456!   ← 缺大写,第一条断言否决
# 输出:True  Ab1!Ab1!Ab1!    ← 12 位压线通过

读法:三个 (?=...) 站在开头各查一类字符,谁也不消费;四项全过,.{12,} 才出手量长度。要加新规则(如"禁用三个连续相同字符")就再补一条断言 (?!.*(.)\1{2}),主结构不动——环视清单的增量演进能力,是它在校验场景不可替代的原因。运维侧把 .{12,} 的下限一改,就是策略收紧,正则其余部分原样保留。

配方二:日志审计——揪出未脱敏的敏感字段。合规扫描器每天要翻几百万行日志,找"密钥字段后面跟了明文值"的行;已按规范脱敏成 *** 开头的值要放行:

import re
audit = r"(?i)\b(pw|secret|token)\s*[:=]\s*(?!\*{3,})(\S+)"
for line in ['pw=abc123', "pw: ***", "token= eyJhbGci", "secret=***abc"]:
    m = re.search(audit, line)
    print(line, "->", m.group(0) if m else None)
# 输出:pw=abc123 -> pw=abc123            ← 明文,报警
# 输出:pw: *** -> None                   ← 已脱敏,放行
# 输出:token= eyJhbGci -> token= eyJhbGci ← JWT 明文,报警
# 输出:secret=***abc -> None             ← 脱敏前缀,放行

两个环视分工明确:(?i) 让大小写混写的日志(Password、SECRET)一个都漏不掉;(?!\*{3,}) 站在值的位置上,看见三连星就撤——"匹配违规"与"豁免合规"写在同一条表达式里,扫描循环里就不需要 if-else 二次过滤。第二组捕获 \S+ 顺手把违规值摘出来供工单引用。这两个配方背下来,环视的工程价值就落地了。

环视三连练

练习一:驼峰拆分。把 GetHttpResponse 拆成三个单词——在每个大写字母的位置"切一刀",环视正好是"定位不消费"的:

import re
print(re.findall(r"[A-Z][a-z]*", "GetHttpResponse"))
# 输出:['Get', 'Http', 'Response']

练习二:负向过滤。匹配后面不跟数字的字母组合 (?![a-z]) 类断言,写一条"匹配未被引用包住的单词",体会否定先行的"看见就放弃"。

练习三:密码分档。在上一章密码示例基础上加一档:12 位以上且含特殊符号。做法是把先行断言从一个加到四个——环视的并列组合天生支持"规则即清单"的增量演进。

三连的共性:环视从不改变"匹配了哪些字符",只改变"在哪些位置开灯"。练习一时请特别体会:正则引擎在 Http 的 H 处开灯、在 Response 的 R 处开灯,但每次开灯后真正消费字符的是 [A-Z][a-z]* 本体。定位与消费分工明确,环视就算真正学会了。

两个高频疑问

问:(?=) 里面能再嵌套环视吗? 语法上可以,(?=^(?=.*\d).*$) 这类嵌套也确实有真实用途(比如在行内多条件联查)。但可读性衰减极快,嵌套超过一层就该考虑拆成多次匹配或代码判断——这是环视工程化的通用红线。

问:环视和捕获组怎么选? 经验法则:要"取值"用捕获组,要"设条件"用环视。捕获组产出数据,环视产出约束;一个 (?=...) 能替代的往往是一段 if 判断,而一个 (...) 替代的是一次字符串切片。把两种工具的"产出物"想清楚,选型就不会纠结。

import re
print(re.findall(r"\d+(?=分)", "张三 90分 李四 85分 缺考"))
# 输出:['90', '85']

(?=分) 确保"只有跟着'分'字的数字才是分数"——缺考 没有数字、普通数字没有"分",都被正确排除。条件挂在上下文上,这正是环视的核心用法,练到不用思考就能写出它,本章的目的就达到了。

参考资源

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