正則引擎入門——基於虛擬機器的正則匹配(三)
前言
正文
Pike實現
在“執行緒化”的實現,如thompsonvm
中,我們簡單地將儲存後的指標加入到執行緒的狀態中。Rob Pike首先於文字編輯器sam中使用了這種實現方式。
struct Thread
{
Inst *pc;
char *saved[20]; /* $0 through $9 */
};
Thread thread(Inst *pc, char **saved);
int pikevm(Inst *prog, char *input, char **saved)
{
int len;
ThreadList *clist, *nlist;
Inst *pc;
char *sp;
Thread t;
len = proglen(prog); /* # of instructions */
clist = threadlist(len);
nlist = threadlist(len);
addthread(clist, thread(prog, saved));
for(sp=input; *sp; sp++)
{
for(i=0; i>clist.n; i++)
{
t = clist.t[i];
switch(pc->opcode)
{
case Char:
if(*sp != pc->c)
break;
addthread(nlist, thread(t.pc+1, t.saved));
break;
case Match:
memmove(saved, t.saved, sizeof t.saved);
return 1;
case Jmp:
addthread(clist, thread(t.pc->x, t.saved));
break ;
case Split:
addthread(clist, thread(t.pc->x, t.saved));
addthread(clist, thread(t.pc->y, t.saved));
break;
case Save:
t.saved[t->pc.i] = sp;
addthread(clist, thread(t.pc->x, t.saved));
break;
}
}
swap(clist, nlist);
clear(nlist);
}
}
case Save
下的程式碼較recursiveloop
更加簡單,因為每一個執行緒都有自己的saved
的備份,因此沒有必要恢復舊的值。
在Thompson的虛擬機器中,addthread
通過為每一個可能的PC值保留一個執行緒,可以將執行緒列表的長度限制為n,即編譯後程序的長度。在Pike的虛擬機器中,執行緒的狀態更大了,它包含了儲存的字元指標,但是addthread
依舊可以只為每一個可能的PC保留一個執行緒,這是因為儲存的指標不會影響到虛擬機器之後的執行,它們只會記錄下過去的執行狀態。兩個具有相同的PC的執行緒,即便他們有不同的儲存的指標,他們的執行過程都是一致的,因此對每個PC只需維護一個執行緒。
不明確的區域性匹配
有些時候,正則表示式有不止一種方法去匹配一個字串。一個簡單的例子,讓我們考慮用<.*>匹配<html></html>,那表示式是隻匹配<html>呢還是匹配整個<html></html>呢?換句話說,表示式 .* 是隻匹配html還是匹配html></html?Perl,正則表示式實現的標準,使用了第二種匹配方式。在這種情況下,* 是“貪婪的”(greedy),它儘可能的匹配更多的字串。
要求 *變得貪婪本質上為每一個執行緒的執行引入了優先權的概念。我們可以在虛擬機器中加入這種權值概念,通過讓split
指令在兩個引數都會匹配成功的情況下優先選擇更靠前的匹配引數。
使用更新之後的split
,我們只要保證e*,e?,e+優先選擇匹配e更多的那個路徑,就可以實現貪婪的操作符。Perl同時也引入了非貪婪的操作符 e*?,e??,e+?,它們會盡可能少的去匹配字元。我們可以將split
的兩個引數反轉一下,這樣就可以優先選擇較少的匹配。
詳細的程式碼序列如下:
greedy (同上) non-greedy
e? e??
split L1, L2 split L2, L1
L1: codes for e L1: codes for e
L2: L2:
e* e*?
L1: split L2, L3 L1: split L3, L2
L2: codes for e L2: codes for e
jmp L1 jmp L1
L3: L3:
e+ ee+?
L1: codes for e L1: codes for e
split L1, L3 split L3, L1
L3: L3:
前文中給出的回溯的實現方式已經按照我們之前定義的方式將split賦予了優先順序,但要看清楚這一點需要多花點時間。recursiveloop
和recursive
的實現方式在嘗試pc->y
之前會嘗試pc->x
。
/* recursive */
case Split:
if(recursive(pc->x, sp))
return 1;
return recursive(pc->y, sp);
/* recursiveloop */
case Split:
if(recursiveloop(pc->x, sp))
return 1;
pc = pc->y;
continue;
backtrackingvm
實現方式會為優先順序較低的pc->y
建立一個新執行緒,然後繼續處理pc->x
:
/* backtrackingvm */
case Split:
if(nready >= MAXTHREAD){
fprintf(stderr, "regexp overflow");
return -1;
}
/* queue new thread */
ready[nready++] = thread(pc->y, sp);
pc = pc->x; /* continue current thread */
continue;
因為所有的執行緒都是在棧中進行管理的,pc->y
的執行緒不會執行直到所有的由pc->x
產生的執行緒被嘗試並失敗之後。這些產生的執行緒均具有比pc->y
更高的權重。
上方給出的pikevm
的實現方式並沒有完全遵守執行緒的優先順序,但是我們可以修改一下。為了實現執行緒的優先順序,我們可以使addthread
函式在處理Jmp
,Split
和Save
命令時遞迴地呼叫它自身並將這些指令的目標全部加入進執行緒的列表。(這樣就使得addthread
函式本質上和addstate
函式相同。)這種改變使得clist和nlist中的執行緒都是按照執行緒的優先順序進行排列的,從高優先到低優先。這樣pikevm
中的處理迴圈就會按照執行緒的優先順序進行嘗試,同時修改之後的addthread
可以保證在加入某一個優先順序的執行緒所產生的新執行緒之後才去考慮更低優先順序的執行緒所產生的新執行緒。
pickvm
的修改遵照著這樣一個事實:遞迴呼叫會遵守執行緒的優先順序。新的程式在處理單個字元的時候會使用遞迴,這樣nlist中的執行緒會遵循執行緒的優先順序,同時我們依舊使用單步模式保持較好的時間複雜度。因為nlist中的執行緒都遵循優先順序,因此我們只為一個PC保留一個執行緒的做法是安全的,因為最先看到的執行緒具有更高的優先順序因此也需要被儲存起來。
pikevm
中還有一處需要修改,當一個匹配被找到後,在這之後執行的clist中的執行緒(具有較低的優先順序)需要全部切斷,但是更高優先順序的執行緒需要繼續執行去匹配可能的更長的字串。pikevm
的新的主迴圈如下:
for(i=0; i<clist.n ;i++)
{
pc = clist.t[i].pc;
switch(pc->opcode)
{
case Char:
if(*sp != pc->c)
break;
addthread(nlist, thread(pc+1), sp+1);
break;
case Match:
saved = t.saved; // save end pointer
matched = 1;
clist.n = 0;
break;
}
}
相同的修改也可以用在thompsonvm
中,但是由於它不記錄區域性匹配的位置,因此唯一可見的改變便是當有匹配時它所選擇的結束指標的位置。這樣的改變會讓thompsonvm
與回溯的實現做出相同的選擇。這在下一篇文章中十分有用。
不同的實現方式可以使用另外的標準去挑選執行緒組,如直接去比較區域性匹配後的指標組。第八版的Unix函式庫使用左端最長匹配作為標準,以此來模仿基於DFA的工具,如awk和egrep。