这里是 Mastering Lookahead and Lookbehind 文章的简单翻译,这篇文章是在自己搜索问题的时候 stackoverflow 上回答问题的人推荐的,看完觉得写得很不错.这里的简单翻译是指略去了一些 js 不具备的内容,再者原文实在是太长了,所以也去掉了一些没有实质内容的话,同时也加入了很多自己的理解.如果需要深入理解 js 的断言机制,还是推荐先去看完 MDN 的基础再去看这篇文章( http://www.rexegg.com/regex-lookarounds.html)效果会比较好 .
一开始是对零宽断言的简单概念介绍,略去.
先行断言例子:简单密码验证
密码需要满足四个条件:
6 到 10 个单字字符 \w
至少包含一个小写字母 [a-z]
至少包含三个大写字母 [A-Z]
至少包含一个数字 \d
最初的设想就是在字符串的开头先行检测四次,每次检测每个条件.
条件一
这里文章用 \A 匹配字符串开头,用 \z 匹配字符串结尾,和 js 不一样,改了一下
第一个条件很简单:^\w{6,10}$.加入先行断言:(?=^\w{6,10}$),先行断言:在字符串开头的位置后面,是 6 到 10 个字符,以及字符串的结尾.
(at the current position in the string, what follows is the beginning of the string, six to ten word characters, and the very end of the string. )
我们想在字符串的开头断言,因此需要用 ^ 做一个锚点定位,不需要重复声明开头,所以把 ^ 从断言中拿出来:
^(?=\w{6,10}$)
留意到,虽然我们已经用先行断言检测了整个字符串,但是我们的位置还没有变,正则验证锚点依然停留在字符串的开头位置,只是做了先行判断.意味着我们还可以继续检测整个字符串.
条件二
检测小写字母最容易想到的写法是 .*[a-z],但是这种写法 .* 一开始就会匹配到字符串的结尾,导致回溯,容易想到的写法是 .*?[a-z] 这会导致更多的回溯.推荐的写法是 [^a-z]*[a-z](当需要用到包含某些字符时,可以参考这种通用的写法),将条件加入先行断言:(?=[^a-z]*[a-z]) ,因此正则变成:
^(?=\w{6,10}$)(?=[^a-z]*[a-z])
断言里面依然没有匹配任何字符,两个断言的位置是可以互换的.
条件三
类似条件二:
(?=(?:[^A-Z]*[A-Z]){3})
正则变成了:
^(?=\w{6,10}$)(?=[^a-z]*[a-z])(?=(?:[^A-Z]*[A-Z]){3})
条件四
类似的:(?=\D*\d)
正则变成了:
^(?=\w{6,10}$)(?=[^a-z]*[a-z])(?=(?:[^A-Z]*[A-Z]){3})(?=\D*\d)
此时,我们在字符串开头断言,并先行检测了四次判读了四种条件,依然没有匹配任何字符,但是验证了密码.
匹配有效字符串
检查完毕后,正则检测的位置依然停留在字符串开头,可以用一个简单的.* 去匹配整个字符串,因为不管.* 匹配到了什么,都是经过验证的.因此:
^(?=\w{6,10}$)(?=[^a-z]*[a-z])(?=(?:[^A-Z]*[A-Z]){3})(?=\D*\d).*
微调:移除一个条件
检查这个正则里的先行断言,可以留意到 \ w{6,10}$ 这个表达式检查了字符串的所有字符,因此可以用他匹配整个字符串而不是用.*,因此可以减少一个先行判断简化正则:
^(?=[^a-z]*[a-z])(?=(?:[^A-Z]*[A-Z]){3})(?=\D*\d)\w{6,10}$
总结这个结果,如果检查 n 个条件,正则至多需要 n-1 个先行判断.甚至能够把几个先行判断合并.
实际上,除了 \ w{6,10}$ 刚好匹配了整个字符串外,其他的几个先行判断也可以通过改写匹配整个字符串,比如 (?=\D*\d) 可以加一个简单的.*$ 匹配到字符串结尾:
^(?=\w{6,10}$)(?=[^a-z]*[a-z])(?=(?:[^A-Z]*[A-Z]){3})\D*\d.*$
此外,为什么要在.* 后面加 $,难道不能匹配到字符串结尾么?因为点符号不匹配换行符(除非在 DOTALL mode 下,即点匹配所有),因此.* 只能匹配到第一行的末尾,如果有换行则无法匹配到,$ 保证了我们不仅到达一行的结尾,也到达了字符串的结尾.
在这个正则表达式里,开头的 (?=\w{6,10}$) 已经匹配到了结尾,所以后面的 $ 不是很必要.
先行断言的位置几乎没有影响
在这个例子里,因为三个先行断言都没有改变位置,所以可以互换.虽然结果没有影响,但是会影响性能,应该把容易验证失败的先行断言放在前面.
实际上,我们把 ^ 放在前面就是考虑了这个情况,因为 ^ 也没有匹配任何字符移动正则匹配锚点,他也可以和其他先行断言互换,但是这会带来问题.
首先,在 DOTALL mode 下,后行负向断言 (?<!.) 可以匹配开头,即前面没有任何字符,非 DOTALL mode 下,有 (?<![\D\d]) 匹配开头.
现在假设把 ^ 放在第四个位置,在三个先行断言后,这时如果第三个断言失效了,那么正则引擎会到第二个位置继续从第一个先行断言匹配,就这样不停地改变位置匹配直到全部位置都失败.虽然只要匹配到 ^ 就不会从其他位置继续判断,但是正则引擎因为提前失败而无法到达 ^.
放第一位时,除了开头位置外,其他位置在第一次匹配 ^ 就失败了,因此效率高些.
零宽断言没有改变位置
这里是一些初学者常犯的错误.
比如用 A(?=5) 匹配 AB25,不理解地方在于先行断言里的
5
是紧跟 A 后的位置,如果要匹配后面的位置,需要用 (?=[^5]*5).
用 A(?=5)(?=[A-Z]) 匹配 A5B,依然是位置不变问题,应该是用 A(?=5[A-Z])
零宽断言的用法
验证
即上面密码验证的例子,即一个字符串满足多个条件.每个条件都是检测整个字符串.
限制字符范围
比如匹配非 Q 字符外的单字字符 \ w.有几种写法:
字符减法,[\w-[Q]](js 不支持)
[_0-9a-zA-PR-Z]
[^\WQ]
先行断言写法:(?!Q)\w
在先行断言当前位置后面不是 Q 后,\w 匹配了一个字符.这个写法不仅容易理解,也容易附加拓展,比如不包含 Q 和 K,那么就是: (?![QK])\w` 后行断言: \w(?
Tempering the scope of a token 标志范围调整
限制标志 (token) 的匹配范围.
举个例子,如果想要匹配后面没有跟着 {END} 的任何字符,可以用:
(?:(?!{END}).)*
每一个. 标志都被 (?!{END}) 调整,断言点标志不能是 {END} 的开头,这个技巧叫
tempered greedy token
另外一种方案有点过于复杂,略去.
Delimiter 分隔符
在第一个 #START# 出现后匹配后面的所有字符写法:
(?<=#START#).*
或者匹配字符串的所有字符,除了 #END#
.*?(?=#END#)
两个断言可以合并:
( ? <=#START#). * ?( ? =#END#)
Inserting Text at a Position 在位置插入文本
给你一个文件,里面都是驼峰命名的电影标题,比如
HaroldAndKumarGoToWhiteCastle
,为了方便阅读,需要在大小写之间插入空格,下面的正则匹配这些位置:
(?<=[a-z])(?=[A-Z])
在编辑器的正则匹配查找中,可以用这个去匹配这些位置,并用空格代替.(这里能想到 /[a-z][A-Z]/g 同样能够查找,但是找到的不是位置,所以替换起来就不是那么方便了.
Splitting a String at a Position 在某位置分割字符串
类似上面的例子,就可以分割大小写之间的位置,在很多语言中,用 split 函数加上正则可以返回一个单词数组.
Finding Overlapping Matches 查找重叠匹配
有时候需要在同一个单词里做多次匹配,举个例子,想在 ABCD 中匹配 ABCD,BCD,CD 和 D,可以用:
(?=(\w+))
这个还蛮好理解的,会匹配四个位置,"","A",,"","B","","C","","D","".不过至于说怎么提取这四个部分,还没找到合适的方法.
Zero-Width Matches 0 宽度匹配
零宽断言,锚点,边界在包含标志的正则表达式中,允许正则引擎返回匹配的字符串.举个例子 (?<=start_)\d+,正则引擎会返回数字,但是不包括前缀 start_.
下面是一些应用:
Validation 验证
即类似密码验证例子
Inserting 插入
类似插入空格例子
Splitting 分割
类似插入空格例子
Overlapping Matches 重叠匹配
同一个单词里做多次匹配例子
Positioning the Lookaround 零宽断言定位
零宽断言有两个选择去定位,在文本前和文本后,一般来讲,其中一个性能更高.
Lookahead 先行断言
\d+(?= dollars) 和 (?=\d+ dollars)\d + 都匹配 100 dallars 中的
100
,但是前者性能更佳,因为他只匹配 \ d + 一次.(这里写一下自己对第二个式子的理解,第二个式子其实是先断言当前位置的后面是 \ d+ dollars,然后匹配断言中的字符串中的 \ d+).
Negative Lookahead 先行负向断言
\d+(?! dollars) 和 (?!\d+ dollars)\d + 都匹配 100 pesos 中的
100
,但是前者性能更佳,同上.
后面还有两个后行断言的例子,js 不支持就不列举了.
这些例子的不同在于匹配的前后.这里的说明不是要就纠结于位置,只是能够知道并感觉到这样写正则的效率,通过练习,会慢慢熟悉这些不同并写出性能更高的正则.
Lookarounds that Look on Both Sides: Back to the Future
这个部分涉及到的是零宽断言的嵌套,这里只说明一下里面举的例子,因为 js 不支持后行断言,这里讲的东西作用就不大了.
匹配下划线之间的数字:_12_,有很多方法,文中提出的新方法是:
(?<=_(?=\d{2}_))\d+
即,当前位置前面断言匹配了下划线_, 同时下划线的后面断言匹配了 \ d{2}_, 即整个后行断言匹配的是_\d{2}_,而当前的位置在_和 \ d{2} 之间,后面用 \ d + 匹配数字.
Compound Lookahead and Compound Lookbehind 复合先行和复合后行
在标志后至多有一个字符
匹配后面至多有一个下划线的数字:
\d+(?=_(?!_))
还有一种不太优雅的写法是:\d+(?=(?!__)_)
标志前至多有一个字符
匹配前面至多有一个下划线的数字:
(?<=(?
还有一种不太优雅的写法是:(?<=_(?<!__))\d+
Multiple Compounding 多重复合
即多个嵌套,这个有点复杂,就是超过一次嵌套,多个条件一起判断.这里就不列举了,可以看看这个例子:
( ? <=( ? <!( ? <!X) _) _)\d +
表示数字前缀不能是多个下划线,除了 X__这种情况.
在这个字符串中,DIC 后面是允许的动物名,我们要匹配前面_tokens 中在允许动物名内的.
The Engine Doesn't Backtrack into Lookarounds……because they're atomic
_rabbit _dog _mouse DIC: cat: dog: mouse
_(\w+)\b(?=.*:\1\b)
获得_dog 和_mouse.
翻转一下:
_(?=.*:(\w+)\b)\1\b
这样只匹配到了_mouse
这个地方很神奇,稍微讲一下.第一个正则还蛮好理解的每次正向断言都拿前面的 \ 1 捕获去匹配后面,按从左往右多次匹配结果到两个结果.第二个正则就特殊,捕获是放在正向断言里的,正向断言由于贪婪匹配会直接到了_mouse 的下划线后的位置,然后正则引擎跳出正向断言去匹配 \ 1, 匹配到 mouse 成功.匹配结束.这里的重点是,正则引擎并不能在正向判断里面回溯,只要跳出了正向断言,就不会再进去.因此这里的正向断言只会匹配到 mouse.我一开始想到加个非贪婪,那么就只会匹配到 cat 了.
Fixed-Width, Constrained-Width and Infinite-Width Lookbehind 负向断言,略去
Lookarounds (Usually) Want to be Anchored
匹配一个包含一个单词的字符串,里面有一位数字:
^(?=\D*\d)\w+$
这里需要考虑的问题是 ^ 锚点是否有必要.
这里的重点在于 ^ 能够减少错误的次数,如果没有 ^,正则引擎会在每个位置都去匹配,只有在所有位置都错误后才会返回错误,但是加了 ^,只要开头匹配错误引擎就会停止.虽然在匹配成功的情况下,两种情况返回是一样的,但是在性能上差别却很大.
One Exception: Overlapping Matches
不过有时候我们希望正则引擎匹配多个位置,比如上面的例子:(?=(\w+)).在 ABCD 中匹配了四次,获得了四个我们想要的结果.
后记
后记提到了上面讲到的 [^a-z]*[a-z] 优化为[^a-z]*+[a-z],不过一看就知道 js 不支持,这个的优化点在于,如果发现匹配不成功,有些不够智能的引擎会回溯前面的非小写字符,去匹配后面的小写字母这样显而易见的无效回溯.
这篇文章的大致解释就到这里,后面需要在了解一下关于正则引擎的问题了.
来源: http://www.bubuko.com/infodetail-2457293.html