正則引擎入門——基於虛擬機器的正則匹配(一)
前言
正文
介紹
說出幾個如今被廣泛應用的位元組碼直譯器或者虛擬機器:Sun公司的JVM?Adobe公司的Flash?.NET或者Mono?Perl?Python?PHP?這些的確都很流行,但是還有一個比上述這些加起來都還要廣泛使用的,那便是Henry Spencer的正則表示式庫的位元組碼直譯器以及它的眾多衍生品。
該系列的第一篇文章介紹了實現正則匹配的兩種主要的方法:一個是在awk和egrep中使用的,最壞情況為線性時間複雜度的基於NFA或DFA的策略,另一個是廣泛使用的,包括sed,Perl,PCRE和Python,最壞情況為指數級時間複雜度的基於回溯的策略。
這篇文章介紹兩種實現虛擬機器的方法,該虛擬機器執行已經被編譯成為位元組碼的正則表示式,正如.NET和Mono是兩種不同的方法去實現執行已經被編譯成為CLI位元組碼的程式的虛擬機器一樣。
將正則表示式的匹配過程看作是虛擬機器的執行使得我們可以僅僅通過加入(並實現)新的機器指令來加入新的特性。特別的,我們可以加入區域性匹配(Submatching)的指令,當我們使用(a+)(b+)匹配abbbb之後,程式可以知道括號表示式(a+)(通常用\1或$
正則表示式的虛擬機器
在開始之前,我們需要定義一個正則表示式的虛擬機器(回想一下Java VM)。虛擬機器執行一個或多個執行緒,每一個都會執行一個正則表示式的程式,該程式僅僅由一系列的正則表示式指令組成。每一個執行緒在執行的時候都會維護兩個暫存器:一個程式計數器(PC)和一個字串指標(SP)。
正則表示式指令如下:
char c
:如果SP指向的不是字元c,終止這個執行緒:匹配失敗了。否則SP移到下一個字元,PC移到下一條指令。
match
:終止該執行緒:它匹配成功了。
jmp
split
:分列操作:從x和y處繼續。建立一個SP和原執行緒相同的新執行緒。一個從PC為x處繼續執行,另一個從y處繼續執行。 虛擬機器在執行初期只會有一個執行緒,該執行緒的PC指向程式的開始處,而它的SP則指向輸入字串的開始處。為了執行執行緒,虛擬機器會執行PC所指向的那一條指令;執行完當前的這條指令之後PC會被改變為指向下一條指令。這樣不斷重複直到一條指令(匹配失敗或成功)終止該執行緒。如果有任意一個執行緒到達匹配狀態,那正則表示式就匹配成功。
將正則表示式編譯成為遞迴執行的位元組碼取決於正則表示式的形式。回憶一下前一篇文章,正則表示式有四種基本的形式:單一的字元,如a,串聯如e1
單字元a被編譯為一條指令
char a
。串聯的形式會將兩個子正則表示式的指令放到一起。並聯符會編譯成為split
,這樣允許兩個選擇均可以匹配成功。一個零一重複e?使用split
構成了一個與空字串的並聯關係。零個或多個重複e*以及一個多個重複e+使用split
來選擇是匹配e還是跳出重複。 具體的程式碼如下:
a | char a |
e1e2 | codes for e1 codes for e2 |
e1|e2 | split L1, L2 L1: codes for e1 jmp L3 L2: codes for e2L3: |
e? | split L1, L2 L1: codes for eL2: |
e\* | L1: split L2, L3 L2: codes for ejmp L1 L3: |
e+ | L1: codes for esplit L1, L3 L3: |
當整個正則表示式被編譯完畢,整個程式碼的最後會被加上一句match
指令。
例如,正則表示式a+b+會被編譯成如下指令:
char a
split 0,2
char b
split 2,4
match
當匹配字串abb時,虛擬機器會按如下的步驟執行:
Thread | PC | SP | Execution |
---|---|---|---|
T1 | char a |
**a**ab | 匹配到字元 |
T1 | split 1,3 |
a**a**b | 建立執行緒T2,PC=3 SP=a |
T1 | char a |
a**a**b | 匹配到字元 |
T1 | split 1,3 |
aa**b** | 建立執行緒T3,PC=3 SP=b |
T1 | char a |
aa**b** | 匹配失敗:T1終止 |
T2 | char b |
a**a**b | 匹配失敗:T2終止 |
T3 | char b |
aa**b** | 匹配到字元 |
T3 | split 3,5 |
abb_ | 建立執行緒T4,PC=5 SP=字元末尾 |
T3 | char b |
abb_ | 匹配失敗(字串末尾):T3終止 |
T4 | match |
abb_ | 匹配成功 |
在這個例子中,虛擬機器等待當前的執行緒結束後才建立新的執行緒,並且現成的執行是遵循先後的順序的(舊執行緒先執行)。但這並不是虛擬機器的實現細則所要求的,執行緒的安排是取決於具體的實現方式。另外的實現方式可能會採用其他的方式執行執行緒甚至會讓執行緒交替執行。
虛擬機器介面的C實現
文章接下來的部分會考察一系列的虛擬機器實現方式,並使用C程式碼進行說明。正則表示式的程式使用一系列的Inst結構體進行實現,在C中定義如下:
enum
{ /* Inst.opcode */
Char,
Match,
Jmp,
Split
};
struct Inst
{
int opcode;
int c;
Inst *x;
Inst *y;
};
這種形式的位元組碼與我們上一篇文章的NFA圖的表現形式極其相似。我們可以將位元組碼看作是將NFA圖編碼成為一系列的機器碼,或者將NFA看作是位元組碼的控制流圖。兩種不同的視角都會將這兩樣不同的事物聯絡在一起。而這篇文章主要從編碼為機器碼的角度看待這兩種事物。
任何的虛擬機器的實現形式都會將程式和輸入的字串作為引數並返回一個標誌著匹配成功與否的整數(0表示失敗;1表示成功)。
int implementation(Inst *prog, char *input);
遞歸回溯實現
一個可能的最簡單的實現方式並不直接的模擬執行緒。相反的,在它需要建立一個新的執行緒並執行的時候它會遞迴的呼叫它自身,利用了prog和input引數在呼叫時會在初始執行緒的pc和sp的基礎上拷貝一份的原理。(即此時函式呼叫時的傳參是值傳遞而不是引用傳遞)
int recursive(Inst *pc, char *sp)
{
switch(pc->opcode)
{
case Char:
if(*sp != pc->c)
return 0;
return recursive(pc+1, sp+1);
case Match:
return 1;
case Jmp:
return recursive(pc->x, sp);
case Split:
if(recursive(pc->x, sp))
return 1;
return recursive(pc->y, sp);
}
assert(0);
return -1; /* not reached */
}
上邊的版本使用了非常多的遞迴,經常使用重遞迴的語言如Lisp,ML,Erlang的程式設計師應該不會感到不適。帶大多數的C程式設計師會將return recursive(...);
這種形式(尾遞迴)的語句重寫成為一個返回到程式開頭的跳轉語句,因此上邊的語句就被改成如下形式:
int recursiveloop(Inst *pc, char *sp)
{
for(;;)
{
switch(pc->opcode)
{
case Char:
if(*sp != pc->c)
return 0;
pc++;
sp++;
continue;
case Match:
return 1;
case Jmp:
pc = pc->x;
continue;
case Split:
if(recursiveloop(pc->x, sp))
return 1;
pc = pc->y;
continue;
}
assert(0);
return -1; /* not reached */
}
}
其中的遞迴均被改為了直接的迴圈。
注意到上邊的這個版本依然需要一個遞迴,在case Split
中我們會在嘗試pc->y
之前嘗試pc->x
。
Henry Spencer早期的庫函式以及Java, Perl, PCRE, Python中的回溯實現以及ed,sed,grep的早期版本均是以上邊的實現作為核心的。當只有少量的回溯時,這種實現方式執行得非常快,但是當有許多的可能性需要嘗試的時候,花費的時間就相當可觀了,正如我們在上一篇文章中所看到的那樣。
而這種回溯的實現方式有一個工業級的實現常常不會有的缺點:(a*)*這樣的正則表示式會在編譯後的程式中造成死迴圈,而這個虛擬機器無法檢測到這種迴圈。要修復這個問題很簡單,但由於我們關注的重點不是回溯,因此我們簡單的忽略這個問題。