正则表达式前瞻

使用 (?=...) 和 (?!...) 根据后续模式进行匹配的正则表达式语法,不消耗字符。

正则表达式前瞻 (lookahead) 是一种在不消耗字符的情况下检查后续是否匹配特定模式的语法。肯定前瞻 (?=...) 在指定模式匹配时成立,否定前瞻 (?!...) 在指定模式不匹配时成立。前瞻是零宽断言 (zero-width assertion),只检查条件而不推进匹配位置,这是它与普通模式的区别。

用具体例子说明前瞻的基本行为:\d+(?=元) 匹配"100元"中的"100",但"元"本身不包含在匹配结果里,因为前瞻不"消耗"字符串。否定前瞻的例子:\d+(?!元) 匹配"100美元"中的"100"。不过把它用在"100元"上,并不是什么都匹配不到。引擎会回退并返回"10" ("10"的下一个字符是"0",而不是作为条件的"元",于是条件成立)。如果想以整个数字串为单位来判断,就要像 \d+(?!\d|元) 这样把回退的退路堵住。

前瞻的代表性应用场景之一是密码的条件验证。通过组合多个前瞻,可以在一个正则表达式中同时检查"包含字母""包含数字""包含特殊字符""至少 8 个字符"等条件。例如,^(?=.*[A-Za-z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$ 匹配满足所有这些条件的密码。

不过,把这个写法原样照搬,会拒掉比预想更多的密码。由于末尾的 [A-Za-z\d@$!%*?&]{8,} 是在逐一列举可以使用的字符,含有连字符的 correct-horse-9! 会不合格。符号的条件也只限于那 7 种,因此 Passw0rd# 同样通不过。如果只想看字符种类是否齐备,那么末尾取宽一些写成 .{8,},第三个前瞻也写成 (?=.*[^A-Za-z\d]) 这样"包含 1 个以上的非字母数字字符"的形式,更符合本意。

JavaScript 从 ES2018 开始也支持后顾断言 (lookbehind)。肯定后顾 (?<=...) 在指定模式出现在前方时匹配,否定后顾 (?<!...) 在指定模式不在前方时匹配。例如,(?<=\$)\d+ 匹配"$100"中的"100",但不匹配单独的"100"。

将前瞻和后顾组合使用,可以精确提取特定上下文中的字符串。但需注意,前瞻与后顾并不是所有正则表达式共通的功能。以在有限自动机上执行为前提的 RE2 系引擎 (如 Go 的 regexp) 在设计上就不具备它们,因此同一个模式无法原样移植。这种情况下要改写成不使用前瞻的形式,或者把匹配分成多次进行。此外,在前瞻内部叠加量词的模式容易使回溯膨胀,用在外部给来的字符串上时存在响应回不来的危险 (ReDoS)。

在字符计数方面,前瞻可以定位满足特定条件的字符串位置,用于文本结构分析。例如,在与 \d(?=(\d{3})+$) 匹配到的位数之后插入逗号,"1234567"就会变成"1,234,567"。因为前瞻不消耗字符,所以只做分隔位置的判定,位数本身的字符仍会留在结果中。

分享这篇文章