本文主要分享正则表达式中的量词以及量词的贪婪模式与惰性模式,通过示例来探索正则表达式引擎如何进行搜索匹配。
想系统了解下正则表达式,可以先参考在下 往期的文章。
量词
量词,即表示要匹配的字符或表达式的数量。
为什么需要量词?拿预定义的特殊字符 \d
来说,它代表 0-9 的数字。但是在使用时,它只会匹配一个数字而不是一串。
js
const reg = /\d/g;
const str = '123456';
reg.exec(str);
在上述示例中,执行一次 exec
,只会得到第一个数字 1
,再执行一次会得到 2
,再执行得到 3
,以此类推······
所以你会发现,\d
每次只会匹配一个数字,而不会自动将 123456
识别成十二万三千四百五十六 🤣
那如何非要一次性匹配所有数字呢?就需要量词出马了。
js
const reg1 = /\d+/g;
其中,+
是含有特殊意义的元字符,表示将前一项匹配 1 次或更多次(>= 1)。这样,我们就能一次性配到的完整的 123456
了。
除了 +
外,还有 *
、?
、{n}
,其含义可参见 量词。
棘手的量词与失效的 g
乍一看量词是不是非常简单?哪里要匹配多次就在其后面追加个量词,但实际上它们可能很棘手。
我们找个比 /\d+/
更复杂的东西,来探索一下它的工作原理。
这里一段文本:'a "witch" and her "broom" is one'
,需要匹配其中引号包裹的两个单词 witch
和 broom
。
首先要做的是定位带引号的字符串,然后定位引号里面的内容,我们很容易想到 /".+"/
这样的正则表达式,最后再给它加个修饰符 g
,让它全局匹配,看上去很完美,但事实可能并非如此,我们可以试一下:
js
let regexp = /".+"/g;
let str = 'a "witch" and her "broom" is one';
console.log(str.match(regexp)); // => "witch" and her "broom"
它没有找到匹配项 "witch"
和 "broom"
,而是找到:"witch" and her "broom"
。
贪婪模式
为了查找到一个匹配项,正则表达式引擎会采用以下算法:
- 对于字符串中的每一个位置
- 尝试匹配该位置的模式。
- 如果未匹配,则转到下一个位置。
但这样简单的描述并不能说清楚这个正则表达式匹配失败的原因,所以让我们详细说明一下模式 ".+"
是如何进行搜索的。
step 1:匹配第一个引号 "
正则表达式引擎从源字符串 0 的位置开始查找,尝试匹配表达式中的第一个引号 "
。当然,0 号位是个 a
,不符合条件,匹配失败。
然后继续前进,1 号位是个空格,不符合条件,继续前进,在 2 号位匹配到了 "
号:
step 2:匹配 .+
找到引号后,引擎开始根据下一个条件 .+
尝试匹配剩余的字符串。
这个 .
有点儿意思,它表示匹配除了换行符之外的任意字符(当然也包括引号 "
),而恰好此时又有 +
的 buff 加持,所以它就一路狂飙,从 w
一直匹配到末尾才停止。于是,就变成了这样:
这正是贪婪的本质所在,正所谓"贪婪,戒之在贪,伏卧罚之"。
step 3:回溯
因为上一步的贪婪,直接把整个字符串都给干完了,所有这里出现一个问题:表达式中还有最后一个引号 "
没有开始匹配,但是字符串已经不够了······
不过好在正则表达式引擎知道它给 .+
匹配太多项了,所以开始 回溯
。换句话说,它"吐出"了量词匹配项的最后一个字符:
step 4:匹配第二个引号 "
现在,它就有余粮来尝试匹配引号了,如果符合预期,那么搜索将结束。不过这里最后一个字符是 e
,所以不匹配。
没关系,引擎会继续将 .+
的重复次数减少一个字符,继续吐出一粒余粮,如果还不匹配,就一直吐吐吐······(yue)
终于,在引擎的不断回溯中,找到了引号 "
:
匹配完成,它的整个流程类似于:
这可能不是我们所期望的,但这就是它的工作方式。
惰性模式
惰性模式中的量词与贪婪模式中的是相反的。它表示:"重复最少的次数"。
我们可以通过在量词后面添加一个问号 ?
来启用它,而 ?
本身就是具有特殊含义的元字符,在量词中表示将前面的项匹配 0 或 1 次。
所以用 ?
来开启量词的惰性模式合情合理。现在,.+
就能符合预期了:
js
let regexp = /".+?"/g;
let str = 'a "witch" and her "broom" is one';
console.log(str.match(regexp)); // => "witch", "broom"
同样来看一下惰性的搜索过程:
step 1:匹配第一个引号 "
第一步是一样的,在 2 号位匹配到第一个引号 "
。
step 2:交替进行的 .+ 和 "
第二步有些类似,但是不同的是,因为我们对 +?
启用了惰性模式,引擎不会尝试在当前字符匹配成功后继续往后多匹配一个字符,而会停止并立即尝试对剩余的模式 "
进行匹配:
如果这里有一个引号,搜索就会停止,但这里是一个 i
,所以没有匹配到引号。
当后面的 "
条件不满足,当前 .+
条件才会继续往下搜索。两个条件(.+
和 "
)就这样一直交替进行······真的是打一鞭子走一步,就很惰性 🤣
终于,它找到了第一个匹配项:
step 3:全局匹配直到结束
在修饰符 g
的作用下,接下来的搜索会从当前匹配的结尾开始,继续往下并产生下一个匹配项:
量词 *?
和 ??
的工作方式也是类似的。
失败的惰性量词
有时,惰性量词不是万能的,让我们来看一个有意思的例子:
这里又有两段文本:
js
let str1 = '...<a href="link1" class="doc">... <a href="link2" class="doc">...';
let str2 = '...<a href="link1" class="wrong">... <p style="" class="doc">...';
我们想要找到 <a href="..." class="doc">
形式的带有任意 href 的链接。也就是说,针对第一个字符串结果是 <a href="link1" class="doc">
和 <a href="link2" class="doc">
两个链接,而针对第二个字符串,没有匹配的内容,结果是 null
。
这个表达式应该怎么写呢?很简单:/<a href=".*?" class="doc">/g
。
验证一下:
js
let regexp = /<a href=".*?" class="doc">/g;
// 有效 => ['<a href="link1" class="doc">', '<a href="link2" class="doc">']
console.log(str1.match(regexp))
// 无效 => ['<a href="link1" class="wrong">... <p style="" class="doc">']
console.log(str2.match(regexp))
会发现,对于第一个字符串 str1,该表达式生效了。但是对于第二个字符串 str2,它匹配到了一堆文本,而不是 null。
惰性 *?
失效了,为什么?
原因如下:
首先,你可以把这段表达式分成三部分:<a href="
和 .*?
和 " class="doc">
。
其次,搜索步骤如下:
- 正则表达式先寻找链接的开始:
<a href="
。 - 然后它寻找
.*?
,取一个字符(惰性的!),然后 检查字符串的剩余部分是否与模式的剩余条件匹配(未匹配)。 - 然后再取一个字符到
.*?
中,以此类推······直到最终到达" class="doc">
。
其中,2 和 3 两步的结果可以看成这样:
反向字符集
既然贪婪模式和惰性模式都有问题,我们就可以换个方式。在上述的两段文本中,会发现只有 href 里面的链接满足内容中既没有引号 "
,又紧跟引号结尾。我们抓住这个突破口,使用 [^abc]
反向字符集试试。
所谓 [^abc]
反向字符集,意思是,将不是 a 或 不是b 或 不是c 视为一类,换句话说,只要字符串中有不是a b c 其中任意一个的其他字符都行。
正确的变体可以是这样的:href="[^"]*"
。它会获取 href 特性中的所有字符直到最近的引号,正好符合我们的需求。
js
let regexp = /<a href="[^"]*" class="doc">/g;
// 有效 => ['<a href="link1" class="doc">', '<a href="link2" class="doc">']
console.log(str1.match(regexp))
// 有效 => null
console.log(str2.match(regexp))
请注意,这个逻辑并不能取代惰性量词。我只是想说,在有些场景下,惰性量词可能不是很好用,这时就可以换个思路来解决问题。
总结
最后点一下题,到底是正则的贪(niu)婪(qu)还是正则的惰(lun)性(sang)导致了匹配的结果与预期不同?
都不是,而是量词的有两种工作模式:
在默认情况下,正则表达式引擎尝试匹配尽可能多的字符,然后在模式的后续条件不匹配时再将其逐一缩短。
而通过在量词后追加 ?
,引擎仅在模式的后续条件无法在给定位置匹配时增加当前条件的重复匹配次数。
好了,本期《今日说法》到此结束,欢迎大家观看。下期《走近科学》继续与您相约🤪