70 行 Python 程式碼編寫一個遞迴下降解析器

發表於2015-12-27

3個月前,我寫了一篇文章,詳細講述了用解析庫編寫計算器的過程。然而,讀者們普遍反應,他們對於見到一個從頭開始寫並且除了電池以外別無他物的計算器更感興趣。我想,為什麼不呢?

寫一個計算機很簡單,如果你使用針對算術表示式的hacks 的話。但是hacks的產生的後果也幾乎總是一樣的:解決方案不夠優雅,不可擴充套件,並且很難直觀的理解。我喜歡挑戰,並且打算髮一個有益的帖子,所以我決定用通用遞迴下降解析器來寫它。本著與上次相同的精神,我打算用盡可能少的行數來幹這件事,所以它充滿了hacks和tricks。但它們是表面的,並且不止限於我手頭的任務。

這篇文章我將一步一步詳細的解釋一下。如果你想直接跳到程式碼,你可以滾動到這篇文章的最後。我希望當你讀完後你能更好的理解如何解析內部的工作,啟發你用適當的解析庫,以避免混亂。

要理解這篇文章,你應該很好的理解Python,建議你要了解一些它是怎麼解析,它是用來幹什麼的。如果你不知道,我建議你閱讀我的前一篇文章,在裡面我詳細解釋的語法及怎麼去使用。

第一步:標記化

處理表示式的第一步就是將其轉化為包含一個個獨立符號的列表。這一步很簡單,且不是本文的重點,因此在此處我省略了很多。
首先,我定義了一些標記(數字不在此中,它們是預設的標記)和一個標記型別:

下面就是我用來標記 expr 表示式的程式碼:

第一行是將表示式分割為基本標記的技巧,因此

下一行命名標記,這樣分析器就能通過分類識別它們:

任何不在 token_map 中的標記被假定為數字。我們的分詞器缺少稱為驗證的屬性,以防止非數字被接受,但幸運的是,運算器將在以後處理它。
就是這樣。現在我們有了一個標記列表,下一步就是將它解析為一個 AST。

第二步: 語法定義

我選擇的解析器實現自一個本地垂直解析器,其來源於LL解析器的一個簡單版本。它是一個最簡單的解析器實現,事實上,只有僅僅14行程式碼。它是一種自上而下的解析器,這意味著解析器從最上層規則開始解析(like:expression),然後以遞迴方式嘗試按照其子規則方式解析,直至符合最下層的規則(like:number)。換句話解釋,當自底向上解析器(LR)逐步地收縮標記,使規則被包含在其它規則中,直到最後僅剩下一個規則,而自頂向下解析器(LL)逐步展開規則並進入到少數的抽象規則,直到它能夠完全匹配輸入的標記。
在深入到實際的解析器實現之前,我們可對語法進行討論。在我之前發表的文章中,我使用過LR解析器,我可以像如下方式定義計算器語法(標記使用大寫字母表示):

(如果您還不理解上述語法,請閱讀我之前發表的文章)

現在我使用LL解析器,以如下方式定義計算器的語法:

大家可以看到,這裡有一個微妙的變化。有關”add and mul”的遞迴定義被反轉了。這是個非常重要的細節,我會向大家詳細說明這一點。

LR版本使用了左遞迴的模式。當LL解析器遇到遞迴的時候,它會嘗試去匹配規則。所以,當左遞迴發生是,解析器會進入無窮遞迴。甚至連聰明的LL解析器例如ANTLR也逃避不了這個問題,它會以友好的錯誤提示代替無窮的遞迴,而不像我們這個玩具解析器那樣。

左遞迴可以很容易的轉變為右遞迴,我就這麼做的。但是解析器並不是那麼簡單,它又會產生另一個問題:當左遞迴正確的解析 3-2-1 為(3-2)-1,而右遞迴卻錯誤的解析為3-(2-1)。我還沒想到一個簡單的解決辦法,所以為了讓事情簡單,我決定讓它繼續使用錯誤的解析格式,並在後面處理這個問題(請看步驟4)

第三步:解析為一個AST

演算法其實很簡單。我們會定義一個接收兩個引數的遞迴方法:第一個引數是我們要嘗試匹配的規則名稱,第二個引數是我們要保留的標識列表。我們從add(最上層規則)方法開始,其已包含完整的標識列表,遞迴呼叫已非常明確。方法將返回一個陣列,其包含元素為:一個是當前匹配項,另一個是保留匹配的標識列表。我們將實現標識匹配功能,以使這段程式碼可用(它們都是字串型別;一個是大寫格式,另一個是小寫格式)。

以下是解析器實現的程式碼:

程式碼4至5行說明:如果規則名稱(rule_name)確實是一個標識,並被包含在標識列表(tokens)中,同時檢查其是否匹配當前標識。如果是,表示式將返回匹配方法,標識列表任然進行使用。

程式碼第6行說明:迭代將迴圈檢查是否匹配該規則名稱對應的子規則,通過遞迴實現每條子規則的匹配。如果規則名稱滿足匹配標識的條件,get()方法將返回一個空陣列,同時程式碼將返回空值(見16行)。

第9-15行,實現迭代當前的sub-rule,並嘗試順序地匹配他們。每次迭代都儘可能多的匹配標識。如果某一個標識無法匹配,我們就會放棄整個sub-rule。但是,如果所有的標識都匹配成功,我們就到達else語句,並返回rule_name的匹配值,還有剩下標識。

現在執行並看看1.2/(11+3)的結果。

結果是一個tuple,當然我們並沒有看到有剩下的標識。匹配結果並不易於閱讀,所以讓我吧結果畫成一個圖:

這就是概念上的AST。通過你思維邏輯,或者在紙上描繪,想象解析器是如何運作的,這樣是個很好的鍛鍊。我不敢說這樣是必須的,除非你想神交。你可以通過AST來幫助你實現正確的演算法。

到目前為止,我們已經完成了可以處理二進位制運算,一元運算,括號和操作符優先權的解析器。

現在只剩下一個錯誤待解決,下面的步驟我們將解決這個錯誤。

第四步:後續處理

我的解析器並非在任何場合管用。最重要的一點是,它並不能處理左遞迴,迫使我把程式碼寫成右遞迴方式。這樣導致,解析 8/4/2 這個表示式的時候,AST結果如下:

如果我們嘗試通過AST計算結果,我們將會優先計算4/2,這當然是錯誤的。一些LL解析器選擇修正樹裡面的關聯性。這樣需要編寫多行程式碼;)。這個不採納,我們需要使它扁平化。演算法很簡單:對於AST裡面的每個規則 1)需要修正 2)是一個二進位制運算 (擁有sub-rules)3) 右邊的操作符同樣的規則:使後者扁平成前者。通過“扁平”,我意思是在其父節點的上下文中,通過節點的兒子代替這個節點。因為我們的穿越是DFS是後序的,意味著它從樹的邊緣開始,並一直到達樹根,效果將會累加。如下是程式碼:

這段程式碼可以讓任何結構的加法或乘法表示式變成一個平面列表(不會混淆)。括號會破壞順序,當然,它們不會受到影響。

基於以上的這些,我可以把程式碼重構成左關聯:

但是,我並不會這樣做。我需要更少的程式碼,並且把計算程式碼換成處理列表會比重構整棵樹需要更少的程式碼。

第五步:運算器

對樹的運算非常簡單。只需用與後處理的程式碼相似的方式對樹進行遍歷(即 DFS 後序),並按照其中的每條規則進行運算。對於運算器,因為我們使用了遞迴演算法,所以每條規則必須只包含數字和操作符。程式碼如下:

我使用 calc_binary 函式進行加法和減法運算(以及它們的同階運算)。它以左結合的方式計算列表中的這些運算,這使得我們的 LL語法不太容易獲取結果。

第六步:REPL

最樸實的REPL:

不要讓我解釋它 :)

附錄:將它們合併:一個70行的計算器

相關文章