zl程序教程

您现在的位置是:首页 >  后端

当前栏目

C#词法分析器之正则表达式的使用

c#正则表达式 使用 分析器 词法
2023-06-13 09:14:52 时间

正则表达式是一种描述词素的重要表示方法。虽然正则表达式并不能表达出所有可能的模式(例如“由等数量的a和b组成的字符串”),但是它可以非常高效的描述处理词法单元时要用到的模式类型。

一、正则表达式的定义
正则表达式可以由较小的正则表达式按照规则递归地构建。每个正则表达式r 表示一个语言L(r),而语言可以认为是一个字符串的集合。正则表达式有以下两个基本要素:

1.ϵ 是一个正则表达式,L(ϵ)=ϵ,即该语言只包含空串(长度为0的字符串)。
2.如果a 是一个字符,那么a 是一个正则表达式,并且L(a)={a},即该语言只包含一个长度为1 的字符串a。
由小的正则表达式构造较大的正则表达式的步骤有以下四个部分。假定r 和s 都是正则表达式,分别表示语言L(r) 和L(s),那么:

1.(r)|(s) 是一个正则表达式,表示语言L(r)∪L(s),即属于L(r) 的字符串和属于L(s) 的字符串的集合(L(r)∪L(s)={s|s∈L(r)ors∈L(s)})。
2.(r)(s) 是一个正则表达式,表示语言L(r)L(s),即从L(r) 中任取一个字符串,再从L(s) 中任取一个字符串,然后将它们连接后得到的所有字符串的集合(L(r)L(s)={st|s∈L(r)andt∈L(s)})。
3.(r)∗ 是一个正则表达式,表示语言L(r)∗,即将L(r) 连接0 次或多次后得到的语言。
4.(r) 是一个正则表达式,表示语言L(r)。
上面这些规则都是由Kleene在20世纪50年代提出的,在之后有出现了很多针对正则表达式的扩展,他们被用来增强正则表达式表述字符串模式的能力。这里采用是类似Flex的正则表达式扩展,风格则类似于.Net内置的正则表达式:

正则表达式 描述 x 单个字符x。 . 除了换行以外的任意单个字符。 [xyz] 一个字符类,表示"x","y","z"中的任意一个字符。 [a-z] 一个字符类,表示"a"到"z"之间的任意一个字符(包含"a"和"z")。 [^a-z] 一个字符类,表示除了[a-z]之外的任意一个字符。 [a-z-[b-f]] 一个字符类,表示[a-z]范围减去[b-f]范围的字符,等价于[ag-z]。  r*  将任意正则表达式r重复0次或多次。  r+  将r重复1次或多次。  r?  将r重复0次或1次,即“可选”的r。  r{m,n}  将r重复m次至n次(包含m和n)。  r{m,}  将r重复m次或多次(大于等于m次)。  r{m}  将r重复恰好m次。  {name}  展开预先定义的正则表达式“name”,可以通过预先定义一些正则表达式,以实现简化正则表达式。  "[xyz]\"foo"  原义字符串,表示字符串“[xyz]"foo”,用法与C#中定义字符串基本相同。  \X  表示X字符转义,如果X是"a","b","t","r","v","f","n"或"e",表示相应的ASCII字符;如果X是"w","W","s","S","d"或"D",则表示相应的字符类;否则表示字符X。  \nnn  表示使用八进制形式指定的字符,nnn最多由三位数字组成。  \xnn  表示使用十六进制形式指定的字符,nn恰好由两位数字组成。  \cX  表示X指定的ASCII控制字符。 \unnnn 表示使用十六进制形式指定的Unicode字符,nnnn恰好由四位数字组成。 \p{name} 表示name指定的Unicode通用类别或命名块中的单个字符。 \P{name} 表示除了name指定的Unicode通用类别或命名块之外的单个字符。 (r) 表示r本身。 (?r-s:pattern)

应用或禁用子正则表达式中指定的选项。选项可以是字符"i","s"或"x"。

"i"表示不区分大小写;"-i"表示区分大小写。
"s"表示允许"."匹配换行符;"-s"表示不允许"."匹配换行符。
"x"表示忽略模式中的空白和注释,除非使用"\"字符转义或者在字符类中,或者使用双引号("")括起来;"-x"表示不忽略空白。

以下下两列中的模式是等价的:

(?:foo) (foo) (?i:ab7) ([Aa][Bb]7) (?-i:ab) (ab) (?s:.) [\u0000-\uFFFF] (?-s:.) [^\n\r] (?ix-s:a.b) ([Aa][^\n\r][Bb]) (?x:ab) ("ab") (?x:a\b) ("ab") (?x:a""b) ("ab") (?x:a[]b) ("ab") (?x:a
   (?#comment)

   c) (abc)  (?#comment)  表示注释,注释中不允许出现右括号")"。  rs  r与s的连接。  r|s  r与s的并。 r/s 仅当r后面跟着s时,才匹配r。这里"/"表示向前看,s并不会被匹配。 ^r 行首限定符,仅当r在一行的开头时才匹配。 r$ 行尾限定符,仅当r在一行的结尾时才匹配。这里的行尾可以是"\n",也可以是"\r\n"。 <s>r 仅当当前是上下文s时才匹配r。 <s1,s2>r 仅当当前是上下文s1或s2时才匹配r。 <*>r 在任意上下文中匹配r。 <<EOF>> 表示在文件的结尾。 <s1,s2><<EOF>> 表示在上下文s1或s2时的文件的结尾。
这里与字符类和Unicode通用类别相关的知识请参考C#的正则表达式语言-快速参考中的“字符类”小节。大部分的正则表达式表示方法也与C#中的相同,有所不同的向前看(r/s)、上下文(<s>r)和文件结尾(<<EOF>>)会在之后的文章中解释。

利用上面的表格中列出扩展正则表达式,就可以比较方便的定义需要的模式了。不过有些需要注意的地方:

  1. 这里的定义不支持POSIXStyle的字符类,例如[:alnum:]之类的,与Flex不同。
  2. $匹配行尾,即可以匹配\n也可以匹配\r\n,也与Flex不同。
  3. 字符集的相减是C#风格的[a-z-[b-f]],而不是Flex那样的[a-c]{-}[b-z]。
  4. 向前看中的$只表示"$",而不再匹配行尾,例如a/b$仅当"a"后面是"b$"时才匹配"a"。

二、正则表达式的表示

虽然上面定义了正则表达式的规则,但它们表示起来却很简单,我使用Cyjb.Compiler.RegularExpressions命名空间下的8个类来表示任意的正则表达式,其类图如下所示:

图1正则表达式类图

其中,Regex类是正则表达式的基类,CharClassExp表示字符类(单个字符),LiteralExp表示原义文本(多个字符组成的字符串),RepeatExp表示正则表达式重复(可以重复上限至下限之间的任意次数),AlternationExp表示正则表达式的并(r|s),ConcatenationExp表示正则表达式的连接(rs),AnchorExp表示行首限定、行尾限定和向前看,EndOfFileExp表示文件的结尾(<<EOF>>)。

将CharClassExp、LiteralExp、RepeatExp、AlternationExp、ConcatenationExp这些类进行嵌套,就可以表示大部分正则表达式了;AnchorExp单独拿出来是因为它只能作为最外层的正则表达式,而不能位于其它正则表达式内部;EndOfFileExp则是专门用于<<EOF>>的。这里并未考虑上下文,因为上下文的处理并不在正则表达式这里,而是在之后的“终结符符定义”部分。

正则表达式的表示比较简单,但为了更加易用,有必要提供从字符串(例如"abc[0-9]+")转换为相应的正则表达式的转换方法。RegexCharClass类是System.Text.RegularExpressions.RegexCharClass类的包装,用于表示一个字符类,我对其中的某些函数进行了修改,以符合我这里的正则表达式定义。RegexOptions类和RegexParser类则是用于正则表达式解析的类,具体的解析算法太过复杂,就不多加解释。

三、正则表达式

正则表达式构造好后,就需要使用它去匹配词素。一个词法分析器可能需要定义很多正则表达式,还可能包括上下文以及行首限定符,处理起来还是比较复杂的。为了简便起见,我会首先讨论怎么用一条正则表达式去匹配字符串,在之后的文章中再讨论如何用组合多条正则表达式去匹配词素。

使用正则表达式匹配字符串,一般都会用到有穷自动机(finiteautomata)的表示方法。有穷自动机是识别器(recognizer),只能对每个可能的输入回答“是”或“否”,表示时候与此自动机相匹配。或者说,不断的读入字符,直到有穷自动机回答“是”,此刻就正确的匹配了一个字符串。

有穷自动机分为两类:

不确定的有穷自动机(NondeterministicFiniteAutomata,NFA)对其边上的标号没有任何限制。一个符号标记离开同一状态的多条边,并且空串$\epsilon$也可以作为标号。确定的有穷自动机(DeterministicFiniteAutomata,DFA)对于每个状态及自动机输入字母表中的每个符号有且只有一条离开该状态、以该符号为标号的边。

NFA和DFA可以识别的语言集合是相同的(后面会说到NFA如何转换为等价的DFA),并且这些语言的集合正好是能够用正则表达式描述的语言集合(正则表达式可以转换为等价的NFA)。因此,采用有穷自动机来识别正则表达式描述的语言,也是很自然的。

3.1不确定的有穷自动机NFA

一个不确定的有穷自动机(NFA)由以下几个部分组成:

    一个有穷的状态集合$S$。一个输入符号集合$\Sigma$,即输入字母表(inputalphabet)。我们假设空串$\epsilon$不是$\Sigma$中的元素。一个转换函数(transitionfunction),它为每个状态和$\Sigma\cup\{\epsilon\}$的每个符号都给出了相应的后继状态(nextstate)的集合。$S$中的一个状态$s_0$被指定为开始状态,或者说初始状态。$S$的一个子集$F$被指定为接受状态(或者说终止状态)的集合。

下图就是一个能识别正则表达式(a|b)*baa的语言的NFA,边上的字母就是该边的标号。

图2NFA实例

NFA的匹配过程很直观,从起始状态开始,每读入一个符号,NFA就可以沿着这个符号对应的边前进到下一个状态($\epsilon$边不用读入符号也可以前进,当然也可以不前进),就这样不断读入符号,直到所有符号都读入进来,如果最后到达的是接受状态,那么匹配成功,否则匹配失败。

在状态1上,有两条标号为b的边,一条指向状态1,一条指向状态2,这就使自动机产生了不确定性——当到达状态1时,如果读入的字符是"b",那么并不能确定应该转移到状态1还是2,此时就需要使用集合保存所有可能的状态,把它们都尝试一遍才可以。

接下来尝试用这个NFA去匹配字符串"ababaa"。

步骤当前节点读入字符转移到节点1{0,1}a{1}2{1}b{1,2}3{1,2}a{1,3}4{1,3}b{1,2}5{1,2}a{1,3}6{1,3}a{1,4}

此时字符串已经全部读入,最后到达了状态1和4,其中状态4是一个接受状态,因此NFA返回结果“是”。

使用NFA进行模式匹配的时间复杂度是$O(k(n+m))$,其中$k$为要匹配的字符串的长度,$n$为NFA中的状态数,$m$为NFA中的转移数。可见,NFA的效率与输入字符串的长度和NFA的大小成正比,效率并不高。

3.2确定的有穷自动机DFA

确定的有穷自动机(DFA)是NFA的一个特例,其中:

    没有输入$\epsilon$之上的转换动作。对每个状态$s$和每个输入符号$a$,有且只有一条标号为$a$的边离开。

因此,NFA抽象的表示了用来识别某个语言中串的算法,而相应的DFA则是具体的识别串的算法。

下图是同样识别正则表达式(a|b)*baa的语言的DFA,看起来比NFA的要复杂不少。

图3DFA实例

DFA的匹配过程则更加简单,因为没有了$\epsilon$转换和不确定的转换,只要从起始状态开始,每读入一个符号,就直接沿着这个符号对应的边前进到下一个状态(这个状态是唯一的),就这样不断读入符号,直到所有符号都读入进来,如果最后到达的是接受状态,那么匹配成功,否则匹配失败。

接下来尝试用这个DFA去匹配字符串"ababaa"。

步骤当前节点读入字符转移到节点10a020b131a242b151a262a3

此时字符串已经全部读入,最后到达了状态3,是一个接受状态,因此DFA返回结果“是”。

使用DFA进行模式匹配的时间复杂度是$O(k)$,其中$k$为要匹配的字符串的长度,可见,DFA的效率只与输入字符串的长度有关,效率非常高。

3.3为什么使用DFA

上面介绍的NFA和DFA识别语言的能力是相同的,但在词法分析中实际使用的都是DFA,是有下面几种原因。

    NFA的匹配效率比不过DFA的,词法分析器显然运行的越快越好。虽然DFA的构造则要花费很长时间,一般是$O(r^3)$,最坏情况下可能会是$O(r^22^r)$,但在词法分析器这一特定领域中,DFA只需要构造一次,就可以多次使用,而且Flex可以在生成源代码的时候就构造好DFA,耗点时间也没有关系。DFA在最坏情况下可能会使状态个数呈指数增长,《编译原理》上给出了一个例子$(a|b)*a(a|b)^{n-1}$,识别这个正则表达式的NFA具有$n+1$个状态,而DFA却至少有$2^n$个状态,不过这么特殊的情况在编程语言中基本不会见到,不用担心这一点。

不过NFA还是有用的,因为DFA要利用NFA,通过子集构造法得到;将正则表达式转换为NFA,也有助于理解如何处理多条正则表达式和处理向前看。下一篇文章就开始介绍NFA的表示以及如何将正则表达式转换为NFA。