練習(xí) 33:解析器
譯者:飛龍
協(xié)議:CC BY-NC-SA 4.0
自豪地采用谷歌翻譯
想象一下,你將獲得一個(gè)巨大的數(shù)字列表,你必須將其輸入到電子表格中。一開始,這個(gè)巨大的列表只是一個(gè)空格分隔的原始數(shù)據(jù)流。你的大腦會(huì)自動(dòng)在空格處拆分?jǐn)?shù)字流并創(chuàng)建數(shù)字。你的大腦像掃描器一樣。然后,你將獲取每個(gè)數(shù)字,并將其輸入到具有含義的行和列中。你的大腦像一個(gè)解析器,通過獲取扁平的數(shù)字(記號(hào)),并將它們變成一個(gè)更有意義的行和列的二維網(wǎng)格。你遵循的規(guī)則,什么數(shù)字進(jìn)入什么行什么列,是你的“語法”,解析器的工作就是像你對(duì)于電子表格那樣使用語法。
我們?cè)賮砜匆幌戮毩?xí) 32 中的微型 Python 代碼,再從三個(gè)不同的角度討論解析器:
def hello(x, y):
print(x + y)
hello(10, 20)
當(dāng)你查看這個(gè)代碼時(shí),你看到什么?我看到一棵樹,類似于我們之前創(chuàng)建的BSTree
或TSTree
。你看到樹了嗎?我們從這個(gè)文件的最上方開始,學(xué)習(xí)如何將字符轉(zhuǎn)換為樹。
首先,當(dāng)我們加載一個(gè).py
文件時(shí),它只是一個(gè)“字符”流 - 實(shí)際上是字節(jié),但 Python 使用Unicode,所以必須處理字符。這些字符在一行中,毫無結(jié)構(gòu),掃描器的任務(wù)是增加第一層次的意義。掃描器通過使用正則表達(dá)式,從字符串流中提取意義,創(chuàng)建記號(hào)列表。我們已經(jīng)將一個(gè)字符列表轉(zhuǎn)換為一個(gè)記號(hào)列表,但看看def hello(x,y):
函數(shù)。這是一個(gè)函數(shù),里面有代碼塊。這意味著某種形式的“包含”或“東西里面的東西”的結(jié)構(gòu)。
一個(gè)很容易表示包含的方式是用一棵樹。我們可以使用表格,像你的電子表格一樣,但它并不像樹那么容易。接下來看看hello(x, y)
部分。我們有一個(gè)NAME(hello)
記號(hào),但是我們要抓取(...)
部分的內(nèi)容,并且知道它在括號(hào)內(nèi)。再次,我們可以使用一個(gè)樹,我們將(...)
部分中的x, y
部分“嵌套” 為樹的子節(jié)點(diǎn)或分支。最終,我們就擁有了一棵樹,從這個(gè) Python 代碼的根開始,并且每個(gè)代碼塊,print
,函數(shù)定義和函數(shù)調(diào)用都是根的分支,它們也有子分支,以此類推。
為什么我們這樣做?我們需要基于其語法,知道 Python 代碼的結(jié)構(gòu),以便我們稍后分析。如果我們不將記號(hào)的線性列表轉(zhuǎn)換成樹結(jié)構(gòu),那么我們不知道函數(shù),代碼塊,分支或表達(dá)式的邊界在哪里。我們必須以“直線”方式在飛行中確定邊界,這不容易使其可靠。很多早期的糟糕語言是直線語言,我們現(xiàn)在知道了他們不必須是這樣。我們可以使用解析器構(gòu)建樹結(jié)構(gòu)。
解析器的任務(wù)是從掃描器中獲取記號(hào)列表,并將其翻譯成更有意義的語法樹。你可以認(rèn)為解析器是,對(duì)記號(hào)流應(yīng)用另一個(gè)正則表達(dá)式。掃描器的正則表達(dá)式將大量字符放入記號(hào)中。解析器的“正則表達(dá)式”將這些記號(hào)放在盒子里面,它里面有盒子,以此類推,直到記號(hào)不再是線性的。
解析器也為這些盒子添加了含義。解析器將簡單地刪除()
括號(hào)記號(hào),并為可能的Function
類創(chuàng)建一個(gè)特殊的parameters
列表。它會(huì)刪除冒號(hào),無用的空格,逗號(hào),任何沒有真正意義的記號(hào),并將其轉(zhuǎn)換為更易于處理的嵌套結(jié)構(gòu)。最后的結(jié)果可能看起來像,上面的示例代碼的偽造樹:
* root
* Function
- name = hello
- parameters = x, y
- code:
* Call
- name = print
- parameters =
* Expression
- Add
- a = x
- b = y
* Call
- name = hello
- parameters = 10, 20
遞歸下降解析
有幾種已建立的方法,可以為這種語法創(chuàng)建解析器,但最簡單的方法稱為遞歸下降解析器(RDP)。我實(shí)際上在我《笨辦法學(xué) Python》練習(xí) 49 中講解了這個(gè)話題。你創(chuàng)建了一個(gè)簡單的 RDP 解析器來處理你的小游戲語言,你甚至不了解它。在本練習(xí)中,我將對(duì)如何編寫 RDP 解析器進(jìn)行更正式的描述,然后讓你使用我們上面的 Python 小代碼片段來嘗試它。
RDP 使用多個(gè)相互遞歸的函數(shù)調(diào)用,它實(shí)現(xiàn)了給定語法的樹形結(jié)構(gòu)。RDP 解析器的代碼看起來像你正在處理的實(shí)際語法,只要遵循一些規(guī)則,它們就很容易編寫。RDP 解析器的兩個(gè)缺點(diǎn)是:它們可能不是非常有效,并且通常需要手動(dòng)編寫它們,因此它們的錯(cuò)誤比生成的解析器更多。對(duì)于 RDP 解析器可以解析的東西,還有一些理論上的限制,但是由于你手動(dòng)編寫它們,你通常可以解決很多限制。
為了編寫一個(gè) RDP 解析器,你需要使用三個(gè)主要操作,來處理掃描器的記號(hào):
peek
如果下一個(gè)記號(hào)能夠匹配,返回它,但是不從流中移除。
match
匹配下一個(gè)記號(hào),并且從流中移除。
skip
由于不需要下個(gè)記號(hào),跳過它,將其從流中移除。
你會(huì)注意到,這些是我在練習(xí) 33 中讓你為掃描器創(chuàng)建的三個(gè)操作,這就是為什么。你需要他們來實(shí)現(xiàn)一個(gè) RDP 解析器。
你可以使用這三個(gè)函數(shù)來編寫語法解析函數(shù),從掃描器中獲取記號(hào)。這個(gè)練習(xí)的一個(gè)簡短的例子是,解析這個(gè)簡單的函數(shù):
def function_definition(tokens):
skip(tokens) # discard def
name = match(tokens, 'NAME')
match(tokens, 'LPAREN')
params = parameters(tokens)
match(tokens, 'RPAREN')
match(tokens, 'COLON')
return {'type': 'FUNCDEF', 'name': name, 'params': params}
你可以看到我只是接受記號(hào)并使用match
和skip
處理它們。你還會(huì)注意到我有一個(gè)parameters
函數(shù),它是“遞歸下降解析器”的“遞歸”部分。當(dāng)它需要為函數(shù)解析參數(shù)時(shí),function_definition
會(huì)調(diào)用parameters
。
BNF 語法
嘗試從頭開始編寫一個(gè) RDP 解析器是沒有某種形式的語法規(guī)范的,有點(diǎn)棘手。你還記得當(dāng)我要求你將單個(gè)正則表達(dá)式轉(zhuǎn)換成 FSM 嗎?這很難嗎?它需要更多的代碼,不只是正則表達(dá)式中的幾個(gè)字符。當(dāng)你為這個(gè)練習(xí)編寫 RDP 解析器時(shí),你將會(huì)做類似的事情,因此它有助于使用一種語言,它是“語法的正則表達(dá)式”。
最常見的“語法的正則表達(dá)式”被稱為 Backus–Naur Form(BNF),以創(chuàng)作者 John Backus 和 Peter Naur 命名。BNF 描述了所需的記號(hào),以及這些記號(hào)如何重復(fù)來形成語言的語法。BNF 還使用與正則表達(dá)式相同的符號(hào),所以*
,+
和?
有相似的含義。
對(duì)于這個(gè)練習(xí),我將使用 https://tools.ietf.org/html/rfc5234 上面的 IETF 增強(qiáng) BNF 語法,來規(guī)定上面的微型 Python 代碼段的語法。ABNF 運(yùn)算符大部分與正則表達(dá)式相同,只是由于某種奇怪的原因,它們?cè)谝貜?fù)的東西之前放置重復(fù)符號(hào)。除此之外,請(qǐng)閱讀規(guī)范,并嘗試弄清楚下面的意思:
root = *(funccal / funcdef)
funcdef = DEF name LPAREN params RPAREN COLON body
funccall = name LPAREN params RPAREN
params = expression *(COMMA expression)
expression = name / plus / integer
plus = expression PLUS expression
PLUS = "+"
LPAREN = "("
RPAREN = ")"
COLON = ":"
COMMA = ","
DEF = "def"
讓我們僅僅查看funcdef
那一行,并將其與function_definition
Python 代碼比較,匹配每一個(gè)部分:
funcdef =
我們使用def function_definition(tokens)
來復(fù)制,并且它是我們的語法的這個(gè)部分的開始。
DEF
它在語法中規(guī)定了DEF = "def"
,并且在 Python 代碼中,我們使用skip(tokens)
跳過了它。
name
我需要它,所以我使用name = match(tokens, 'NAME')
匹配它。我使用 CAPITALS 的約定,在 BNF 中表示我會(huì)跳過的東西。
LPAREN
我假設(shè)我收到了一個(gè)def
,但是現(xiàn)在我打算確保有一個(gè)(
,所以我要匹配它。但是我使用match(tokens, 'LPAREN')
來忽略結(jié)果。它就像“需要但是忽略”。
params
在 BNF 中我將params
定義為了新的“語法產(chǎn)生式”,或者“語法規(guī)則”。意思是在我的 Python 代碼中,我需要一個(gè)新的函數(shù)。這個(gè)函數(shù)中,我可以使用params = parameters(tokens)
來調(diào)用那個(gè)函數(shù)。之后我定義了parameters
函數(shù)來為函數(shù)處理逗號(hào)分隔的參數(shù)。
RPAREN
同樣我需要但是去掉了它,使用match(tokens, 'RPAREN')
。
COLON
同樣,我去掉了匹配match(tokens, 'COLON')
。
body
我這里實(shí)際上跳過了函數(shù)體,因?yàn)?Python 的縮進(jìn)語法對(duì)于這個(gè)例子太難了。你不需要在練習(xí)中處理這個(gè)例子,除非你喜歡它。
這基本上是,你如何讀取 ABNF 規(guī)范,并將其系統(tǒng)地轉(zhuǎn)換為代碼。你從根開始,將每個(gè)語法產(chǎn)生式實(shí)現(xiàn)為一個(gè)函數(shù),并讓掃描器處理簡單的記號(hào)(我用CAPITAL
(大寫)字母表示)。
簡單的示例黑魔法解析器
這是我快速 Hack 出來的 RDP 解析器,你可以使用它,作為你的更正式和簡潔的解析器的基礎(chǔ)。
from scanner import *
from pprint import pprint
def root(tokens):
"""root = *(funccal / funcdef)"""
first = peek(tokens)
if first == 'DEF':
return function_definition(tokens)
elif first == 'NAME':
name = match(tokens, 'NAME')
second = peek(tokens)
if second == 'LPAREN':
return function_call(tokens, name)
else:
assert False, "Not a FUNCDEF or FUNCCALL"
def function_definition(tokens):
"""
funcdef = DEF name LPAREN params RPAREN COLON body
I ignore body for this example 'cause that's hard.
I mean, so you can learn how to do it.
"""
skip(tokens) # discard def
name = match(tokens, 'NAME')
match(tokens, 'LPAREN')
params = parameters(tokens)
match(tokens, 'RPAREN')
match(tokens, 'COLON')
return {'type': 'FUNCDEF', 'name': name, 'params': params}
def parameters(tokens):
"""params = expression *(COMMA expression)"""
params = []
start = peek(tokens)
while start != 'RPAREN':
params.append(expression(tokens))
start = peek(tokens)
if start != 'RPAREN':
assert match(tokens, 'COMMA')
return params
def function_call(tokens, name):
"""funccall = name LPAREN params RPAREN"""
match(tokens, 'LPAREN')
params = parameters(tokens)
match(tokens, 'RPAREN')
return {'type': 'FUNCCALL', 'name': name, 'params': params}
def expression(tokens):
"""expression = name / plus / integer"""
start = peek(tokens)
if start == 'NAME':
name = match(tokens, 'NAME')
if peek(tokens) == 'PLUS':
return plus(tokens, name)
else:
return name
elif start == 'INTEGER':
number = match(tokens, 'INTEGER')
if peek(tokens) == 'PLUS':
return plus(tokens, number)
else:
return number
else:
assert False, "Syntax error %r" % start
def plus(tokens, left):
"""plus = expression PLUS expression"""
match(tokens, 'PLUS')
right = expression(tokens)
return {'type': 'PLUS', 'left': left, 'right': right}
def main(tokens):
results = []
while tokens:
results.append(root(tokens))
return results
parsed = main(scan(code))
pprint(parsed)
你會(huì)注意到,我正在使用我寫的scanner
模塊,擁有我的match
,peek
,skip
和scan
函數(shù)。我使用from scanner import *
,僅使這個(gè)例子更容易理解。你應(yīng)該使用你的Scanner
類。
你會(huì)注意到,我把這個(gè)小解析器的 ABNF 放在每個(gè)函數(shù)的文檔注釋中。這有助于我編寫每個(gè)解析器代碼,稍后可以用于錯(cuò)誤報(bào)告。在嘗試挑戰(zhàn)練習(xí)之前,你應(yīng)該研究此解析器,甚至可能作為“代碼大師副本”。
挑戰(zhàn)練習(xí)
你的下一個(gè)挑戰(zhàn)是,將你的 Scanner
類與新編寫的Parser
類組合在一起,你可以派生并重新實(shí)現(xiàn)使我這里的簡單的解析器。你的基礎(chǔ)Parser
類應(yīng)該能夠:
- 接受一個(gè)
Scanner
對(duì)象并執(zhí)行自身。你可以假設(shè)任何默認(rèn)函數(shù)是語法的起始。 - 擁有錯(cuò)誤處理代碼,比我簡單的
assert
用法更好。
你應(yīng)該實(shí)現(xiàn)PunyPythonPython
,它可以解析這個(gè)微小的 Python 語言,并執(zhí)行以下操作:
- 不是僅僅產(chǎn)生
dicts
的列表,你應(yīng)該為每個(gè)語法生產(chǎn)式的結(jié)果創(chuàng)建類。這些類之后成為列表中的對(duì)象。 - 這些類只需要存儲(chǔ)被解析的記號(hào),但是要準(zhǔn)備做更多事情。
- 你只需要解析這個(gè)微小的語言,但你應(yīng)該嘗試解決“Python 縮進(jìn)”問題。你可能需要秀阿貴掃描器,使其更智能,才能在行的開頭匹配
INDENT
空白字符,并在其他位置忽略它。你還需要跟蹤如何多少縮進(jìn)了多少,同時(shí)也記錄零縮進(jìn),所以你可以“壓縮”代碼塊。
一個(gè)泛用的測(cè)試套件涉及到,將這個(gè)微小的 python 的更多樣本交給解析器,但現(xiàn)在只需要得到一個(gè)小文件來解析。嘗試在測(cè)試中獲得良好的覆蓋率,并盡可能多地發(fā)現(xiàn)錯(cuò)誤。
研究性學(xué)習(xí)
這個(gè)練習(xí)相當(dāng)龐大,所以只需要完成。花點(diǎn)時(shí)間,一次做一點(diǎn)點(diǎn)。我強(qiáng)烈建議學(xué)習(xí)我這里的小型樣本,直到你完全弄清楚,并打印正在處理的關(guān)鍵位置的記號(hào)。
深入學(xué)習(xí)
查看 David Beazley 的 SLY 解析器生成器,以便讓你的計(jì)算機(jī)為你生成你的解析器和掃描器(也稱為分詞器)。隨意嘗試用 SLY 重復(fù)此練習(xí)來進(jìn)行比較。