什么是零宽断言
零宽断言(Zero-width Assertion)是一种只检测位置,不消耗字符的正则元素。它像一道"门禁":只有满足特定条件的位置才能通过,但断言本身不包含在匹配结果中。
零宽(Zero-width)
零宽断言匹配的是字符串中的一个位置,不消耗任何字符。匹配成功后,引擎的指针不向前移动。类似的零宽元素还有
^、$、\b。前瞻(Lookahead)
检测当前位置右边(前方)的内容是否满足条件。正向前瞻
(?=...):右边必须匹配;负向前瞻 (?!...):右边不能匹配。后顾(Lookbehind)
检测当前位置左边(后方)的内容是否满足条件。正向后顾
(?<=...):左边必须匹配;负向后顾 (?<!...):左边不能匹配。| 语法 | 类型 | 含义 |
|---|---|---|
(?=X) | 正向前瞻 | 当前位置右边必须能匹配 X |
(?!X) | 负向前瞻 | 当前位置右边不能匹配 X |
(?<=X) | 正向后顾 | 当前位置左边必须能匹配 X |
(?<!X) | 负向后顾 | 当前位置左边不能匹配 X |
正向前瞻 (?=...)
正向前瞻断言:当前位置右侧必须能匹配指定模式,但这部分不计入匹配结果。
/\d+(?= dollars)/g
I have 100 dollars and 200 euros
只匹配后面跟着 " dollars" 的数字,但 " dollars" 不在结果中
# 提取后面跟着 " dollars" 的数字 re.findall(r'\d+(?= dollars)', 'I have 100 dollars and 200 euros') # → ['100'] (200 后面是 euros,不匹配) # 在密码前面插入提示(不替换内容本身) re.sub(r'(?=\d)', '#', 'abc123') # → 'abc#1#2#3' (在每个数字前插入 #) # 数字格式化:每3位加一个逗号 def format_number(n): s = str(n) # 用前瞻找到后面有3的倍数个数字的位置 return re.sub(r'(?<=\d)(?=(\d{3})+$)', ',', s) format_number(1234567) # → '1,234,567' format_number(12345) # → '12,345'
负向前瞻 (?!...)
负向前瞻:当前位置右侧不能匹配指定模式。
/\bcat(?!s\b)/g
cat cats catch
匹配 cat,但不是后跟 "s" 的 cats;注意 catch 也匹配因为后跟 ch
# 匹配 "cat" 但不是 "cats" re.findall(r'\bcat(?!s\b)', 'cat cats catch') # → ['cat', 'cat'] ('cat' 单词 和 'catch' 中的 'cat') # 精确匹配完整单词 "cat"(不是 cats, catch 等) re.findall(r'\bcat\b', 'cat cats catch') # → ['cat'] (更简单的写法) # 提取不以 "Error" 结尾的日志行 text = """INFO: System started ERROR: File not found INFO: Processing done WARNING: Low memory""" re.findall(r'^(?!.*Error).*$', text, re.MULTILINE | re.IGNORECASE) # 找出不含 "Error" 的行(负向前瞻应用于整行过滤) # 匹配不是 HTTP/HTTPS 的链接 re.findall(r'(?!https?://)\b\w+://\S+', 'ftp://example.com http://test.com') # → ['ftp://example.com']
正向后顾 (?<=...)
正向后顾:当前位置左边必须能匹配指定模式。Python 的 re 模块要求后顾中的模式是固定长度的(不能有 *、+、? 等变长量词)。
/(?<=\$)\d+/g
Price: $100 and €200
匹配 $ 后面的数字,但 $ 不计入匹配结果
# 提取 $ 后的数字(不包含 $) re.findall(r'(?<=\$)\d+(\.\d{2})?', 'Price: $100, Sale: $9.99') # → [('',), ('.99',)] 有捕获组,注意返回的是元组 # 更清晰的写法:只用后顾,不加捕获组 re.findall(r'(?<=\$)\d+(?:\.\d{2})?', 'Price: $100, Sale: $9.99') # → ['100', '9.99'] # 提取 = 后面的值(如配置文件) config = "host=localhost\nport=5432\nuser=admin" re.findall(r'(?<==)\S+', config) # → ['localhost', '5432', 'admin'] # 提取 HTML 标签之间的文字 re.findall(r'(?<=>)[^<]+', '<h1>Title</h1><p>Content</p>') # → ['Title', 'Content']
负向后顾 (?
负向后顾:当前位置左边不能匹配指定模式。
# 匹配不在 "anti" 后面的 "virus" re.findall(r'(?<!anti)virus', 'antivirus and new virus strain') # → ['virus'] (antivirus 中的 virus 被排除) # 匹配非负数(不以 - 开头的数字) re.findall(r'(?<!-)\b\d+', 'values: -5, 10, -3, 42') # → ['5', '10', '3', '42'] ← 注意:-5 中的 5 被排除,但 - 左边没有 - 的数字保留 # 实际上 '-' 后的 \b 边界会被匹配,更好的方式: re.findall(r'(?<!-)\b\d+', 'values: -5, 10, -3, 42') # → ['10', '42'] (-5 中,\b 在 '-' 和 '5' 之间,(?<!-) 要求左边不是 -,失败)
INFO(后顾的长度限制)Python 的
re 模块要求后顾断言((?<=...) 和 (?<!...))的内容必须是固定长度的。例如 (?<=\d{2}) 可以(固定2位),但 (?<=\d+) 会报错(变长)。Python 的第三方 regex 库和 JavaScript 没有这个限制。
四种断言综合对比
# 目标:从 "100px 200em 300%" 中只提取后面是 "px" 的数字 text = '100px 200em 300%' # 方法1:正向前瞻(常规方法) re.findall(r'\d+(?=px)', text) # → ['100'] # 方法2:负向前瞻(排除法) re.findall(r'\d+(?!px|em|%)', text) # 这里不能精确工作,因为 100px 中 1 前3位也不是单位 # 实际应用组合 # 提取冒号后面、但不在引号内的值(用否定后顾) # 密码强度验证(用多个前瞻) STRONG_PWD = re.compile( r'^' r'(?=.*[A-Z])' # 至少一个大写 r'(?=.*[a-z])' # 至少一个小写 r'(?=.*\d)' # 至少一个数字 r'(?=.*[!@#$%])' # 至少一个特殊字符 r'.{8,}' # 总长度至少8位 r'$' ) STRONG_PWD.match('Abc123!@') # 匹配:满足所有条件 STRONG_PWD.match('password') # None:无大写、数字、特殊字符 STRONG_PWD.match('Pass123') # None:无特殊字符且长度为7
前瞻的工作原理(引擎视角)
了解引擎如何处理前瞻,有助于理解为什么前瞻不消耗字符:
INFO(引擎视角)当引擎到达前瞻位置时,它会"暂时跳到右侧"做一次子匹配尝试:如果子匹配成功(正向前瞻)或失败(负向前瞻),则断言成立,引擎指针不移动,继续处理主模式的下一部分。这就是"零宽"的含义——前瞻检测位置但不消耗字符。
# 可视化前瞻的零宽特性 # 模式:/q(?=u)/ 字符串:"queen" # # 引擎尝试位置0:q # 'q' 匹配 'q' ✓ 指针移到位置1 # (?=u) 前瞻:临时检查位置1是否是 'u' # 'u' == 'u' ✓ 前瞻成功 # 指针回到位置1(不消耗 'u') # 整个匹配:'q'(只有 q,没有 u!) re.findall(r'q(?=u)', 'queen quite') # → ['q', 'q'] (只匹配 q,u 不在结果中) re.findall(r'q(?=u)u', 'queen quite') # → ['qu', 'qu'] (前瞻成功后,主模式继续匹配 u)
实战:不替换内容的插入操作
零宽断言在"在特定位置插入内容"的场景中非常有用:
# 数字格式化:1234567 → 1,234,567 def add_thousands_sep(n: int) -> str: s = str(abs(n)) # 找到每个"其右侧有3的倍数个数字"的位置,插入逗号 formatted = re.sub(r'(?<=\d)(?=(\d{3})+$)', ',', s) return ('-' if n < 0 else '') + formatted add_thousands_sep(1234567) # → '1,234,567' add_thousands_sep(-9876543) # → '-9,876,543' add_thousands_sep(999) # → '999' # 在驼峰命名的单词边界插入空格 re.sub(r'(?<=[a-z])(?=[A-Z])', ' ', 'helloWorld getUserName') # → 'hello World get User Name' # 在不以 \n 结尾的行末添加换行 re.sub(r'(?<!\\n)$', r'\\n', 'line1', flags=re.MULTILINE)
可变长后顾:Python regex 库与 JavaScript
Python 的标准 re 模块要求后顾断言必须是固定长度,但这个限制在其他引擎中不存在:
WARNING(后顾的长度限制与跨引擎差异)Python
re 模块的后顾断言((?<=...) 和 (?<!...))要求内容为固定长度。(?<=\d{2,3}) 会报 error: look-behind requires fixed width pattern。JavaScript(无限制)、PCRE2(支持变长后顾)和 Python 的第三方 regex 库则没有此限制。如果你的模式需要在可变长上下文中做后顾匹配,切换引擎或改写逻辑。
# Python re 模块:固定长度后顾 → 正常 re.findall(r'(?<=USD)\d+', 'USD100 EUR200') # → ['100'] (USD 是 3 字符,固定长度) # Python re 模块:变长后顾 → 报错 # re.findall(r'(?<=USD?)\d+', 'USD100') # error! # ✅ 解决方案1:用第三方 regex 库 import regex regex.findall(r'(?<=\w{2,5}:)\d+', 'ab:100 longkey:200') # → ['100', '200'] (变长后顾,regex 库支持) // JavaScript(ES2018+)无长度限制 'abc123 de456'.match(/(?<=[a-z]{2,4})\d+/g) // → ['123', '456'] (变长后顾正常工作)
嵌套断言与复杂组合
断言可以嵌套或组合,用于构建精细的匹配条件:
# 组合多个断言:必须同时满足"前面有 $"和"后面有 .00" re.findall(r'(?<=\$)\d+(?=\.00)', '$100.00 $99.99 $50.00') # → ['100', '50'] (后面不是 .00 的 99 不匹配) # 负向前瞻与正向后顾组合: # 匹配 http:// 开头但不是 https:// 的 URL re.findall(r'http(?!s)://\S+', 'http://a.com https://b.com') # → ['http://a.com'] # 实战:提取 Python 函数名(def 后面的标识符,不是 def 本身) code = """ def calculate(x, y): pass def validate_input(data): pass """ re.findall(r'(?<=def )\w+', code) # → ['calculate', 'validate_input'] # 前瞻嵌套:不跟着逗号或句号的单词 re.findall(r'\b\w+(?![,.])\b', 'hello, world. foo bar') # → ['hello', 'world', 'foo', 'bar'] (注意:边界行为取决于具体位置)
WARNING(断言不参与捕获)零宽断言内部的内容即使包含捕获组,也不会被
findall 的捕获逻辑影响——断言只检测位置,其内容不属于匹配结果。例如 (?=(\d{3})) 中的 (\d{3}) 技术上是一个捕获组,但因为在前瞻内部,findall 会返回它,导致意外行为。最佳实践:断言内部使用非捕获组 (?:...)。
小结
本章要点
- 零宽断言只检测位置,不消耗字符——匹配结果不包含断言部分
- 正向前瞻
(?=X):右边必须匹配 X;负向前瞻(?!X):右边不能匹配 X - 正向后顾
(?<=X):左边必须匹配 X;负向后顾(?<!X):左边不能匹配 X - Python
re模块要求后顾内容是固定长度;JavaScript 和 Pythonregex库无此限制 - 多个前瞻可以叠加使用,常用于"必须同时满足多个条件"的密码验证
- 断言内部建议使用非捕获组
(?:...),避免意外捕获行为 - 前瞻在"无需替换、只需在某位置插入"时特别有用(如数字格式化)