正则表达式解释器实现原理.docx
《正则表达式解释器实现原理.docx》由会员分享,可在线阅读,更多相关《正则表达式解释器实现原理.docx(23页珍藏版)》请在冰豆网上搜索。
![正则表达式解释器实现原理.docx](https://file1.bdocx.com/fileroot1/2023-2/9/25abd3b2-641e-4ad3-bfa5-268fec5c05d5/25abd3b2-641e-4ad3-bfa5-268fec5c05d51.gif)
正则表达式解释器实现原理
正则表达式解释器实现原理1
以JavaScript正则为例
Author:
tuiye@
正则表达式可以用来:
(1)验证字符串是否符合指定特征,比如验证是否是合法的邮件地址。
(2)用来查找字符串,从一个长的文本中查找符合指定特征的字符串,比查找固定字符串更加灵活方便。
(3)用来替换,比普通的替换更强大。
对于一个正则表达式一般有2种方式,以JS为例
其一为使用正则表达式文字常量:
varre=/^[Jj]ava[Ss]cript/i;
其二为使用RegExp构造函数:
varre=newRegExp(“^[Jj]ava[Ss]cript”,”i”);
而一个正则表达式解释器主要有3部分组成,分别是解析(parse)、编译(compile)与执行(execute)。
1 解析
正则的表达式的词法与语法比较简单,基本语法如下:
A)普通字符和元字符
普通字符是那些表示自身的字符,例如从a到z,A到Z,0到9等;
元字符具有特殊意义,如‘.’,表示除了‘/n’外的所有字符,其他具有此功能的有
表1 元字符
元字符
特殊意义
^
匹配输入字符串的开始位置。
要匹配 "^" 字符本身,请使用 "/^"
$
匹配输入字符串的结尾位置。
要匹配 "$" 字符本身,请使用 "/$"
.
匹配除了换行符(/n)以外的任意一个字符。
要匹配小数点本身,请使用 "/."
*
修饰匹配次数为 0 次或任意次。
要匹配 "*" 字符本身,请使用 "/*"
+
修饰匹配次数为至少 1 次。
要匹配“+” 字符本身,请使用 “/+”
?
修饰匹配次数为 0 次或 1 次。
要匹配 “?
” 字符本身,请使用 “/?
”
=
用于前向引用或向后引用
!
用于前向引用或向后引用
:
用于前向引用或向后引用
|
用于前向引用或向后引用
/
转义用
/
用于前向引用或向后引用
()
标记一个子表达式的开始和结束位置。
要匹配小括号,请使用 “/(“和 “/)”
[]
用来自定义能够匹配 ‘多种字符’的表达式。
要匹配中括号,请使用“/[“ 和 “/]”
{}
修饰匹配次数的符号。
要匹配大括号,请使用 “/{“ 和 “/}”
元数据如要表示自身,那么需要用’/’来辅助转义
B)字符类
单个的字符可以组成字符类,其语法为用’[’与’]’组成,例如[abcA-Z79]表示可以匹配a,b,c与A到Z,7,9的字符
其中’-’为连字符,表示字符的跨度。
‘^’在”[]”间也是特殊字符,表示取反
其他的特殊字符如下表:
表2 字符类中的预定义字符类
预定义字符类
特殊意义
^
在紧跟’[’表示取反,表示自身要转义
-
在字符间,表示连字符,如要表示自身,须紧接在’[’或’[^’之后
.
小数点可以匹配除了换行符(/n)以外的任意一个字符
/d
可以匹配任何一个 0~9 数字字符
/D
D大写,可以匹配任何一个非数字字符
/s
可以匹配空格、制表符、换页符等空白字符的其中任意一个
/S
S大写,可以匹配任何一个空白字符以外的字符
/w
可以匹配任何一个字母或者数字或者下划线
/W
W大写,可以匹配任何一个字母或者数字或者下划线以外的字符
JavaScript无POSIX格式
C)限定符(重复)
限定符有2种形式,分别为’*’,’+’,’?
’与’{’与’}’来表示
表3 限定符
限定符
特殊意义
*
表达式尽可能的多匹配,最少可以不匹配,相当于 {0,}
+
表达式尽可能的多匹配,至少匹配1次,相当于 {1,}
?
表达式尽可能匹配1次,也可以不匹配,相当于 {0,1}
{m,n}
表达式尽可能重复n次,至少重复m次:
"ba{1,3}"可以匹配"ba"或"baa"或"baaa"
{m}
表达式固定重m次,比如:
"/w{2}" 相当于 "/w/w"
{m,}
表达式尽可能的多匹配,至少重复m次:
"/w/d{2,}"可以匹配"a12","x456"...
在正则中有贪婪与非贪婪之分,默认的情况下,正则是贪婪的
如果要把正则设置为非贪婪有2种方式,一种为设置在原先的限定符加上’?
’就行,另一种在设置
举例说明,/.+/ 将匹配"abdddd"中的所有字符,/.+?
/ 只将匹配"abdddd"中的第一个a,也就是默认的尽可能多的匹配字符,而非贪婪重复则尽可能上的匹配。
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,而后一个可以。
究其原因就是引用处的配平必须与被引用处一致,此例中与之匹配的可以是aceac。
E)定位符(锚)和前向引用
定位符如下表所示
表4 定位符
限定符
特殊意义
^
匹配输入字符串的开始位置。
要匹配 "^" 字符本身
$
匹配输入字符串的结尾位置。
要匹配 "$" 字符本身
?
表达式尽可能匹配1次,也可以不匹配,相当于 {0,1}
/b
匹配单词边界,例如一个/w和/W的位置,或者一个/w与字符串的开始和结尾的位置
/B
和上面的想法,匹配一个非单词边界
如果正则表达式的匹配模式为 MULTILINE 模式,^ 可匹配一行文本的行首,$ 可匹配一行文本的行末。
当 /b 被包含于字符集合中时,/b 代表退格符(ASCII码 =8)。
除了这些预定义的定位符,还可以自定义定位符,这种类型的定位符叫做前向引用(look-aheadanchor)和后向引用(look-behindanchor,JavaScript不支持)。
前向引用使用(?
=…)表示正的前向引用,(?
!
…)表示负的前向引用下面是一个前向引用的例子/Java(?
!
Script)([A-Z]/w*)/ 其中(?
!
Script)匹配后面不跟Script的位置,而(?
=Script)匹配后面是Script的位置。
以上讲解了JavaScript的语法规则,下面我们来论述一下解析的过程。
解析的过程是语法分析(LexicalAnalysis)与词法分析(GrammarAnalysis)。
2 编译
编译(Compile)阶段,主要的工作就是生成字节流(EmitByteCode)。
而生成ByteCode的算法(规则)JS中就是NFA。
生成的ByteCode是归于执行(Execute)时做匹配利用。
各个状态即为正则中的语义(OPCODE)的表示,各个OPCODE以一定的格式与关系住成了状态机,JS中是组成NFA的状态机。
下面介绍下在流行的两种算法NFA(NondeterministicFiniteAutomaton)与DFA(DeterministicFiniteautomaton),Perl,Python,JS等都是NFA的,而awk与grep等用的是DFA,两种算法的具体实现如下:
1)有限状态机(FiniteAutomation)
状态机是一个有一组不同状态的集合的系统。
有一个特殊状态――它描述了系统的初始状态。
而其他的一个或多个状态为终止状态;当一个事件将我们带到这样的一些状态时,状态机将退出。
状态是与转换相关联的,每个转换都标注有输入事件的名称。
当事件发生时,我们将随着相关的转换从当前状态移动到新的状态。
一个有限状态机包含一组状态集(states)、一个起始状态(startstate)、一组输入符号集(alphabet)、一个映射输入符号和当前状态到下一状态的转换函数(transitionfunction)的计算模型。
当输入符号串,模型随即进入起始状态。
它要改变到新的状态,依赖于转换函数。
假定一个输入符号(symbol),可以得到2个或者2个以上的可能状态,那么这个finiteautomaton就是不确定的,反之就是确定的。
一个正则可以与一个FA等同,其转化的规律如下
对于单个字符的
两个状态的连接e1e2
对于e?
对于e1|e2
对于e*
对于e+
2)不确定有限状态机(NFA)
例如要匹配abab|abbb,其NFA的状态是
3)确定性有限状态机(DFA)
以上例子的DFA如下
其中s1-s10为各个的状态对应于NFA中的s1-s10
3 执行
1)NFA
那么一个abbb字符串的匹配过程如下:
一个更加高效的方式是同步匹配两者:
这里我们看到,是利用正则表达式来扫描要匹配的字符串,又由于此时是不确定状态机,所以利用试探与backing的方式来做匹配的。
NFA是由正则来做驱动匹配的。
这就像一个过程语言,控制了解析器在匹配中的try/fail。
2)DFA
而确定性状态机相反,由于对于相应的输入都有一定的状态的迁移,所以总的来说,DFA的匹配效率要高一些。
DFA是由字符串作驱动来匹配的,在每个字符串中的每个字符只被扫描一次。
这种方式就是尝试此状态时可能的每种输入同时进行匹配。
4 实践
见JS1.6与PCRE7.2
1)JS1.6
以 /.*ht*p{0,3}/ 为例来说明JS1.6与PCRE7.2的NFA组成
JS1.6中,基本上以一个字符(广义上的字符,比如/n我们认为是回车字符)以一个节点建立RENode。
比如例子中,我们建立了7个RENode,依次为’.’,’*’,’h’,’t’,’*’,’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节点顺序如下图
*
0
4
.
ENDCHILD
FLAT
h
*
0
5
FLAT
t
END
CHILD
QUANT
0
3
0
5
FLAT1
P
END
即
JS1.6中编译的过程就为生成NFA的过程,主要是调整生成OP_ALT,OP_BACKREF,OP_STAR等跳转关系
编译后,生成NFA
(注:
此例中上下行为父子关系)
REOP_STAR
REOP_FLAT1
REOP_STAR
REOP_QUANT
REOP_DOT’.’
‘h’
REOP_FLAT’t’
REOP_FLAT‘p’
即
执行(匹配)的过程,我们以匹配字符串”xhtttpps”
匹配中的OPCODE
匹配中的匹配位置与状态
成功与否
Start(保存,MatchBack用)
|xhtttpps
|
|xhtttpps
.*
True
.*
xhtttpps
True
…
…
…
.*
False
h
False
…
…
…
h
False(则回朔)
h
True
t
True
…
…
…
t
False(则回朔)
p
True
p
True
Done
s
True
注:
其中’|’代表匹配所在的位置,’<’代表匹配成功开始,’>’代表匹配成功结束。
所以上述正则可匹配上述字符串中的”xhtttpp”
2)PCRE7.2
以/ht*p{0,2}/为例
PCRE的解析与编译是合而为一的,也就是说,解析编译后生成的OPCODE即为最终的NFA。
这里NFA与JS1.6中的NFA是形式是一样的,当然细节上有区别。
因此其匹配的过程也是相似。
当然PCRE也提供理论部分的DFA作为其状态机。
(待续)
以上的正则解析编译后的以如下格式存在。
OP
BRA
0
11
OP
CHAR
h
OP
STAR
t
OP
UPTO
0
2
p
OP
KET
0
11
OP
END
由于其NFA与JS有一致性,这里不再重复,倒是其Match时的一个消递归的方式比较不错,下面来做一个小的说明。
基本思想是这样的,因为我们递归的时候每次都要保存一些变量与“栈”上,这样过多的嵌套就会引起很大的变量于“栈”上,而且由于某些操作系统对“栈”的大小是有限制的,这就在一定的时候会引起“栈”溢出,从而到时程序运行问题,常见的就是Crash。
一般比较常用的消递归的方式主要有2种,其一是无限循环,其二就是自己从“堆”上保存自己的变量。
这里用了第二种方式。
以函数直接调用自身这种方式来说明。
那么在此函数中定有一处或几处是调用到自身的,在调用自身处,在“堆”上分配出空间frame,用于保存当前的变量的值,并把当前frame压入自己的frame栈(数据结构中的栈,与上面提到的“栈”不同),并且在此设定一个label(标签,用于RETURN时候的goto到此用)。
并且goto至函数的入口处,此时犹如一个函数的新调用,而且可以减少调用函数的开销。
当函数执行完(比如匹配不成功,需要回退;或者subpattern执行完毕)时候,我们需要做“返回”。
返回前,我们必须保存执行到此时的一个“结果”,如函数的返回值类似。
然后就是取出要返回的label的位置,用goto到那里,把当前的frame销毁,继续执行上一步中未完成的部分。
大体上就是这样。
理解正则表达式2
rex注:
本文原作者孟岩,原文转自孟岩CSDN博客。
本文为《程序员》07年3月号《七种武器》专题所做。
rex以前只是知道如何使用正则表达式而已,在读MRE时,读到NFA、DFA其实都是一头雾水,不知所云。
现在抓紧时间恶补基础知识,学习了些编译原理,这才有些明白。
如今再看从源头讲正则表达式的文章,就心有戚戚了,呵呵。
搜到好文章一篇,与大家分享。
在程序员日常工作中,数据处理占据了相当的比重。
而在所有的数据之中,文本又占据了相当的比重。
文本能够被人理解,具有良好的透明性,利于系统的开发、测试和维护。
然而,易于被人理解的文本数据,机器处理起来就不一定都那么容易。
文本数据复杂多变,特定性强,甚至是千奇百怪。
因此,文本处理程序可谓生存环境恶劣。
一般来说,文本处理程序都是特定于应用的,一个项目有一个项目的要求,彼此之间很难抽出共同点,代码很难复用,往往是“一次编码,一次运行,到处补丁”。
其程序结构散乱丑陋,谈不上有什么“艺术性”,基本上与“模式”、“架构”什么的无缘。
在这里,从容雅致、温文尔雅派不上用场,要想生存就必须以暴制暴。
事实上,几十年的实践证明,除了正则表达式和更高级的parser技术,在这样一场街头斗殴中别无利器。
而其中,尤以正则表达式最为常用。
所以,对于今天的程序员来说,熟练使用正则表达式着实应该是一种必不可少的基本功。
然而现实情况却是,知道的人很多,善于应用的人却很少,而能够洞悉其原理,理智而高效地应用它的人则少之又少。
大多数开发者被它的外表吓倒,不敢也不耐烦深入了解其原理。
事实上,正则表达式背后的原理并不复杂,只要耐心学习,积极实践,理解正则表达式并不困难。
下面列举的一些条款,来自我本人学习和时间经验的不完全总结。
由于水平和篇幅所限,只能浮光掠影,不足和谬误之处,希望得到有识之士的指教。
1. 了解正则表达式的历史
正则表达式萌芽于1940年代的神经生理学研究,由著名数学家StephenKleene第一个正式描述。
具体地说,Kleene归纳了前述的神经生理学研究,在一篇题为《正则集代数》的论文中定义了“正则集”,并在其上定义了一个代数系统,并且引入了一种记号系统来描述正则集,这种记号系统被他称为“正则表达式”。
在理论数学的圈子里被研究了几十年之后,1968年,后来发明了UNIX系统的KenThompson第一个把正则表达式用于计算机领域,开发了qed和grep两个实用文本处理工具,取得了巨大成功。
在此后十几年里,一大批一流计算机科学家和黑客对正则表达式进行了密集的研究和实践。
在1980年代早期,UNIX运动的两个中心贝尔实验室和加州大学伯克利分校分别围绕grep工具对正则表达式引擎进行了研究和实现。
与之同时,编译器“龙书”的作者AlfredAho开发了Egrep工具,大大扩展和增强了正则表达式的功能。
此后,他又与《C程序设计语言》的作者BrianKernighan等三人一起发明了流行的awk文本编辑语言。
到了1986年,正则表达式迎来了一次飞跃。
先是C语言顶级黑客HenrySpencer以源代码形式发布了一个用C语言写成的正则表达式程序库(当时还不叫opensource),从而把正则表达式的奥妙带入寻常百姓家,然后是技术怪杰LarryWall横空出世,发布了Perl语言的第一个版本。
自那以后,Perl一直是正则表达式的旗手,可以说,今天正则表达式的标准和地位是由Perl塑造的。
Perl5.x发布以后,正则表达式进入了稳定成熟期,其强大能力已经征服了几乎所有主流语言平台,成为每个专业开发者都必须掌握的基本工具。
2. 掌握一门正则表达式语言
使用正则表达式有两种方法,一种是通过程序库,另一种是通过内置了正则表达式引擎的语言本身。
前者的代表是Java、.NET、C/C++、Python,后者的代表则是Perl、Ruby、JavaScript和一些新兴语言,如Groovy等。
如果学习正则表达式的目标仅仅是应付日常应用,则通过程序库使用就可以。
但只有掌握一门正则表达式语言,才能够将正则表达式变成编程的直觉本能,达到较高的水准。
不但如此,正则表达式语言也能够在实践中提供更高的开发和执行效率。
因此,有心者应当掌握一门正则表达式语言。
3. 理解DFA和NFA
正则表达式引擎分成两类,一类称为DFA(确定性有穷自动机),另一类称为NFA(非确定性有穷自动机)。
两类引擎要顺利工作,都必须有一个正则式和一个文本串,一个捏在手里,一个吃下去。
DFA捏着文本串去比较正则式,看到一个子正则式,就把可能的匹配串全标注出来,然后再看正则式的下一个部分,根据新的匹配结果更新标注。
而NFA是捏着正则式去比文本,吃掉一个字符,就把它跟正则式比较,匹配就记下来:
“某年某月某日在某处匹配上了!
”,然后接着往下干。
一旦不匹配,就把刚吃的这个字符吐出来,一个个的吐,直到回到上一次匹配的地方。
DFA与NFA机制上的不同带来5个影响:
1. DFA对于文本串里的每一个字符只需扫描一次,比较快,但特性较少;NFA要翻来覆去吃字符、吐字符,速度慢,但是特性丰富,所以反而应用广泛,当今主要的正则表达式引擎,如Perl、Ruby、Python的re模块、Java和.NET的regex库,都是NFA的。
2. 只有NFA才支持lazy和backreference等特性;
3. NFA急于邀功请赏,所以最左子正则式优先匹配成功,因此偶尔会错过最佳匹配结果;DFA则是“最长的左子正则式优先匹配成功”。
4. NFA缺省采用greedy量词(见item4);
5. NFA可能会陷入递归调用的陷阱而表现得性能极差。
我这里举一个例子来说明第3个影响。
例如用正则式/perl|perlman/来匹配文本‘perlmanbook’。
如果是NFA,则以正则式为导向,手里捏着正则式,眼睛看着文本,一个字符一个字符的吃,吃完‘perl’以后,跟第一个子正则式/perl/已经匹配上了,于是记录在案,往下再看,吃进一个‘m’,这下糟了,跟子式/perl/不匹配了,于是把m吐出来,向上汇报说成功匹配‘perl’,不再关心其他,也不尝试后面那个子正则式/perlman/,自然也就看不到那个更好的答案了。
如果是DFA,它是以文本为导向,手里捏着文本,眼睛看着正则式,一口一口的吃。
吃到/p/,就在手里的‘p’上打一个钩,记上一笔,说这个字符已经匹配上了,然后往下吃。
当看到/perl/之后,DFA不会停,会尝试再吃一口。
这时候,第一个子正则式已经山穷水尽了,没得吃了,于是就甩掉它,去吃第二个子正则式的/m/。
这一吃好了,因为又匹配上了,于是接着往下吃。
直到把正则式吃完,心满意足往上报告说成功匹配了‘perlman’。
由此可知,要让NFA正确工作,应该使用/perlman|perl/模式。
通过以上例子,可以理解为什么NFA是最左子式匹配,而DFA是最长左子式匹配。
实际上,如果仔细分析,关于NFA和DFA的不同之处,都可以找出道理。
而明白这些道理,对于有效应用正则表达式是非常有意义的。
4. 理解greedy和lazy量词
由于日常遇到的正则表达式引擎全都是NFA,所以缺省都采用greedy量词。
Greedy量词的意思不难理解,就是对于/.*/、/\w+/这样的“重复n”次的模式,以贪婪方式进行,尽可能匹配更多字符,直到不得以罢手为止。
举一个例子,以/<.*>/模式匹配‘PerlHacks\t’文本,匹配结果不是‘’,而是‘PerlHacks’。
原因就在于NFA引擎以贪婪方式执行“重复n次”的命令。
让我们来仔细分析一下这个过程。
条款3指出,NFA的模型是以正则式为导向,拿着正则式吃文本。
在上面的例子里,当它拿着/.*/这个正则式去吃文本的时候,缺省情况下它就这么一路吃下去,即使碰到‘>’字符也不罢手——既然/./是匹配任意字符,‘>’当然也可以匹配!
所以就尽管吃下去,直到吃完遇到结尾(包括\t字符)也不觉得有什么不对。
这个时候它突然发现,在正则表达式最后还有一个/>/,于是慌了神,知道吃多了,于是就开始一个字符一个字符的往回吐,直到吐出倒数第二个字符‘>’,完成了与正则式的匹配,才长舒一口气,向上汇报,匹配字符串从第一个字符‘<’开始,到倒数第二个字符‘>’结束,即’Perl