简易正则表达式引擎源码阅读

  第一篇博客。分析一下一个简单的正则表达式引擎的实现。这个引擎是Ozan S. Yigit(Dept. of Computer Science, York University)根据4.nBSD UN*X中的regex routine编写的,在他的个人主页上可以找到源码。引擎支持的特性不多,但源码不到1000行,而且是典型的compile-execute模式,逻辑清晰,对理解正则表达式的工作原理很有帮助。

1 受支持的特性

  引擎支持的正则表达式特性如下。

字符解释 
 常规字符

 除了元字符(. [ ] * + ^ $)以外的字符。

匹配字符本身

 . 匹配任意字符 
 [set]

character class。 匹配所有set中的字符。

如果set的第一个字符是^,表示匹配所有不在set内的字符。

快捷写法S-E表示匹配从字符S到字符E的所有字符。

例:

[a-z] 匹配 任意一个小写字母

[^]-] 匹配 除了 "]" 和 "-" 以外的任意字符

[^A-Z] 匹配 除了大写字母以外的任意字符

[a-zA-Z] 匹配 任意字母

*

closure。匹配零个或多个指定符号。

它只能紧跟在常规字符、"." 、character class或closure的后面。

它会尽可能多地匹配满足条件的字符。

+

匹配一个或多个指定符号。其他规则与*相同。

(group)

captured group。匹配group中的符号。

在其后的表达式中可以使用1 ~ 9引用这个group

1 ~ 9 引用之前匹配的group
<

匹配单词开头的边界。

单词是一个或多个由字母、数字和下划线组成的序列。

> 匹配单词末尾的边界。
其他字符 匹配字符本身。主要用于对元字符进行转义
^

如果^是表达式的第一个字符,表示匹配字符串的开头;

否则将匹配^字符本身。

$

如果$是表达式的最后一个字符,表示匹配字符串的末尾;

否则将匹配$字符本身。

2 工作原理

  引擎首先将表达式编译为NFA,然后使用这个NFA匹配字符串。

2.1 编译

  引擎扫描整个表达式,并生成一个NFA。NFA的结构是一个由opcode组成的序列。所有opcode如下。

 opcodeoperand 解释
 CHR 字符 匹配一个字符
ANY   匹配任意一个字符
CCL bitset

character class。

操作数为16 byte的bitset,其中每个bit都

与一个ASCII字符匹配。

BOL    匹配字符串开头
EOL   匹配字符串末尾
BOT 1~9 标识一个captured group的开头
EOT 1~9 标识一个captured group的结尾
BOW   匹配单词开头的边界
EOW   匹配单词末尾的边界
REF 1~9 引用group
CLO  

closure。

一个CLO ... END pair所包含的内容为closure的内容。

END   标识NFA结束或closure结束

  例:

  表达式: foo*.* 

  NFA:  CHR f CHR o CLO CHR o END CLO ANY END END 

  匹配:  fo foo fooo foobar fobar foxx ... 

  表达式: fo[ob]a[rz] 

  NFA:  CHR f CHR o CCL bitset CHR a CCL bitset END 

  匹配:  fobar fooar fobaz fooaz 

  表达式: foo\+ 

  NFA:  CHR f CHR o CHR o CHR CLO CHR END END    -- x+ 被转换成 xx* 

  匹配:  foo foo\ foo\ ... 

  表达式: (foo)[1-3]1 

  NFA:  BOT 1 CHR f CHR o CHR o EOT 1 CCL bitset REF 1 END 

  匹配:  foo1foo foo2foo foo3foo 

  表达式: (fo.*)-1 

  NFA:  BOT 1 CHR f CHR o CLO ANY END EOT 1 CHR - REF 1 END 

  匹配:  foo-foo fo-fo fob-fob foobar-foobar ... 

  编译生成的NFA保存在一个全局char数组中:

#define MAXNFA  1024
static CHAR nfa[MAXNFA];

  re_comp()函数接受一个表达式字符串并编译生成NFA。如果编译失败,则返回错误信息的字符串,否则返回0。

 1 char *re_comp(char *pat) {
 2     char *p;               /* pattern pointer   */
 3     CHAR *mp = nfa;          /* nfa pointer       */
 4     CHAR *lp;              /* saved pointer..   */
 5     CHAR *sp = nfa;          /* another one..     */
 6 
 7     int tagi = 0;          /* tag stack index   */
 8     int tagc = 1;          /* actual tag count  */
 9 
10     int n;
11     CHAR mask;        /* xor mask CCL */
12     int c1, c2;

  p作为遍历字符串pat的指针使用。mp作为写入nfa数组的指针使用,它始终指向最近写入的数据的下一个位置。lp和sp用于保存mp的位置。

  tagi是一个stack的top指针,这个stack保存了当前所处的group编号:

#define MAXTAG  10
static int  tagstk[MAXTAG];

  tagc是group编号的counter。

  剩下的局部变量都作为临时变量。

13     for (p = pat; *p; p++) {
14         lp = mp;
15         switch (*p) {
16         case '.':               /* match any char..  */
17             store(ANY);
18             break;

  生成NFA的主循环。

  首先使用lp保存当前mp,在for循环末尾再将lp赋值给sp。sp保存了上一个opcode位置的指针。

  15行的switch根据不同的字符模式生成opcode和operand。

  17行从"."字符生成ANY opcode,store()是一个写入mp的宏:

#define store(x)    *mp++ = x
19         case '^':               /* match beginning.. */
20             if (p == pat)
21                 store(BOL);
22             else {
23                 store(CHR);
24                 store(*p);
25             }
26             break;
27         case '$':               /* match endofline.. */
28             if (!p[1])
29                 store(EOL);
30             else {
31                 store(CHR);
32                 store(*p);
33             }
34             break;

  如果^字符是字符串的第一个字符,那么生成 BOL ,否则当作常规字符处理,生成 CHR ^ 。对$字符的处理也类似。

35         case '[':               /* match char class..*/
36             store(CCL);
37             if (*++p == '^') {
38                 mask = 0377;    
39                 p++;
40             }
41             else
42                 mask = 0;
43 
44             if (*p == '-')        /* real dash */
45                 chset(*p++);
46             if (*p == ']')        /* real brac */
47                 chset(*p++);
48             while (*p && *p != ']') {
49                 if (*p == '-' && p[1] && p[1] != ']') {
50                     p++;
51                     c1 = p[-2] + 1;
52                     c2 = *p++;
53                     while (c1 <= c2)
54                         chset((CHAR)c1++);
55                 } else
56                     chset(*p++);
57             }
58             if (!*p)
59                 return badpat("Missing ]");
60 
61             for (n = 0; n < BITBLK; bittab[n++] = (char) 0)
62                 store(mask ^ bittab[n]);
63     
64             break;

  35~64行处理character class。

  37行判断"["字符后的第一个字符是否是"^",如果是,那么需要在最后bitwise not整个bitset,这里用了一个mask,在最后会将bitset的每个byte和mask做xor操作(62行)。

  44行和46行的两个if对出现在character class开头的"-"和"]"字符当作常规字符处理。chset()函数传入一个ASCII字符,将一个临时bitset的对应bit置为1:

static void chset(CHAR c) {
    bittab[(CHAR) ((c) & BLKIND) >> 3] |= bitarr[(c) & BITIND];
}

  chset()函数中涉及的全局变量和宏定义如下:

#define MAXCHR    128
#define CHRBIT    8
#define BITBLK    MAXCHR/CHRBIT
#define BLKIND    0170
#define BITIND    07

static CHAR bittab[BITBLK];
static CHAR bitarr[] = {1,2,4,8,16,32,64,128};

  bittab是一个临时bitset,程序在处理character class时首先将bit信息写入bittab,最后再将bittab写入nfa作为 CCL 的operand。

  MAXCHAR是一个ASCII字符所需的bitset空间,BITBLK是一个bitset的字节大小,即MAXCHR/CHRBIT = 128 / 8 = 16字节。

  回到case ']'的代码。48行的while循环处理方括号内的字符。49行判断S-E形式,51行获取S的ASCII,之所以要加1(c1 = p[-2] + 1)是因为在上一次循环中已经将S字符的bit置为1了,不再需要置1。52行获取E字符的ASCII,53行的while循环将S~E字符区间的bit全部置为1。

  56行处理除了S-E形式以外的常规字符。

  61行将bittab的每个字节和mask做xor操作后写入nfa。

65         case '*':               /* match 0 or more.. */
66         case '+':               /* match 1 or more.. */
67             if (p == pat)
68                 return badpat("Empty closure");
69             lp = sp;        /* previous opcode */
70             if (*lp == CLO)        /* equivalence..   */
71                 break;
72             switch(*lp) {
73             case BOL:
74             case BOT:
75             case EOT:
76             case BOW:
77             case EOW:
78             case REF:
79                 return badpat("Illegal closure");
80             default:
81                 break;
82             }
83 
84             if (*p == '+')
85                 for (sp = mp; lp < sp; lp++)
86                     store(*lp);
87 
88             store(END);
89             store(END);
90             sp = mp;
91             while (--mp > lp)
92                 *mp = mp[-1];
93             store(CLO);
94             mp = sp;
95             break;

  65行和66行的两个case处理closure。69行将上一个opcode的指针赋值给lp。

  70行判断如果出现了两个连续的closure(x**的形式),那么会忽略当前closure。

  72行的switch限制closure所包含的内容,必须为常规字符、"."、character class或closure。

  84行将x+形式转换为xx*形式,即将上一个opcode和operand复制一遍(lp ~ mp - 1)。

  88行和89行添加两个 END ,其中一个是为了后面插入 CLO 预留空间用的。90行~94行将上一个opcode后移一字节,并在空出的位置插入 CLO 

 96         case '\':              /* tags, backrefs .. */
 97             switch(*++p) {
 98             case '(':
 99                 if (tagc < MAXTAG) {
100                     tagstk[++tagi] = tagc;
101                     store(BOT);
102                     store(tagc++);
103                 }
104                 else
105                     return badpat("Too many \(\) pairs");
106                 break;
107             case ')':
108                 if (*sp == BOT)
109                     return badpat("Null pattern inside \(\)");
110                 if (tagi > 0) {
111                     store(EOT);
112                     store(tagstk[tagi--]);
113                 }
114                 else
115                     return badpat("Unmatched \)");
116                 break;

  98行的case处理"("。100行将当前group counter值压入tagstk,然后生成 BOT tagc 。

  107行处理")"。108行的if防止出现空的group。111行和112行生成 EOT x ,并从tagstk中弹出原group counter值。

117             case '<':
118                 store(BOW);
119                 break;
120             case '>':
121                 if (*sp == BOW)
122                     return badpat("Null pattern inside \<\>");
123                 store(EOW);
124                 break;

  上面的两个case处理"<"和">"。121行的if防止出现空的单词(<>)。

125             case '1': case '2': case '3': case '4': case '5':
126             case '6': case '7': case '8': case '9':
127                 n = *p-'0';
128                 if (tagi > 0 && tagstk[tagi] == n)
129                     return badpat("Cyclical reference");
130                 if (tagc > n) {
131                     store(REF);
132                     store(n);
133                 }
134                 else
135                     return badpat("Undetermined reference");
136                 break;

  处理group引用。128行防止出现循环引用(当前正位于某个group中时对这个group进行引用)。

  130行的if防止引用尚未存在的group。

137             default:
138                 store(CHR);
139                 store(*p);
140             }
141             break;

  对于"x"中x的其他情况,直接生成 CHR x 。

142         default :               /* an ordinary char  */
143             store(CHR);
144             store(*p);
145             break;
146         }

  如果*p是常规字符,生成 CHR x 。

147     if (tagi > 0)
148         return badpat("Unmatched \(");
149     store(END);150     return 0;
151 }

  在主循环结束后,判断表达式中的(和)是否匹配(tagi == 0)。最后向nfa写入一个 END 

2.2 匹配

  在生成NFA后,就可以用这个NFA对目标字符串进行匹配。

  

  这里要对NFA分三种情况:

  1) 开头是 BOL 。此时仅在字符串开头使用整个NFA进行一次匹配;

  

  2) 开头是 CHR x 。此时需要在字符串中找到字符x第一次出现的位置,然后从这个位置开始使用NFA进行匹配,如果匹配失败则从下一个位置开始使用NFA匹配;

  

  3) 其他情况。从字符串开头开始使用NFA匹配,若匹配失败则从字符串的第二个字符开始使用NFA匹配,以此类推。

  closure的处理

  closure要尽可能多地匹配符合条件的字符,因此要先跳过所有匹配的字符,从第一个不匹配的字符开始用剩余的NFA进行匹配,若匹配失败则向前移动一个字符,继续使用NFA匹配。

  

  函数re_exec()接受一个字符串,使用全局nfa进行匹配。若匹配成功则返回非0,并将所有匹配的group的start offset和end offset放入全局变量:

static char *bol;
char *bopat[MAXTAG];
char *eopat[MAXTAG];

  bopat和eopat分别保存group的start offset和end offset。其中group 0是整个匹配的字符串。bol在匹配的过程中保存字符串地址。

 1 int re_exec(char *lp) {
 2     CHAR c;
 3     char *ep = 0;
 4     CHAR *ap = nfa;
 5 
 6     bol = lp;
 7 
 8     memset(bopat, 0, sizeof (char *) * MAXTAG);
 9 
10     switch(*ap) {
11     case BOL:            /* anchored: match from BOL only */
12         ep = pmatch(lp,ap);
13         break;
14     case CHR:            /* ordinary char: locate it fast */
15         c = *(ap+1);
16         while (*lp && *lp != c)
17             lp++;
18         if (!*lp)        /* if EOS, fail, else fall thru. */
19             return 0;
20     default:            /* regular matching all the way. */
21         do {
22             if ((ep = pmatch(lp,ap)))
23                 break;
24             lp++;
25         } while (*lp);
26         break;
27     case END:            /* munged automaton. fail always */
28         return 0;
29     }
30     if (!ep)
31         return 0;
32 
33     bopat[0] = lp;
34     eopat[0] = ep;
35     return 1;
36 }

  10行的switch处理nfa的3种情况。如果nfa第一个opcode是 BOL ,从字符串开头进行一次匹配。pmatch()函数是使用NFA匹配字符串的核心函数,它返回匹配的字符串的end offset。如果第一个opcode是 CHR x ,16行的while将找到x字符第一次出现的位置,之后和第三种情况一样处理。其他情况下,21行的do-while循环将逐个以字符串的每个字符开始使用NFA匹配。在匹配完后,将start offset和end offset分别保存到bopat[0]和eopat[0]。

 1 static char *pmatch(char *lp, CHAR *ap) {
 2     int op, c, n;
 3     char *e;        /* extra pointer for CLO */
 4     char *bp;        /* beginning of subpat.. */
 5     char *ep;        /* ending of subpat..     */
 6     char *are;            /* to save the line ptr. */
 7 
 8     while ((op = *ap++) != END)
 9         switch(op) {
10         case CHR:
11             if (*lp++ != *ap++)
12                 return 0;
13             break;
14         case ANY:
15             if (!*lp++)
16                 return 0;
17             break;
18         case CCL:
19             c = *lp++;
20             if (!isinset(ap,c))
21                 return 0;
22             ap += BITBLK;
23             break;

  8行的循环遍历整个nfa,并根据不同的opcode做不同处理。 CHR x 的处理是直接对*lp和x进行判断。 ANY 匹配任意字符,因此只需要判断字符串中是否有剩余字符。 CCL bitset 需要判断字符在bitset中对应bit是否为1,使用isinset这个宏实现:

#define isinset(x,y)     ((x)[((y)&BLKIND)>>3] & bitarr[(y)&BITIND])

  22行跳过bitset所占用的nfa空间。

24         case BOL:
25             if (lp != bol)
26                 return 0;
27             break;
28         case EOL:
29             if (*lp)
30                 return 0;
31             break;

   BOL 和 EOL 的处理很简单,只要判断lp是否是字符串首地址或末尾。

32         case BOT:
33             bopat[*ap++] = lp;
34             break;
35         case EOT:
36             eopat[*ap++] = lp;
37             break;

   BOT n 和 EOT n 分别将当前的字符串指针写入bopat和eopat数组。

38          case BOW:
39             if (lp!=bol && iswordc(lp[-1]) || !iswordc(*lp))
40                 return 0;
41             break;
42         case EOW:
43             if (lp==bol || !iswordc(lp[-1]) || iswordc(*lp))
44                 return 0;
45             break;

   BOW 成功的条件是上一个字符是非单词字符(或没有上一个字符,即位于字符串开头)并且当前字符是单词字符。iswordc()宏判断某个字符是否为单词字符。

   EOW 成功的条件是前一个字符是单词字符且当前字符是非单词字符,如果当前位于字符串开头那么判断也将失败。

  这两个opcode都不匹配任何字符,他们只是匹配字符的边界:

  

46         case REF:
47             n = *ap++;
48             bp = bopat[n];
49             ep = eopat[n];
50             while (bp < ep)
51                 if (*bp++ != *lp++)
52                     return 0;
53             break;

   REF n 先从bopat和eopat取出group n的start offset和end offset,对字符串中的每个字符逐个与start offset ~ end offset中的字符做比较。

54         case CLO:
55             are = lp;
56             switch(*ap) {
57 
58             case ANY:
59                 while (*lp)
60                     lp++;
61                 n = ANYSKIP;
62                 break;
63             case CHR:
64                 c = *(ap+1);
65                 while (*lp && c == *lp)
66                     lp++;
67                 n = CHRSKIP;
68                 break;
69             case CCL:
70                 while ((c = *lp) && isinset(ap+1,c))
71                     lp++;
72                 n = CCLSKIP;
73                 break;
74             default:
75                 re_fail("closure: bad nfa.", *ap);
76                 return 0;
77             }
78 
79             ap += n;
80 
81             while (lp >= are) {
82                 if (e = pmatch(lp, ap))
83                     return e;
84                 --lp;
85             }
86             return 0;

   CLO 的处理比较复杂。首先使用临时变量are保存当前字符串指针lp。接下来对closure包含的内容的三种不同情况分别处理。

  对于 ANY ,将直接把lp移动到字符串末尾。将ANYSKIP(值为2)赋给n,n是nfa指针ap将跳过的字节数(79行)。这种情况下NFA的opcode序列如下:

   CLO  ANY  END  ...

  此时ap指向 ANY ,需要跳过2个字节才能移动到下一个opcode,因此ANYSKIP值为2。

  对于 CHR x ,跳过所有字符x。CHRSKIP值为3( CLO  CHR  x  END )。

  对于 CCL bitset ,跳过所有位于bitset中的字符。CCLSKIP值为18( CLO  CCL  bitset (16 bytes)  END )。

  81行从当前lp开始使用剩余的NFA递归调用pmatch()匹配,并不断向前移动lp指针,直到lp小于are指针。

87         default:
88             re_fail("re_exec: bad nfa.", op);
89             return 0;
90         }
91     return lp;
92 }

  最后返回当前lp指针,即最后一个匹配的字符的下一个字符的位置。

原文地址:https://www.cnblogs.com/plodsoft/p/5853945.html