Chapter 04 / 10

零宽断言(Lookaround)

前瞻(Lookahead)与后顾(Lookbehind)——精准控制匹配的上下文条件

什么是零宽断言

零宽断言(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 会返回它,导致意外行为。最佳实践:断言内部使用非捕获组 (?:...)

小结

本章要点