1. 程式人生 > >實現一個正則表示式引擎in Python(一)

實現一個正則表示式引擎in Python(一)

前言

專案地址:Regex in Python

開學摸魚了幾個禮拜,最近幾天用Python造了一個正則表示式引擎的輪子,在這裡記錄分享一下。

實現目標

實現了所有基本語法

st = 'AS342abcdefg234aaaaabccccczczxczcasdzxc'
pattern = '([A-Z]+[0-9]*abcdefg)([0-9]*)(\*?|a+)(zx|bc*)([a-z]+|[0-9]*)(asd|fgh)(zxc)'

regex = Regex(st, pattern)
result = regex.match()
log(result)

更多示例可以在github上看到

前置知識

其實正則表示式的引擎完全可以看作是一個小型的編譯器,所以完全可以按之前寫的那個C語言的編譯器的思路來,只是沒有那麼複雜而已

  1. 首先進行詞法分析
  2. 語法分析(這裡用自頂向下)
  3. 語義分析 (因為正則的表達能力非常弱,所以可以省略生成AST的部分直接進行程式碼生成)
  4. 程式碼生成,這裡也就是進行NFA的生成
  5. NFA到DFA的轉換,這裡開始就是正則和狀態機的相關的知識了
  6. DFA的最小化

NFA和DFA

有限狀態機可以看作是一個有向圖,狀態機中有若干個節點,每個節點都可以根據輸入字元來跳轉到下一個節點,而區別NFA((非確定性有限狀態自動機)和DFA(確定性有限狀態自動機)的是DFA的下一個跳轉狀態是唯一確定的)

有限狀態自動機從開始的初始狀態開始讀取輸入的字串,自動機使用狀態轉移函式move根據當前狀態和當前的輸入字元來判斷下一個狀態,但是NFA的下一個狀態不是唯一確定的,所以只能確定的是下一個狀態集合,這個狀態集合還需要依賴之後的輸入才能確定唯一所處的狀態。如果當自動機完成讀取的時候,它處於接收狀態的話,則說明NFA可以接收這個輸入字串

對於所有的NFA最後都可以轉換為對應的DFA

NFA構造O(n),匹配O(nm)

DFA構造O(2^n),最小化O(kn'logn')(N'=O(2^n)),匹配O(m)

n=regex長度,m=串長,k=字母表大小,n'=原始的dfa大小

NFA接受的所有字串的集合是NFA接受的語言。這個語言是正則語言。

例子

對於正則表示式:[0-9]*[A-Z]+,對應的NFA就是將下面兩個NFA的節點3和節點4連線起來


詞法分析

對於NFA和DFA其實只要知道這麼多和一些相應的演算法就已經足夠了,相應的演算法在後面提及,先完成詞法分析的部分,

這個詞法分析比之前C語言編譯器的語法分析要簡單許多,只要處理幾種可能性

  1. 普通字元
  2. 含有語義的字元
  3. 轉義字元

token

token沒什麼好說的,就是對應正則裡的語法

Tokens = {
    '.': Token.ANY,
    '^': Token.AT_BOL,
    '$': Token.AT_EOL,
    ']': Token.CCL_END,
    '[': Token.CCL_START,
    '}': Token.CLOSE_CURLY,
    ')': Token.CLOSE_PAREN,
    '*': Token.CLOSURE,
    '-': Token.DASH,
    '{': Token.OPEN_CURLY,
    '(': Token.OPEN_PAREN,
    '?': Token.OPTIONAL,
    '|': Token.OR,
    '+': Token.PLUS_CLOSE,
}

advance

advance是詞法分析裡最主要的函式,用來返回當前輸入字元的Token型別

def advance(self):
    pos = self.pos
    pattern = self.pattern
    if pos > len(pattern) - 1:
        self.current_token = Token.EOS
        return Token.EOS

    text = self.lexeme = pattern[pos]
    if text == '\\':
        self.isescape = not self.isescape
        self.pos = self.pos + 1
        self.current_token = self.handle_escape()
    else:
        self.current_token = self.handle_semantic_l(text)

    return self.current_token

advance的主要邏輯就是讀入當前字元,再來判斷是否是轉義字元或者是其它字元

handle_escape用來處理轉義字元,當然轉義字元最後本質上返回的還是普通字元型別,這個函式的主要功能就是來記錄當前轉義後的字元,然後賦值給lexem,供之後構造自動機使用

handle_semantic_l只有兩行,一是查表,這個表儲存了所有的擁有語義的字元,如果查不到就直接返回普通字元型別了

完整程式碼就不放上來了,都在github上

構造NFA

構造NFA的主要檔案都在nfa包下,nfa.py是NFA節點的定義,construction.py是實現對NFA的構造

NFA節點定義

NFA節點的定義也很簡單,其實這個正則表示式引擎完整的實現只有900行左右,每一部分拆開看都非常簡單

  • edge和input_set都是用來指示邊的,邊一共可能有四種種可能的屬性

    • 對應的節點有兩個出去的ε邊
      edge = PSILON = -1
    • 邊對應的是字符集
      edge = CCL = -2
      input_set = 相應字符集
    • 一條ε邊
      edge = EMPTY = -3
    • 邊對應的是單獨的一個輸入字元c
      edge = c
  • status_num每個節點都有唯一的一個標識
  • visited則是為了debug用來遍歷NFA

class Nfa(object):
    STATUS_NUM = 0

    def __init__(self):
        self.edge = EPSILON
        self.next_1 = None
        self.next_2 = None
        self.visited = False
        self.input_set = set()
        self.set_status_num()

    def set_status_num(self):
        self.status_num = Nfa.STATUS_NUM
        Nfa.STATUS_NUM = Nfa.STATUS_NUM + 1

    def set_input_set(self):
        self.input_set = set()
        for i in range(ASCII_COUNT):
            self.input_set.add(chr(i))

簡單節點的構造

節點的構造在nfa.construction下,這裡為了簡化程式碼把Lexer作為全域性變數,讓所有函式共享

正則表示式的BNF正規化如下,這樣我們可以採用自頂向下來分析,從最頂層的group開始向下遞迴

group ::= ("(" expr ")")*
expr ::= factor_conn ("|" factor_conn)*
factor_conn ::= factor | factor factor*
factor ::= (term | term ("*" | "+" | "?"))*
term ::= char | "[" char "-" char "]" | .

BNF在之前寫C語言編譯器的時候有提到:從零寫一個編譯器(二)

主入口

這裡為了簡化程式碼,就把詞法分析器作為全域性變數,讓所有函式共享

主要邏輯非常簡單,就是初始化詞法分析器,然後傳入NFA頭節點開始進行遞迴建立節點

def pattern(pattern_string):
    global lexer
    lexer = Lexer(pattern_string)
    lexer.advance()
    nfa_pair = NfaPair()
    group(nfa_pair)

    return nfa_pair.start_node

term

雖然是採用的是自頂向下的語法分析,但是從自底向上看會更容易理解,term是最底部的構建,也就是像單個字元、字符集、.符號的節點的構建

term ::= char | "[" char "-" char "]" | | .

term的主要邏輯就是根據當前讀入的字元來判斷應該構建什麼節點

def term(pair_out):
    if lexer.match(Token.L):
        nfa_single_char(pair_out)
    elif lexer.match(Token.ANY):
        nfa_dot_char(pair_out)
    elif lexer.match(Token.CCL_START):
        nfa_set_nega_char(pair_out)

三種節點的建構函式都很簡單,下面圖都是用markdown的mermaid隨便畫畫的

  • nfa_single_char

單個字元的NFA構造就是建立兩個節點然後把當前匹配的字元作為edge

def nfa_single_char(pair_out):
    if not lexer.match(Token.L):
        return False

    start = pair_out.start_node = Nfa()
    pair_out.end_node = pair_out.start_node.next_1 = Nfa()
    start.edge = lexer.lexeme
    lexer.advance()
    return True
  • nfa_dot_char

. 這個的NFA和上面單字元的唯一區別就是它的edge被設定為CCL,並且設定了input_set

# . 匹配任意單個字元
def nfa_dot_char(pair_out):
    if not lexer.match(Token.ANY):
        return False

    start = pair_out.start_node = Nfa()
    pair_out.end_node = pair_out.start_node.next_1 = Nfa()
    start.edge = CCL
    start.set_input_set()

    lexer.advance()
    return False
  • nfa_set_nega_char

這個函式邏輯上只比上面的多了一個處理input_set

def nfa_set_nega_char(pair_out):
    if not lexer.match(Token.CCL_START):
        return False
    
    neagtion = False
    lexer.advance()
    if lexer.match(Token.AT_BOL):
        neagtion = True
    
    start = pair_out.start_node = Nfa()
    start.next_1 = pair_out.end_node = Nfa()
    start.edge = CCL
    dodash(start.input_set)

    if neagtion:
        char_set_inversion(start.input_set)

    lexer.advance()
    return True

小結

篇幅原因,現在已經寫到了三百多行,所以就分篇寫,準備在三篇內完成。下一篇寫構造更復雜的NFA和通過構造的NFA來分析輸入字串。最後寫從NFA轉換到DFA,再最後用DFA分析輸入的字元