Python技法:用re模組實現簡易tokenizer

orion發表於2022-04-29

一個簡單的tokenizer

分詞(tokenization)任務是Python字串處理中最為常見任務了。我們這裡講解用正規表示式構建簡單的表示式分詞器(tokenizer),它能夠將表示式字串從左到右解析為標記(tokens)流。

給定如下的表示式字串:

text = 'foo = 12 + 5 * 6'

我們想要將其轉換為下列以序列對呈現的分詞結果:

tokens = [('NAME', 'foo'), ('EQ', '='), ('NUM', '12'), ('PLUS', '+'),\
    ('NUM', '5'), ('TIMES', '*'), ('NUM', '6')]

要完成這樣的分詞操作,我們首先需要定義出所有可能的標記模式(所謂模式(pattern),為用來描述或者匹配/系列匹配某個句法規則的字串,這裡我們用正規表示式來做為模式),注意此處要包括空格whitespace,否則字串中出現任何模式中沒有的字元後,掃描就會停止。因為我們還需要給標記以NAME、EQ等名稱,我們採用正規表示式中的命名捕獲組來實現。

import re
NAME = r'(?P<NAME>[a-zA-Z_][a-zA-Z_0-9]*)' 
# 這裡?P<NAME>表示模式名稱,()表示一個正規表示式捕獲組,合在一起即一個命名捕獲組
EQ = r'(?P<EQ>=)'
NUM = r'(?P<NUM>\d+)' #\d表示匹配數字,+表示任意數量
PLUS = r'(?P<PLUS>\+)' #需要用\轉義
TIMES = r'(?P<TIMES>\*)' #需要用\轉義
WS = r'(?P<WS>\s+)' #\s表示匹配空格, +表示任意數量
master_pat = re.compile("|".join([NAME, EQ, NUM, PLUS, TIMES, WS]))  # | 用於選擇多個模式,表示"或"

接下來我們用模式物件中的scanner()方法來完成分詞操作,該方法建立一個掃描物件:

scanner = master_pat.scanner(text)

然後可以用match()方法獲取單次匹配結果,一次匹配一個模式:

scanner = master_pat.scanner(text)
m = scanner.match() 
print(m.lastgroup, m.group()) # NAME foo
m = scanner.match()
print(m.lastgroup, m.group()) # WS

當然這樣一次一次呼叫過於麻煩,我們可以使用迭代器來批量呼叫,並將單次迭代結果以具名元組形式儲存

Token = namedtuple('Token', ['type', 'value'])
def generate_tokens(pat, text):
    scanner = pat.scanner(text)
    for m in iter(scanner.match, None):
        #scanner.match做為迭代器每次呼叫的方法,
        #None為哨兵的預設值,表示迭代到None停止
        yield Token(m.lastgroup, m.group())
    
for tok in generate_tokens(master_pat, "foo = 42"):
    print(tok)

最終顯示錶達式串"foo = 12 + 5 * 6"的tokens流為:

Token(type='NAME', value='foo')
Token(type='WS', value=' ')
Token(type='EQ', value='=')
Token(type='WS', value=' ')
Token(type='NUM', value='12')
Token(type='WS', value=' ')
Token(type='PLUS', value='+')
Token(type='WS', value=' ')
Token(type='NUM', value='5')
Token(type='WS', value=' ')
Token(type='TIMES', value='*')
Token(type='WS', value=' ')
Token(type='NUM', value='6')

過濾tokens流

接下來我們想要過濾掉空格標記,使用生成器表示式即可:

tokens = (tok for tok in generate_tokens(master_pat, "foo = 12 + 5 * 6")
          if tok.type != 'WS')
for tok in tokens:
    print(tok)

可以看到空格被成功過濾:

Token(type='NAME', value='foo')
Token(type='EQ', value='=')
Token(type='NUM', value='12')
Token(type='PLUS', value='+')
Token(type='NUM', value='5')
Token(type='TIMES', value='*')
Token(type='NUM', value='6')

注意子串匹配陷阱

tokens在正規表示式(即"|".join([NAME, EQ, NUM, PLUS, TIMES, WS]))中順序也非常重要。因為在進行匹配時,re模組就會按照指定的順序對模式做匹配。故若碰巧某個模式是另一個較長模式的子串時,必須保證較長的模式在前面優先匹配。如下面分別展示正確的和錯誤的匹配方法:

LT = r'(?P<LT><)'
LE = r'(?P<LE><=)'
EQ = r'(?P<EQ>>=)'
master_pat = re.compile("|".join([LE, LT, EQ]))  # 正確的順序
master_pat = re.compile("|".join([LT, LE, EQ]))  # 錯誤的順序

第二種順序的錯誤之處在於,這樣會把'<='文字匹配為LT('<')緊跟著EQ('='),而沒有匹配為單獨的LE(<=)。

我們對於“有可能”形成子串的模式也要小心,比如下面這樣:

PRINT = r'(?P<PRINT>print)'
NAME = r'(?P<NAME>[a-zA-Z_][a-zA-Z_0-9]*)'

master_pat = re.compile("|".join([PRINT, NAME]))  # 正確的順序

for tok in generate_tokens(master_pat, "printer"):
    print(tok)

可以看到被print實際上成了另一個模式的子串,導致另一個模式的匹配出現了問題:

# Token(type='PRINT', value='print')
# Token(type='NAME', value='er')

更高階的語法分詞,建議採用像PyParsing或PLY這樣的包。特別地,對於英文自然語言文章的分詞,一般被整合到各類NLP的包中(一般分為按空格拆分、處理前字尾、去掉停用詞三步驟)。對於中文自然語言處理分詞也有豐富的工具(比如jieba分詞工具包)。

引用

  • [1] Martelli A, Ravenscroft A, Ascher D. Python cookbook[M]. " O'Reilly Media, Inc.", 2015.

相關文章