第一篇博客。分析一下一個簡單的正則表達式引擎的實現。這個引擎是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指針,即最后一個匹配的字符的下一個字符的位置。