Chapter 04 / 10

零宽断言(Lookaround)

用前瞻与后顾断言精确定位——匹配位置而非字符,不消耗任何文本

什么是零宽断言

断言(Assertion)是一种特殊的正则结构,它匹配的是位置而不是字符——就像 \b^ 一样,它不"消耗"任何字符。"零宽"(Zero-width)正是指它匹配空字符串(宽度为零)。

零宽断言分为四种:

语法类型含义
(?=...)正向前瞻后面紧跟着 ... 时匹配
(?!...)负向前瞻后面不跟 ... 时匹配
(?<=...)正向后顾前面紧跟着 ... 时匹配
(?<!...)负向后顾前面不跟 ... 时匹配

正向前瞻 (?=...)

"在满足某条件的位置匹配"。前瞻不包含在最终匹配结果中:

/\d+(?= dollars)/g
价格是 100 dollars,不是 200 euros
只匹配后面跟着 " dollars" 的数字,但 " dollars" 本身不在结果中
# 提取单位前的数字
re.findall(r'\d+(?=\s*(?:元|块|RMB))', '价格 99元,优惠价 88 块')
# → ['99', '88']

负向前瞻 (?!...)

"不在某条件的位置匹配":

/\bfoo(?!bar)\b/g
foo foobar foobaz foolish
匹配不后跟 "bar" 的 "foo"("foolish" 中的 foo 也匹配,因为后面是 "lish")
# 匹配不在引号内的逗号(简化示例)
re.findall(r',(?![^"]*")', text)

# 密码验证:包含数字但不是纯数字
re.match(r'^(?=.*\d)(?!^\d+$).{8,}$', password)

正向后顾 (?<=...)

"在某内容之后的位置匹配"。后顾同样不包含在最终结果中:

/(?<=\$)\d+/g
价格:$100,优惠:$80
只匹配 $ 符号后面的数字,结果不含 $
# 提取 HTML 属性值(简化)
re.findall(r'(?<=href=")[^"]+', html)

# 在特定前缀后提取内容
re.search(r'(?<=version:\s)\S+', 'version: 3.2.1').group()
# → '3.2.1'
WARNING大多数引擎要求后顾断言是固定长度的(不能包含 */+/?)。Python 的 re 模块有此限制;Python 3.x 的 regex 第三方库支持可变长度后顾。JavaScript 从 ES2018 起支持后顾断言,且支持可变长度。

负向后顾 (?

"不在某内容之后的位置匹配":

# 匹配不在小数点后的数字
re.findall(r'(?<!\.\d)\d+', '价格 100 元,折扣 0.85')

# 匹配不以 "un" 开头的单词
re.findall(r'(?<!un)\w+', text)

断言的组合使用

多个断言可以叠加在同一位置,实现复杂条件:

# 密码强度:6位以上,含大写字母,含数字,含特殊字符
pattern = r'^(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%]).{6,}$'

def check_password(pwd):
    return bool(re.match(pattern, pwd))

check_password('Abc123!')   # True
check_password('abc123!')   # False(无大写)
check_password('ABC123')    # False(无特殊字符)

零宽断言 vs 捕获组

捕获组 ()零宽断言 (?=...)
匹配字符是,包含在结果中否,只检查位置
消耗字符
可用量词是((abc)+)否(断言不可重复)

实用示例

# 千位分隔符:在数字中插入逗号
re.sub(r'(?<=\d)(?=(\d{3})+$)', ',', '1234567890')
# → '1,234,567,890'

# 提取引号内的内容(不含引号)
re.findall(r'(?<=")\w+(?=")', '"hello" and "world"')
# → ['hello', 'world']

# 找出 TODO 注释中的任务
re.findall(r'(?<=TODO:\s).+', code)

小结