《精通正则表达式(元字符)》这篇讲解了正则表达式常用的一些简单的元字符的使用,但是如果不能理解正则表达式匹配的核心,那么你永远不能在这方面有质的突破。
这一篇就重点讲解正则表达式的核心——正则引擎。
3、正则引擎
正则引擎主要可以分为基本不同的两大类:一种是DFA(确定型有穷自动机),另一种是NFA(不确定型有穷自动机)。DFA和NFA都有很长的历史,不过NFA的历史更长一些。使用NFA的工具包括.NET、PHP、Ruby、Perl、Python、GNU Emacs、ed、sec、vi、grep的多数版本,甚至还有某些版本的egrep和awk。而采用DFA的工具主要有egrep、awk、lex和flex。也有一些系统采用了混合引擎,它们会根据任务的不同选择合适的引擎(甚至对同一表达式中的不同部分采用不同的引擎,以求得功能与速度之间的平衡)
NFA和DFA都发展了很多年了,产生了许多不必要的变体,结果,现在的情况比较复杂。POSIX标准的出台,就是为了规范这种现象,POSIX标准清楚地规定了引擎中应该支持的元字符和特性。除开表面细节不谈,DFA已经符合新的标准,但是NFA风格的结果却与此不一,所以NFA需要修改才能符合标准。这样一来,正则引擎可以粗略地分为三类:DFA;传统型NFA;POSIX NFA。
我们来看使用`to(nite|knight|night)`来匹配文本‘…tonight…’的一种办法。正则表达式从我们需要检查的字符串的首位(这里的位置不是指某个字符的位置,而是指两个相邻字符的中间位置)开始,每次检查一部分(由引擎查看表达式的一部分),同时检查“当前文本”(此位置后面的字符)是否匹配表达式的当前部分。如果是,则继续表达式的下一部分,如果不是,那么正则引擎向后移动一个字符的位置,继续匹配,如此继续,直到表达式的所有部分都能匹配,即整个表达式能够匹配成功。在此例子中,由于表达式的第一个元素是`t`,正则引擎将会从需要匹配的字符串的首位开始重复尝试匹配,直到在目标字符中找到‘t’为止。之后就检查紧随其后的字符是否能由`o`匹配,如果能,就检查下面的元素。下面是`nite`或者`knight`或者`night`。引擎会依次尝试这3种可能。尝试`nite`的过程与之前一样:“尝试匹配`n`,然后是`i`,然后是`t`,最后是`e`”。如果这种尝试失败,引擎就会尝试另一种可能,如此继续下去,直到匹配成功或是报告失败。表达式的控制权在不同的元素之间转换,所以我们可以称它为“表达式主导”。
与表达式主导的NFA不同,DFA引擎在扫描字符串时会记录“当前有效”的所有匹配可能。在此例中引擎会对‘…tonight…’进行扫描,当扫描到t时,引擎会在表达式里面的t上坐上一个标记,记录当前位置可以匹配,然后继续扫描o,同样可以匹配,继续扫描到n,发现有两个可以匹配(knight被淘汰),当扫描到g时就只剩下一个可以匹配了,当h和t匹配完成后,引擎发现匹配已经成功,报告成功。我们称这种方式为“文本主导”,因为它扫描的字符串中的每个字符都对引擎进行了控制。
从它们匹配的逻辑上我们不难发现:一般情况下,文本主导的DFA引擎要快一些。正则表达式主导的NFA引擎,因为需要对同样的文本尝试不同的子表达式匹配,可能会浪费时间。在NFA的匹配过程中,目标文本的某个字符可能会被正则表达式反复检测很多遍(每一个字符被检测的次数不确定,所以NFA叫做不确定型有穷自动机)。相反,DFA引擎在匹配过程中目标文本中的每个字符只会最多检查一遍(每个字符被检测的次数相对确定,所以DFA叫做确定型有穷自动机)。由于DFA取得一个结果可能有上百种途径,但是因为DFA能够同时记录它们,选择哪一个表达式并无区别,也就是说你改变写法对于效率是没有影响的。而NFA是表达式主导,改变表达式的编写方式可能会节省很多功夫。
所以后面我们讲解的知识都是涉及的NFA的。
4、回溯
何为回溯?先来看一个例子,我们使用`a(b|c)d`去尝试匹配字符串“cabb”,正则引擎首先处于字符'c'的前面,开始查看正则表达式,发现第一个为a,不能匹配,然后引擎移动到'c'和'a'之间的位置,继续查看表达式,发现a可以匹配,然后查看表达式的后面,发现有两条路,引擎会做好标记,选择其中一条路,加入选择区匹配b,发现字符'a'后面就是'b',可以匹配,然偶再次查看表达式,需要匹配d,发现字符串后面是'b',不符合条件,这条路失败,引擎会自动回到之前做选择的地方,这里就称作一次回溯。那么引擎会尝试匹配a后面的c,发现'a'后面是'b',这条路也走不通,没有其它的路线了,然后引擎又会移动位置,现在到了'a'和'b'之间,引擎回去尝试匹配表达式的a,发现当前位置后面是'b',无法匹配,引擎又开始向后移动位置,直到移动到最后,发现没有一次匹配成功,然后引擎才会报告失败。而如果中间又一次成功完整匹配了,引擎会自动停止(传统型NFA会停止,而POSIX NFA还会继续,把所有可能匹配完,选择其中一个),报告成功。
现在应该知道回溯其实就是引擎在匹配字符串的过程中出现多选的情况,当其中一种选择无法匹配时再次选择另种的过程叫做回溯。其实我们在优化正则表达式的时候就是考虑的尽量减少回溯的次数。
4.1回溯 匹配优先和忽略优先
《精通正则表达式》这本书里面叫做匹配优先和忽略优先,网上有很多人叫做贪婪模式和非贪婪模式,反正都一样,叫法无所谓。
匹配优先量词我们已经学习了,就是?、+、*、{}这四个。匹配优先量词在匹配的时候首先会尝试匹配,如果失败后回溯才会选择忽略。比如`ab?`匹配"abb"会得到"abb"。这里当匹配成功'a'后,引擎有两个选择,一个是尝试匹配后面的b,一个是忽略后面的b,而由于是匹配优先,所以引擎会尝试匹配b,发现可以匹配,得到了"ab",接着引擎又一次遇到了同样的问题,还是会选择先匹配,所以得到了"abb",接着引擎发现后面没有字符了,就上报匹配成功。
忽略优先量词使用的是在?、+、*、{}后面添加?组成的,忽略优先在匹配的时候首先会尝试忽略,如果失败后回溯才会选择尝试。比如`ab??`匹配“abb”会得到‘a’而不是“ab”。当引擎匹配成功a后,由于是忽略优先,引擎首先选择不匹配b,继续查看表达式,发现表达式结束了,那么引擎就直接上报匹配成功。
例子1:
var reg1=/ab?/; var reg2=/ab??/; var result1=reg1.exec("abc"); var result2=reg2.exec("abc"); document.write(result1+" "+result2);
结果:
例子2:
var reg1=/ab+/; var reg2=/ab+?/; var result1=reg1.exec("abbbc"); var result2=reg2.exec("abbbc"); document.write(result1+" "+result2);
结果:
例子3:
var reg1=/ab*/; var reg2=/ab*?/; var result1=reg1.exec("abbbc"); var result2=reg2.exec("abbbc"); document.write(result1+" "+result2);
结果:
例子4:
var reg1=/ab{2,4}/; var reg2=/ab{2,4}?/; var result1=reg1.exec("abbbbbbc"); var result2=reg2.exec("abbbbbbc"); document.write(result1+" "+result2);
结果:
下面我们来看稍微复杂一点的匹配优先的情况,使用`c.*d`去匹配字符串“caaadc”,我们发现当c匹配成功后,`.*`会一直匹配到最后的'c',然后再去匹配表达式里面的d,发现后面没有字符可以匹配,这是就会回溯到`.*`匹配'c'的地方,选择`.*`忽略'c',那么c就留给后面了,但是发现还是不能匹配d,又得回溯到匹配d的位置,`.*`再次选择忽略匹配,发现就可以匹配d了,这是停止匹配,上报匹配成功,所以结果是“caaad”。
再看一个忽略优先的情况,使用`a.*?d`去匹配字符串“caaadc”,我们发现当匹配成功a时,引擎有两条路,会选择忽略匹配,直接匹配d,但是字符串“caaadc”的a后面是a,所以失败,回溯到之前的选择,悬着匹配,获得“aa”,然后又一次遇到同样的问题,引擎选择忽略匹配,发现后面又是a,不能匹配d,再次回溯,选择匹配,得到“aaa”,这一次忽略匹配后发现后匹配成功了d,那么上报成功,得到“aaad”。
希望这几个例子能够大概讲解清楚这两种不同的情况吧!
4.2回溯 固化分组
有些时候我们并不希望引擎去尝试某些回溯,这时候我们可以通过固化分组来解决问题——`(?>...)`。就是一旦括号内的子表达式匹配之后,匹配的内容就会固定下来(固化(atomic)下来无法改变),在接下来的匹配过程中不会变化,除非整个固化分组的括号都被弃用,在外部回溯中重新应用。下面这个简单的例子能够帮助我们理解这种匹配的“固化”性质。
`!.*!`能够匹配"!Hello!",但是如果`.*`在固化分组里面`!(?>.*)!`就不能匹配,在这两种情况下`.*`都会选择尽可能多的字符,都会包含最后的'!',但是固化分组不会“交还”自己已经匹配了的字符,所以出现了不同的结果。
尽管这个例子没有什么实际价值,固化分组还是有很重要的用途。尤其是它能够提高匹配的效率,而且能够对什么能匹配,什么不能匹配进行准确的控制。但是js这门语言不支持。汗!
4.3回溯 占有优先量词
所谓的占有优先量词就是*+、++、?+、{}+这四个,这些量词目前只有java.util.regex和PCRE(以及PHP)提供,但是很可能会流行开来,占有优先量词类似普通的匹配优先量词,不过他们一旦匹配某些内容,就不会“交还”。它们类似固化分组,从某种意义上来说,占有优先量词只是些表面功夫,因为它们可以用固化分组来模拟。`.++`与`(?>.+)`结果一样,只是足够智能的实现方式能对占有优先量词进行更多的优化。
4.4回溯 环视
环视结构不匹配任何字符,只匹配文本中的特定位置,这一点和单词分界符`\b`、`^`、`$`相似。
`(?=)`称作肯定顺序环视,如`x(?=y)`是指匹配x,仅当后面紧跟y时,如果符合匹配,则只有x会被记住,y不会被记住。
`(?!)`称作否定顺序环视,如`x(?!y)`是指匹配x,仅当后面不紧跟y时,如果符合匹配,则只有x会被记住,y不会被记住。
在环视内部的备用状态一旦退出环视范围后立即清除,外部回溯不能回溯到环视内部的备用状态。使用`ab\w+c`和`ab(?=\w+)c`来匹配字符串“abbbbc”,第一个表达式会成功,而第二个表达式会失败。
例子1:
var reg=/ab(?=c)/; var result1=reg.exec("abcd"); var result2=reg.exec("abbc"); document.write(result1+" "+result2);
结果:
例子2:
var reg=/ab(?!c)/; var result1=reg.exec("abdc"); var result2=reg.exec("abcd"); document.write(result1+" "+result2);
结果:
例子3:
var reg1=/ab\w+bc/; var reg2=/ab(?=\w+)c/; var result1=reg1.exec("abbbbbcb"); var result2=reg2.exec("abbbbbbc"); document.write(result1+" "+result2);
结果:
明显自己都觉得环视没讲解好(找时间再修改一下),还有肯定逆序环视和否定逆序环视、占有优先量词以及固化分组这些都是在解决回溯的问题(不过js现在不支持这些,真要将估计得换语言了),回溯算是影响表达式的罪魁祸首吧!这几个内容看啥时候有时间在细讲吧!写着写着才发现想让人看懂不是那么容易的!体谅一下哦!
5、打造高效正则表达式
Perl、Java、.NET、Python和PHP,以及我们熟悉的JS使用的都是表达式主导的NFA引擎,细微的改变就可能对匹配的结果产生重大的影响。DFA中不存在的问题对NFA来说却很重要。因为NFA引擎允许用户进行精确控制,所以我们可以用心打造正则表达式。
5.1先迈好使的腿
对于一般的文本来说,字母和数字比较多,而一些特殊字符很少,一个简单的改动就是调换两个多选分支的顺序,也许会达到不错的效果。如使用`(:|\w)*`和`(\w|:)*`来匹配字符串“ab13_b:bbbb:c34d”,一般说来冒号在文本中出现的次数少于字母数字,此例中第一个表达式效率低于第二个。
例子:
var reg1=/(:|\w)*/; var reg2=/(\w|:)*/; var result1=reg1.exec("ab13_b:bbbb:c34d"); var result2=reg2.exec("ab13_b:bbbb:c34d"); document.write(result1+" "+result2);
5.2无法匹配时
对于无法匹配的文本,可能它在匹配过程中任然会进行许多次工作,我们可以通过某种方式提高报错的速度。如使用`”.*”!`和`”[^”]*”!`去匹配字符串“The name “McDonald’s” is said “makudonarudo” in Japanese”。我们可以看出第一种回溯的次数明显多于第二种。
5.3多选结构代价高
多选结构是回溯的主要原因之一。例如使用`u|v|w|x|y|z`和`[uvwxyz]`去匹配字符串“The name “McDonald’s” is said “makudonarudo” in Japanese”。最终`[uvwxyz]`只需要34次尝试就能够成功,而如果使用`u|v|w|x|y|z`则需要在每个位置进行6次回溯,在得到同样结果前总共有198次回溯。
少用多选结构。
5.4消除无必要的括号
如果某种实现方式认为`(?:.)*`与`.*`是完全等价的,那么请使用后者替换前者,`.*`实际上更快一些。
5.5消除不需要的字符组
只包含单个字符的字符组有点多余,因为它要按照字符组来处理,而这么做完全没有必要。所以例如`[.]`可以写成`\.`。
5.6量词等价转换
有人习惯用`\d\d\d\d`,也有人习惯使用量词`\d{4}`。对于NFA来说效率上时有差别的,但工具不同结果也不同。如果对量词做了优化,则`\d{4}`会更快一些,除非未使用量词的正则表达式能够进行更多的优化。
5.7使用非捕获型括号
如果不需要引用括号内的文本,请使用非捕获型括号`(?:)`。这样不但能够节省捕获的时间,而且会减少回溯使用的状态的数量。由于捕获需要使用内存,所以也减少了内存的占用。
5.8提取必须的元素
由于很多正则引擎存在着局部优化,主要是依靠正则引擎的能力来识别出匹配成功必须的一些文本,所以我们手动的将这些文本“暴露”出来可以提高引擎识别的可能性。 `xx*`替代`x+`能够暴露必须的‘x’。`-{2,4}`可以写作`--{0,2}`。用`th(?:is|at)`代替`(?:this|that)`就能暴露必须的`th`。
5.9忽略优先和匹配优先
通常,使用忽略优先量词还是匹配优先量词取决于正则表达式的具体需求。例如`^.*:`完全不同于`^.*?:`,因为前者匹配到最后的冒号,而后者匹配到第一个冒号。但是如果目标数据中只包含一个冒号,两个表达式就没有区别了。不过并不是任何时候优劣都如此分明,大的原则是:如果目标字符串很长,而你认为冒号会比较接近字符串的开头,就使用忽略优先量词;如果你认为冒号在接近字符串的末尾位置,你就使用匹配优先。如果数据是随机的,又不知道冒号在哪头,就使用匹配优先量词,因为它们的优化一般来说都要比其他量词要好一些。
5.10拆分正则表达式
有时候,应用多个小正则表达式的速度比一个大正则表达式要快得多。比如你希望检查一个长字符串中是否包含月份的名字,依次检查`January`、`February`、`March`之类的速度要比`January|..|….`快得多。
还有很多优化的方法见《精通正则表达式》,我在这里只是列举了部分容易理解的方式。其实只要理解正则引擎室如何匹配的,理解回溯的逻辑,你就可以对自己写的表达式进行相应的优化了!