用 Python 從零開始寫一個簡單的直譯器(2)

LotAbout發表於2015-10-16

在《用 Python 從零開始寫一個簡單的直譯器(1)》中,我們介紹了 IMP 語言以及我們為它打造的直譯器的通用結構。也深入介紹了詞法分析器。本文中,我們準備寫一個小型的解析器組合子(parser combinator)的庫。這個庫將被用來建立 IMP 語法分析器,語法分析器的作用是從由詞法分析器生成的標記符列表中提取一個抽象語法樹(AST)。該解析器組合子的庫是語言無關的,所以你可以用它來為任意語言寫語法分析器。

什麼是解析器組合子?

構建一個語法分析器/解析器有許多許多的方法。而組合子也許是編寫一個能啟動並執行的解析器的最簡單、最快速的方法了。

你可以將一個解析器想象成一個函式。它接收一個標記符流作為輸入。如果成功的話,語法分析器會「消耗」標記符流中的一部分標記符。它將返回最終抽象語法樹(AST)的一部分,以及剩下的標記符。一個組合子本身也是一個函式,它生成解析器作為輸出,一般情況下,它需要以一個或多個解析器作為輸入,因此得名“組合子”。你可以先為語言的某些部分建立許多小的解析器,再用組合子來構建最終的解析器。 通過這種方式,你便能使用組合子來建立一個類似 IMP 的完整語言。

一個最小的組合子庫

解析器組合子相對來說較為通用,且能用在任意語言上。就像我們在編寫詞法分析器時所做的一樣,我們會先寫一個語言無關的組合子庫,之後用它來完成我們的 IMP 語法分析器。

首先,讓我們定一個 Result 類。每個解析器在解析成功時都會返回一個 Result 物件,錯誤時則返回 None 。一個 Result 物件包括了一個值(作為 AST 的一部分)以及一個位置資訊(標記符流中 一下個標記符的索引)。

接著,我們將定義一個基類 Parser 。先前,我提到解析器本質上是以標記符流為輸入的函式。實際上我們也把解析器定義成帶有 call 方法的物件。這意味著一個語法分析器物件會表現得函式一樣,但我們也可以通過定義其它的一些操作符來提供額外的功能。

實際執行解析的方法是 call 。它的輸入是完整的標記符列表(由詞法分析器返回)以及指向列表中的下一個標記符的索引。call 方法的預設實現將總是返回 None(即解析失敗)。Parser 的子類將提供它們自己的 call 方法的實現。

其它的方法, addmulor、及 xor 分別定義了 + 、 * 、 | 、及 ^ 操 作符。每個操作符提供了呼叫不同組合子的快捷方法。我們不久就要介紹到它們。

接下來,我們來看看最簡單的組合子,Reserved 。

Reserved 將被用來解析保留關鍵字及操作符;它將接受有特定值和標籤的標記符。

請記住,標記符只是值-標籤對。token[0] 代表值,token[1] 代表標籤。

At this point, you might stop and say, “I thought combinators were going to be functions returning parsers. This subclass doesn’t look like a function.”

現在呢,你可能會停下說,“我還以為組合了會是返回解析器的函式。可這個子類並不像是函式啊”。如果你把組合子的建構函式當成是一個返回物件(在當前情況下它正好也是可呼叫的)的函式的話, 它組合子本身也就像是函式一樣了。用子類化來定義新的組合子很簡單,因為我們只需要提供一個建構函式和一個 call 方法,同時我們也還能獲得其它的功能(比如那些過載的運算子)。

我們繼續,Tag 組合子和 Reserved 十分相似。它匹配有某一特殊標籤的任意標記符。標記符的值可以是任意值。

Concat 組合子需要兩個解析器作為輸入(左輸入和右輸入)。一個 Concat 解析器在被呼叫的時候,會先呼叫左解析器,再呼叫右解析器。如果兩個解析器都解析成功了,則返回一個包含了左右解析器返回結果的對。如果有一個解析器解析不成功,則返回 None 。

Concat 可用於解析一個特定的標記符序列。例如,要解析 1+2 ,你可以寫

或著用 + 運算子表示,更為簡潔:

Alternate 組合子也類似,它也需要兩個引數:左解析器和右解析器。它先呼叫左解析器,如果解析成功了,剛返回相應的結果。如果不成功,則呼叫右解析器並返回它的結果。

Alternate 可用於在幾個可能的解析器中進行選擇。例如,如果我們想解析任意的二元運算子:

Opt 可用於解析可選的文字,例如 if 語句中的 else 子句。它需要一個語法分析器作為輸入,如果該解析器呼叫成功,則正常返回它的結果。如果失敗,仍返回一個成功的結果,但該結果的值為 None。呼叫失敗時,解析器不消耗標記符,結果的位置與輸入的位置相同。

Rep 組合子將重複呼叫作為輸入的解析器,直到失敗為止。它可以用來生成某樣事物的列表。要注意的是,如果解析器第一次呼叫就失敗了, Rep 仍舊成功返回,此時它匹配的是一 個空的列表,並且不消耗標記符。

Process 是一個有用的組合子,可以用來處理結果的值。它的輸入是一個解析器和一個函式。當解析器被成功呼叫時,Process 會將它的結果傳給作為輸入的函式作為引數,並用該函式返回的結果取代原本的值作為返回的結果。我們會使用 Process 來將 Concat 及 Rep 返回的結果對及結果列表實際構建成 AST 節點。舉個例子,假設我們使用 Concat 構建了一個語法分析器,當它解析 1+2 時,真正返回的結果為 ((‘1’, ‘+’), ‘2’) ,這個結果並不十分有用。使用 Process 我們可以修改返回的結果,例如,下面的解析器就能累加解析得到的表 達式。

Lazy 也是一個很有用的組合子,儘管不那麼明顯。其它組合子需要解析器作為輸入,與之不同,Lazy 接受一個函式作為引數,該函式接收零個引數並返回一個解析器。除非被呼叫了,否則 Lazy 本身不會呼叫這個函式來獲取解析器。要構建遞迴解析器(就如算術表示式本身可以包含另一個算術表示式)的話就得用到它。這是由於遞迴解析器會呼叫自己,我們並不能直接在定義時就直接呼叫自己;因為在該解析器定義語句執行時,解析器本身還沒有被完整定義。使用 Haskell 或 Scala 等支援惰性運算的語言時我們並不需要它,但無奈 Python 什麼支援,就是不支援惰性運算。

下一個組合子 Phrase 接受一個單獨的解析器作為輸入,呼叫它並正常地返回它的結果。唯一的不同是如果它的解析器沒有消耗所有剩餘的標記符,則 Phrase 呼叫失敗。IMP 語言的最高層將會是一個 Phrase 解析器。這會防止我們匹配一個末尾充滿無用內容的程式。

很不幸,最後一個組合子也是最複雜的一個。Exp 的使用場合比較特殊;它將用來匹配一個表示式,該表示式包含一些元素,這些元素以某些內容分隔。複合語句就是其中的一個例子。

這個例子中包含了由分號隔開的一系列語句。你可能覺得我們並不需要 Exp,因為我們可以用其它的組合子來完成相同的功能。我們可以直接為複合語句寫一個像這樣的解析器:

可你得想想我們要如何定義 stmt。

這樣的話 stmt 一執行就呼叫 compound_stmt ,而它一開始執行又呼叫了 stmt 。這兩個解析器會不停地相互呼叫,直至棧溢位為止。這個問題不侷限於複合語句。算術表示式和布林表示式也存在同樣的問題(考慮一下像 + 等等的運算子或把 and 當成分隔符)這個問題被稱為左遞迴,解析器組合子無法很好地解決。

幸運地是,Exp 為我們提供了左遞迴的解決方法,即匹配一個列表,就像 Rep 一樣。Exp 以兩個解析器作為輸入。第一個解析器用於匹配列表中真正的元素。第二個解析器用於匹配分隔符。成功時,分隔符解析器需要返回一個函式,來將解析得到的左右結果合併成一個單獨的 值。這個結果是對整個列表的累加,從左向右,最終作為結果返回。

讓我們看看實際的程式碼:

result 永遠包含當前解析得到的所有資訊。process_next 是一個函式, Process 會用到。next_parser 會首先呼叫 separator,接著呼叫 parser 來得到列表中的下一個元素。process_next 會以當前結果和新解析的元素作為引數,呼叫 separator 函式來建立 一個新的結果。next_parser 不斷地被迴圈呼叫,直到無法匹配更多的元素。

讓我們看看 Exp 如何解決我們的 compound_stmt 問題。

我們也可以寫成這樣:

下一篇文章中,我們會介紹如何解析算術表示式,屆時我們會介紹更多的細節。

未完待續

本文中,我們會建立一個最小的解析器組合子庫。這個庫可以用來為幾乎任何的計算機語言編寫語法分析器。

下篇文章中,我們會為 IMP 建立抽象語法樹需要的資料結構,並且我們會用本文中的庫來定義一個語法分析器。

如果你對現在就想試試 IMP 直譯器,或者你想檢視所有的原始碼,那麼現在就可以來下載它吧。

打賞支援我翻譯更多好文章,謝謝!

打賞譯者

打賞支援我翻譯更多好文章,謝謝!

任選一種支付方式

用 Python 從零開始寫一個簡單的直譯器(2) 用 Python 從零開始寫一個簡單的直譯器(2)

相關文章