實現一個正則表達式引擎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連接起來

image.png
image.png

詞法分析

對於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

a

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

a

# . 匹配任意單個字符
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

a

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分析輸入的字符串


免責聲明!

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



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