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

fzr發表於2015-10-19

到目前為止,我們已經為直譯器寫了一個詞法分析器一個解析器組合子庫。在這裡,我們會建立抽象語法樹(AST)的資料結構,使用組合子庫寫一個解析器,組合子庫可以實現將詞法分析器返回的標記列表轉換為一個抽象語法樹(AST)。

定義抽象語法樹(AST)

在我們正式寫解析器之前,需要定義清楚解析器輸出的資料結構。我們將以類的形式實現這個資料結構。IMP語言的每個語法元素都有一個對應的類。這些類的物件各自代表了抽象語法樹(AST)的一個結點。

IMP 中有三種結構:算術表示式(用於計算數字)、布林表示式(用於為if-else和while語句計算條件)、宣告語句。我們將從算術表示式開始,因為另外兩種都依賴於它們。

算術表示式可能是下列三種之一:

  • 文字型整型常量,比如42;
  • 變數,比如x;
  • 二進位制操作,比如x+42

這些組成了其他的算術表示式。

我們也可以用括號將表示式分組,像(x+2)*3。這並不是另一種不同的表示式,而是一種解析表示式的方式。

我們將為這三種形式定義三個類,加上為一般算術表示式定義的基類。現在,這些類除了儲存資料並沒有太多的功能。包含了__repr__函式,我們就可以在除錯時列印抽象語法樹(AST)。所有的AST類從繼承自Equality,這樣我們可以比較兩個AST物件是不是相同的。這對於測試很有用。

布林表示式有一點複雜。它分為四種:

  • 關係式表達(如x < 10
  • 與表示式(如x < 10 and y > 20
  • 或表示式
  • 非表示式

關係表示式的左邊和右邊都是算術表示式。“與”,“或”和“非”的左右兩邊都是布林表示式。限制型別可以幫助我們避免類似 “x<10 and 30” 這樣的無意義的表示式。

宣告語句既可以包含算術表示式,也可以包含布林表示式。宣告語句也分為四種:賦值語句、複合語句、條件語句以及迴圈語句。

原語

既然已經有了表示抽象語法樹(AST)的類和一個簡便的解析器組合子,那就該實現解析器了。實現解析器的時候,從語言的最基本的結構開始並繼續,通常是最容易的辦法。

我們將要研究的第一個解析器是關鍵字keyword的解析器。這是Reserved組合子的一個具體版本,該版本使用的是標籤RESERVED ,而所有的關鍵字都被標記的是RESERVED 標籤。記住,當文字和標籤都和給定的一模一樣時,Reserved只能匹配一個單一的字元。

keyword實際上也是一個組合子,因為它是一個能返回解析器的函式。我們會在其他的解析器中直接使用它。id解析器通常被用來匹配變數名。它使用Tag組合子,針對具體標籤匹配一個字元。

num解析器用來解析整數。除了使用Process組合子外,num解析器和id解析器類似。它使用Process組合子(實際上是一個會呼叫Process^操作符)將字元轉換成實際的整數值。

解析算術表示式

解析算術表示式並不是最簡單的事情,但是解析布林表示式和宣告語句都需要解析算術表示式,所以我們從它開始。

首先要定義aexp_value解析器,它將numid解析器返回的值轉變為實際的表示式。

我們會在這裡使用 | 操作符,這是Alternate組合子的簡寫。所以它會嘗試先解析整數表示式。失敗了才會去解析變數表示式。

你會看到我們將aexp_value定義成一個零引數的函式,而不是一個全域性量(global value),像處理idnum一樣。對於所有其他的解析器,也都是一樣處理。原因是我們不希望每個解析器的程式碼立刻被評估。如果把每個解析器都定義為全域性量,每個解析器都不能引用同一個原始檔中定義在其之後的解析器了,因為那時他們還沒定義。

下一步,我們想支援使用括號為算術表示式分組。儘管分組表示式不需要獨自的AST類,但它們也需要一個解析器來處理它們。

process_group是一個使用Process組合子(^操作符)的函式。它會去掉括號並返回其中的表示式。axep_group是實際的解析器。記住,操作符是Concat組合子的簡寫。所以這將解析‘(’,然後是一個算術表示式(由aexp解析,稍後會定義),接著是‘)’。需要避免直接呼叫aexp,因為aexp會呼叫aexp_group,它會導致無限遞迴。為了做到這一點,我們使用了Lazy組合子,它只有在解析器被實際用於某個輸入時才會呼叫aexp函式。

接下來,我們使用aexp_termaexp_valueaexp_group聯絡起來。任何獨立基本的表示式都是aexp_term表示式,我們不必擔心運算子相對於其他表示式的優先順序。

現在到了比較棘手的部分:操作符和優先順序。對我們而言,只是定義了另一種解析器然後與aexp_term一起處理。這就導致一個簡單的表示式,如:

被錯誤的解析為:

解析器需要知道操作符優先順序,進而將優先順序較高的操作分組。我們會定義幾個輔助函式來實現這部分工作。

實際上構成BinoAexp物件的是process_binop。它讀入一個算術操作符並返回一個能聯絡使用該操作符的一對錶達式的函式。

Exp組合子(*操作符)會使用proce_binopExp解析一系列已經分隔好的表示式對。Exp的左運算元是一個解析器,它能匹配表示式列表裡的獨立元素(在我們的例子中是算術表示式)。Exp的右運算元也是一個解析器,它能匹配分隔符(即操作符)。不論匹配的是哪個分隔符,右邊的解析器都會返回一個函式,給定匹配的分隔符,該函式會返回聯結函式。聯結函式的輸入是分隔符左右兩邊的已分解的表示式,返回的是一個單一的,組合後的表示式。是不是很困惑?我們將會大致看一下Exp的使用方法。左邊的解析器事實上返回的就是process_binop

接下來,我們會定義優先順序以及一個處理它們的組合子。

any_operator_in_list輸入一系列關鍵字的字串,返回能匹配它們中任意一個的解析器。這個函式將會在aexp_precedence_levels中呼叫,其中包含了每個優先順序的一系列操作符(最高優先順序優先)。

precedence是這個操作的真正的重點。它的第一個引數,value_parser是一個解析器,它可以讀取表示式的基本部分:數字,變數和分組。這將是aexp_termprecedence_levels是一個操作符列表,每一個優先順序一個列表。我們使用aexp_precedence_levels做到這些。給定一個操作符,combine會返回一個函式,該函式將兩個比較小的表示式轉換成一個比較大的表示式。那就是process_binop

precedence內部,我們首先定義了op_parser,對於給定的優先順序,讀取該級別的任意操作符,返回一個能聯結兩個表示式的函式。op_parser可以作為Exp的右操作符引數。我們從為最高優先順序呼叫op_parserExp開始,因為這些操作應該首先被分組。然後我們用生成的解析器作為下一個優先順序的元素解析器(Exp的左引數)。迴圈結束後,所得到的解析器能夠正確解析任何算術表示式。

這在實際中是如何工作的呢?讓我們一起來看看。

E0value_parser一樣。它可以解析數字,變數和分組,但不包括操作符。任何包含能被E0匹配,由第一優先順序的操作符分隔開的內容的表示式都能被E1解析。所以E1可以匹配a*b/c,但是當它遇到+操作符的時候會報出錯誤。E2則能匹配任何E1能匹配,由下一優先順序的操作符分隔開的表示式。由於我們只有兩種優先順序,E2能匹配任何我們能支援的算術表示式。

一起來看個例子。以一個複雜的表示式為例,逐步以匹配的方式取代各部分。

使用precedence直接定義aexp:

我們也能以一種不太抽象的方式定義優先順序,但它的優勢在於它可以適用於任何操作符優先順序是個問題的場景。在解析布林表示式的時候還會再使用它。

解析布林表示式

解決了算術表示式,我們可以轉移到布林表示式了。實際上布林表示式比算術表示式簡單,我們不需要任何新的工具來解析它們。我們將從最基本的布林表示式,關係式,開始:

process_relop只是一個使用了Process組合子的函式。它需要三個連線值並從中建立出RelopBexp。在bexp_relop中,解析了兩個由關係操作符分隔開的算術表示式(aexp)。使用了之前比較熟悉的any_operator_in_list,這樣我們就不必為每個操作符都單獨寫一個處理程式。也沒有必要使用Exp或是precedence之類的組合子,因為IMP裡的關係表示式並不能像其他語言裡那樣連結使用。

接下來,我們定義了not(非)表示式。not(非)是一個具有高優先順序的一元運算。這使得它比and(與)和or(或)表示式更容易解析。

這裡,我們只是將關鍵字not與一個布林表示式(下一步將會定義)串連在一起。由於bexp_term將用bexp_not來定義,我們需要使用lazy組合子來避免無限遞迴。

對於算術等式,我們幾乎以相同的方式定義bexp_group和bexp_term。這裡並沒有什麼新東西。接下來,我們需要定義包含and(與)和or(或)操作符的表示式。這些操作符實際上和算術操作符一樣的工作原理;二者都是從左往右解析,and(與)有比較高的優先順序。

就像process_binop,process_logic本意是與Exp組合子一起使用。它需要一個操作符,返回一個函式,該函式使用前面的操作符將兩個子表示式聯結成一個表示式。在這個過程中還有優先順序precedence,就像aexp那樣。編寫泛型程式碼在這裡就體現價值了,因為我們不必重複編寫複雜的表示式處理程式碼。

解析宣告語句

aexp和bexp已經完成了,我們可以開始解析IMP宣告語句了。我們將從低階的賦值語句開始。

這個就沒有什麼特別有趣的。接下來,我們先看看stmt_list。它會解析一系列以分號分隔的語句。

記住,這裡我們需要使用Exp組合子,而不能只簡單處理以避免左遞迴,就像stmt() + keyword(‘;’) + stmt()這樣。

下一步是if語句:

這裡唯一複雜的地方是else從句是可選的。這使得process函式有點複雜。

最後,開始處理while迴圈語句:

我們用stmt來包裝它:

你會發現無論是if語句還是while語句都用的是stmt_list作為它們的主體而不是stmt。stmt_list實際上是我們高階定義。我們不能讓stmt直接依賴於stmt_list,因為這樣的解析器會導致左遞迴。而且由於我們希望if和while語句都能夠把複合語句作為主體,所以我們直接使用stmt_list。

整合

現在我們對語言的每個部分都建立了解析器。我們只需要做幾個高階定義:

parser會解析整個程式。一個程式只不過是一個語句列表,但是Phrase組合子保證我們用到了檔案的每一個標記符,而不是在最終遇到無用標記符後提前結束。

客戶端需要解析程式時可以呼叫函式imp_parse 。它需要一個標記符列表,呼叫parser,從第一個標記符開始,然後返回結果AST。

為了測試解析器(除了單元測試),這裡寫了一個簡單的驅動程式:

這個程式會讀入檔案(第一個引數),然後用imp_parse.py(第二個引數)中的解析器解析該檔案。例如:

這應該是一個很好的實驗方法。

總結

本文中,我們從頭開始建立了一個解析器組合子庫,並把它用於IMP的解析器。在本系列的下一篇也是最後一篇中,我們會為已解析的AST寫一個求值器。

再一次,直譯器所有的原始碼都在這裡提供下載。

相關文章