1、正则表达式解释器实现原理正则表达式解释器实现原理1以JavaScript正则为例Author:tuiye正则表达式可以用来:(1)验证字符串是否符合指定特征,比如验证是否是合法的邮件地址。(2)用来查找字符串,从一个长的文本中查找符合指定特征的字符串,比查找固定字符串更加灵活方便。(3)用来替换,比普通的替换更强大。对于一个正则表达式一般有2种方式,以JS为例其一为使用正则表达式文字常量:var re = /JjavaSscript/i;其二为使用RegExp构造函数:var re = new RegExp(“JjavaSscript”,”i”);而一个正则表达式解释器主要有3部分组成,分别是
2、解析(parse)、编译(compile)与执行(execute)。1解析正则的表达式的词法与语法比较简单,基本语法如下:A)普通字符和元字符普通字符是那些表示自身的字符,例如从a到z,A到Z,0到9等;元字符具有特殊意义,如.,表示除了/n外的所有字符,其他具有此功能的有表1元字符元字符特殊意义匹配输入字符串的开始位置。要匹配字符本身,请使用/$匹配输入字符串的结尾位置。要匹配$字符本身,请使用/$.匹配除了换行符(/n)以外的任意一个字符。要匹配小数点本身,请使用/.*修饰匹配次数为0次或任意次。要匹配*字符本身,请使用/*+修饰匹配次数为至少1次。要匹配“+”字符本身,请使用“/+”?修
3、饰匹配次数为0次或1次。要匹配“?”字符本身,请使用“/?”=用于前向引用或向后引用!用于前向引用或向后引用:用于前向引用或向后引用|用于前向引用或向后引用/转义用/用于前向引用或向后引用()标记一个子表达式的开始和结束位置。要匹配小括号,请使用“/(“和“/)”用来自定义能够匹配多种字符的表达式。要匹配中括号,请使用“/“和“/”修饰匹配次数的符号。要匹配大括号,请使用“/“和“/”元数据如要表示自身,那么需要用/来辅助转义B)字符类单个的字符可以组成字符类,其语法为用与组成,例如abcA-Z79表示可以匹配a,b,c与A到Z,7,9的字符其中-为连字符,表示字符的跨度。在”间也是特殊字符,
4、表示取反其他的特殊字符如下表:表2字符类中的预定义字符类预定义字符类特殊意义在紧跟表示取反,表示自身要转义-在字符间,表示连字符,如要表示自身,须紧接在或之后.小数点可以匹配除了换行符(/n)以外的任意一个字符/d可以匹配任何一个09数字字符/DD大写,可以匹配任何一个非数字字符/s可以匹配空格、制表符、换页符等空白字符的其中任意一个/SS大写,可以匹配任何一个空白字符以外的字符/w可以匹配任何一个字母或者数字或者下划线/WW大写,可以匹配任何一个字母或者数字或者下划线以外的字符JavaScript无POSIX格式C)限定符(重复)限定符有2种形式,分别为*,+,?与 与来表示表3限定符限定符
5、特殊意义*表达式尽可能的多匹配,最少可以不匹配,相当于0, +表达式尽可能的多匹配,至少匹配1次,相当于1, ?表达式尽可能匹配1次,也可以不匹配,相当于0, 1m,n表达式尽可能重复n次,至少重复m次:ba1,3可以匹配ba或baa或baaam表达式固定重m次,比如:/w2相当于/w/wm,表达式尽可能的多匹配,至少重复m次:/w/d2,可以匹配a12,x456.在正则中有贪婪与非贪婪之分,默认的情况下,正则是贪婪的如果要把正则设置为非贪婪有2种方式,一种为设置在原先的限定符加上?就行,另一种在设置举例说明,/.+/将匹配abdddd中的所有字符,/.+?/只将匹配abdddd中的第一个a,
6、也就是默认的尽可能多的匹配字符,而非贪婪重复则尽可能上的匹配。D)选择、分组和引用选择的语法就是设置|,如a|bc,那么要么a或bc都可以匹配,如果(a|b)c则为匹配ac或bc。如果我们在上例中设置了”()”,那么这就是分组,每个分组都可以被引用,如(a|b)c*(e|f)/1/2,/1与/2就是引用的语法,/1表示引用了(a|b),/2表示引用(e|f),以此类推。这里要说明的是(a|b)c*(e|f)/1/2与(a|b)c*(e|f)(a|b)(e|f)乍一看两者等同,但实际上,前一个不可以匹配acebf,而后一个可以。究其原因就是引用处的配平必须与被引用处一致,此例中与之匹配的可以是a
7、ceac。E)定位符(锚)和前向引用定位符如下表所示表4定位符限定符特殊意义匹配输入字符串的开始位置。要匹配字符本身$匹配输入字符串的结尾位置。要匹配$字符本身?表达式尽可能匹配1次,也可以不匹配,相当于0, 1/b匹配单词边界,例如一个/w和/W的位置,或者一个/w与字符串的开始和结尾的位置/B和上面的想法,匹配一个非单词边界如果正则表达式的匹配模式为MULTILINE模式,可匹配一行文本的行首,$可匹配一行文本的行末。当/b被包含于字符集合中时,/b代表退格符(ASCII码= 8)。除了这些预定义的定位符,还可以自定义定位符,这种类型的定位符叫做前向引用(look-ahead anchor
8、)和后向引用(look-behind anchor,JavaScript不支持)。前向引用使用(?=)表示正的前向引用,(?!)表示负的前向引用下面是一个前向引用的例子/Java(?!Script)(A-Z/w*)/其中(?!Script)匹配后面不跟Script的位置,而(?Script)匹配后面是Script的位置。以上讲解了JavaScript的语法规则,下面我们来论述一下解析的过程。解析的过程是语法分析(Lexical Analysis)与词法分析(Grammar Analysis)。2编译编译(Compile)阶段,主要的工作就是生成字节流(Emit Byte Code)。而生成By
9、te Code的算法(规则)JS中就是NFA。生成的Byte Code是归于执行(Execute)时做匹配利用。各个状态即为正则中的语义(OPCODE)的表示,各个OPCODE以一定的格式与关系住成了状态机,JS中是组成NFA的状态机。下面介绍下在流行的两种算法NFA(Nondeterministic Finite Automaton)与DFA(Deterministic Finite automaton),Perl,Python,JS等都是NFA的,而awk与grep等用的是DFA,两种算法的具体实现如下:1)有限状态机(Finite Automation)状态机是一个有一组不同状态的集合的
10、系统。有一个特殊状态它描述了系统的初始状态。而其他的一个或多个状态为终止状态;当一个事件将我们带到这样的一些状态时,状态机将退出。状态是与转换相关联的,每个转换都标注有输入事件的名称。当事件发生时,我们将随着相关的转换从当前状态移动到新的状态。一个有限状态机包含一组状态集(states)、一个起始状态(start state)、一组输入符号集(alphabet)、一个映射输入符号和当前状态到下一状态的转换函数(transition function)的计算模型。当输入符号串,模型随即进入起始状态。它要改变到新的状态,依赖于转换函数。假定一个输入符号(symbol),可以得到2个或者2个以上的可
11、能状态,那么这个finite automaton就是不确定的,反之就是确定的。一个正则可以与一个FA等同,其转化的规律如下对于单个字符的两个状态的连接e1e2对于e?对于e1|e2对于e*对于e+2)不确定有限状态机(NFA)例如要匹配abab|abbb,其NFA的状态是3)确定性有限状态机(DFA)以上例子的DFA如下其中s1-s10为各个的状态对应于NFA中的s1-s103执行1)NFA那么一个abbb字符串的匹配过程如下:一个更加高效的方式是同步匹配两者:这里我们看到,是利用正则表达式来扫描要匹配的字符串,又由于此时是不确定状态机,所以利用试探与backing的方式来做匹配的。NFA是由
12、正则来做驱动匹配的。这就像一个过程语言,控制了解析器在匹配中的try/fail。2)DFA而确定性状态机相反,由于对于相应的输入都有一定的状态的迁移,所以总的来说,DFA的匹配效率要高一些。DFA是由字符串作驱动来匹配的,在每个字符串中的每个字符只被扫描一次。这种方式就是尝试此状态时可能的每种输入同时进行匹配。4实践见JS 1.6与PCRE7.21)JS1.6以/.*ht*p0,3/为例来说明JS1.6与PCRE7.2的NFA组成JS1.6中,基本上以一个字符(广义上的字符,比如/n我们认为是回车字符)以一个节点建立RENode。比如例子中,我们建立了7个RENode,依次为.,*,h,t,*
13、,p,” 0, 3”。其中h,t,p分别为REOP_FLAT,而*,”0,3”为REOP_QUANT(RE_STAR)。建立的节点的同时,会调整节点间的关系,主要是ProcessOp()这个函数,调整的关系为两种:一种是OP_CONCAT(连接),另一种为OP_ALT(选择)。OP_CONCAT是指两个OP_FLAT的RENode节点,例如h与t是紧挨的,那么我们把他们处理成“连接”关系。“连接”关系一种顺序关系。至于OP_ALT,则额外建立一个OP_ALT节点把两则建立起选择的关系。例如a|b,那么,建立OP_ALT节点,把节点a与b与节点OP_ALT建立选择关系。解析后RENode节点顺序
14、如下图*04.ENDCHILDFLATh*05FLATtENDCHILDQUANT0305FLAT1PEND即JS1.6中编译的过程就为生成NFA的过程,主要是调整生成OP_ALT,OP_BACKREF,OP_STAR等跳转关系编译后,生成NFA(注:此例中上下行为父子关系)REOP_STARREOP_FLAT1REOP_STARREOP_QUANTREOP_DOT .hREOP_FLAT tREOP_FLAT p即执行(匹配)的过程,我们以匹配字符串”xhtttpps”匹配中的OPCODE匹配中的匹配位置与状态成功与否Start(保存,MatchBack用)|x h t t t p p s|
15、x h t t t p p s.*x h t t t p p sTrue.*x h t t t p p sTrue.*x h t t t p p s|Falsehx h t t t p p s|Falsehx h |t t t p p sFalse(则回朔)hx |h t t t p p sTruetx h |t t t p p sTruetx h t t t |p p sFalse(则回朔)px h t t t |p p sTruepx h t t t p |p sTrueDone sTrue注:其中|代表匹配所在的位置,代表匹配成功结束。所以上述正则可匹配上述字符串中的”xhtttpp”2
16、)PCRE7.2以/ht*p0,2/为例PCRE的解析与编译是合而为一的,也就是说,解析编译后生成的OPCODE即为最终的NFA。这里NFA与JS1.6中的NFA是形式是一样的,当然细节上有区别。因此其匹配的过程也是相似。当然PCRE也提供理论部分的DFA作为其状态机。(待续)以上的正则解析编译后的以如下格式存在。OPBRA011OPCHARhOPSTARtOPUPTO02pOPKET011OPEND由于其NFA与JS有一致性,这里不再重复,倒是其Match时的一个消递归的方式比较不错,下面来做一个小的说明。基本思想是这样的,因为我们递归的时候每次都要保存一些变量与“栈”上,这样过多的嵌套就会
17、引起很大的变量于“栈”上,而且由于某些操作系统对“栈”的大小是有限制的,这就在一定的时候会引起“栈”溢出,从而到时程序运行问题,常见的就是Crash。一般比较常用的消递归的方式主要有2种,其一是无限循环,其二就是自己从“堆”上保存自己的变量。这里用了第二种方式。以函数直接调用自身这种方式来说明。那么在此函数中定有一处或几处是调用到自身的,在调用自身处,在“堆”上分配出空间frame,用于保存当前的变量的值,并把当前frame压入自己的frame栈(数据结构中的栈,与上面提到的“栈”不同),并且在此设定一个label(标签,用于RETURN时候的goto到此用)。并且goto至函数的入口处,此时
18、犹如一个函数的新调用,而且可以减少调用函数的开销。当函数执行完(比如匹配不成功,需要回退;或者subpattern执行完毕)时候,我们需要做“返回”。返回前,我们必须保存执行到此时的一个“结果”,如函数的返回值类似。然后就是取出要返回的label的位置,用goto到那里,把当前的frame销毁,继续执行上一步中未完成的部分。大体上就是这样。理解正则表达式2rex注:本文原作者孟岩,原文转自孟岩CSDN博客。本文为程序员07年3月号七种武器专题所做。rex以前只是知道如何使用正则表达式而已,在读MRE时,读到NFA、DFA其实都是一头雾水,不知所云。现在抓紧时间恶补基础知识,学习了些编译原理,这
19、才有些明白。如今再看从源头讲正则表达式的文章,就心有戚戚了,呵呵。搜到好文章一篇,与大家分享。在程序员日常工作中,数据处理占据了相当的比重。而在所有的数据之中,文本又占据了相当的比重。文本能够被人理解,具有良好的透明性,利于系统的开发、测试和维护。然而,易于被人理解的文本数据,机器处理起来就不一定都那么容易。文本数据复杂多变,特定性强,甚至是千奇百怪。因此,文本处理程序可谓生存环境恶劣。一般来说,文本处理程序都是特定于应用的,一个项目有一个项目的要求,彼此之间很难抽出共同点,代码很难复用,往往是“一次编码,一次运行,到处补丁”。其程序结构散乱丑陋,谈不上有什么“艺术性”,基本上与“模式”、“架
20、构”什么的无缘。在这里,从容雅致、温文尔雅派不上用场,要想生存就必须以暴制暴。事实上,几十年的实践证明,除了正则表达式和更高级的parser技术,在这样一场街头斗殴中别无利器。而其中,尤以正则表达式最为常用。所以,对于今天的程序员来说,熟练使用正则表达式着实应该是一种必不可少的基本功。然而现实情况却是,知道的人很多,善于应用的人却很少,而能够洞悉其原理,理智而高效地应用它的人则少之又少。大多数开发者被它的外表吓倒,不敢也不耐烦深入了解其原理。事实上,正则表达式背后的原理并不复杂,只要耐心学习,积极实践,理解正则表达式并不困难。下面列举的一些条款,来自我本人学习和时间经验的不完全总结。由于水平和
21、篇幅所限,只能浮光掠影,不足和谬误之处,希望得到有识之士的指教。1.了解正则表达式的历史正则表达式萌芽于1940年代的神经生理学研究,由著名数学家Stephen Kleene第一个正式描述。具体地说,Kleene归纳了前述的神经生理学研究,在一篇题为正则集代数的论文中定义了“正则集”,并在其上定义了一个代数系统,并且引入了一种记号系统来描述正则集,这种记号系统被他称为“正则表达式”。在理论数学的圈子里被研究了几十年之后,1968年,后来发明了UNIX系统的Ken Thompson第一个把正则表达式用于计算机领域,开发了qed和grep两个实用文本处理工具,取得了巨大成功。在此后十几年里,一大批
22、一流计算机科学家和黑客对正则表达式进行了密集的研究和实践。在1980年代早期,UNIX运动的两个中心贝尔实验室和加州大学伯克利分校分别围绕grep工具对正则表达式引擎进行了研究和实现。与之同时,编译器“龙书”的作者Alfred Aho开发了Egrep工具,大大扩展和增强了正则表达式的功能。此后,他又与C程序设计语言的作者Brian Kernighan等三人一起发明了流行的awk文本编辑语言。到了1986年,正则表达式迎来了一次飞跃。先是C语言顶级黑客Henry Spencer以源代码形式发布了一个用C语言写成的正则表达式程序库(当时还不叫open source),从而把正则表达式的奥妙带入寻常
23、百姓家,然后是技术怪杰Larry Wall横空出世,发布了Perl语言的第一个版本。自那以后,Perl一直是正则表达式的旗手,可以说,今天正则表达式的标准和地位是由Perl塑造的。Perl 5.x发布以后,正则表达式进入了稳定成熟期,其强大能力已经征服了几乎所有主流语言平台,成为每个专业开发者都必须掌握的基本工具。2.掌握一门正则表达式语言使用正则表达式有两种方法,一种是通过程序库,另一种是通过内置了正则表达式引擎的语言本身。前者的代表是Java、.NET、C/C+、Python,后者的代表则是Perl、Ruby、JavaScript和一些新兴语言,如Groovy等。如果学习正则表达式的目标仅
24、仅是应付日常应用,则通过程序库使用就可以。但只有掌握一门正则表达式语言,才能够将正则表达式变成编程的直觉本能,达到较高的水准。不但如此,正则表达式语言也能够在实践中提供更高的开发和执行效率。因此,有心者应当掌握一门正则表达式语言。3.理解DFA和NFA正则表达式引擎分成两类,一类称为DFA(确定性有穷自动机),另一类称为NFA(非确定性有穷自动机)。两类引擎要顺利工作,都必须有一个正则式和一个文本串,一个捏在手里,一个吃下去。DFA捏着文本串去比较正则式,看到一个子正则式,就把可能的匹配串全标注出来,然后再看正则式的下一个部分,根据新的匹配结果更新标注。而NFA是捏着正则式去比文本,吃掉一个字
25、符,就把它跟正则式比较,匹配就记下来:“某年某月某日在某处匹配上了!”,然后接着往下干。一旦不匹配,就把刚吃的这个字符吐出来,一个个的吐,直到回到上一次匹配的地方。DFA与NFA机制上的不同带来5个影响:1.DFA对于文本串里的每一个字符只需扫描一次,比较快,但特性较少;NFA要翻来覆去吃字符、吐字符,速度慢,但是特性丰富,所以反而应用广泛,当今主要的正则表达式引擎,如Perl、Ruby、Python的re模块、Java和.NET的regex库,都是NFA的。2.只有NFA才支持lazy和backreference等特性;3.NFA急于邀功请赏,所以最左子正则式优先匹配成功,因此偶尔会错过最佳
26、匹配结果;DFA则是“最长的左子正则式优先匹配成功”。4.NFA缺省采用greedy量词(见item 4);5.NFA可能会陷入递归调用的陷阱而表现得性能极差。我这里举一个例子来说明第3个影响。例如用正则式/perl|perlman/来匹配文本 perlman book。如果是NFA,则以正则式为导向,手里捏着正则式,眼睛看着文本,一个字符一个字符的吃,吃完 perl 以后,跟第一个子正则式/perl/已经匹配上了,于是记录在案,往下再看,吃进一个 m,这下糟了,跟子式/perl/不匹配了,于是把m吐出来,向上汇报说成功匹配 perl,不再关心其他,也不尝试后面那个子正则式/perlman/,
27、自然也就看不到那个更好的答案了。如果是DFA,它是以文本为导向,手里捏着文本,眼睛看着正则式,一口一口的吃。吃到/p/,就在手里的 p 上打一个钩,记上一笔,说这个字符已经匹配上了,然后往下吃。当看到 /perl/ 之后,DFA不会停,会尝试再吃一口。这时候,第一个子正则式已经山穷水尽了,没得吃了,于是就甩掉它,去吃第二个子正则式的/m/。这一吃好了,因为又匹配上了,于是接着往下吃。直到把正则式吃完,心满意足往上报告说成功匹配了 perlman。由此可知,要让NFA正确工作,应该使用 /perlman|perl/ 模式。通过以上例子,可以理解为什么NFA是最左子式匹配,而DFA是最长左子式匹配
28、。实际上,如果仔细分析,关于NFA和DFA的不同之处,都可以找出道理。而明白这些道理,对于有效应用正则表达式是非常有意义的。4.理解greedy和lazy量词由于日常遇到的正则表达式引擎全都是NFA,所以缺省都采用greedy量词。Greedy量词的意思不难理解,就是对于/.*/、/w+/这样的“重复n”次的模式,以贪婪方式进行,尽可能匹配更多字符,直到不得以罢手为止。举一个例子,以 / 模式匹配 Perl Hacks t文本,匹配结果不是 ,而是 Perl Hacks 。原因就在于NFA引擎以贪婪方式执行“重复n次”的命令。让我们来仔细分析一下这个过程。条款3指出,NFA的模型是以正则式为导向,拿着正则式吃文本。在上面的例子里,当它拿着/.*/这个正则式去吃文本的时候,缺省情况下它就这么一路吃下去,即使碰到 字符也不罢手既然 /./ 是匹配任意字符, 当然也可以匹配!所以就尽管吃下去,直到吃完遇到结尾(包括t字符)也不觉得有什么不对。这个时候它突然发现,在正则表达式最后还有一个 /,于是慌了神,知道吃多了,于是就开始一个字符一个字符的往回吐,直到吐出倒数第二个字符 ,完成了与正则式的匹配,才长舒一口气,向上汇报,匹配字符串从第一个字符 结束,即 Perl
copyright@ 2008-2022 冰豆网网站版权所有
经营许可证编号:鄂ICP备2022015515号-1