簡易正則表達式引擎源碼閱讀


  第一篇博客。分析一下一個簡單的正則表達式引擎的實現。這個引擎是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如下。

 opcode operand  解釋
 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指針,即最后一個匹配的字符的下一個字符的位置。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM