跟vczh看例項學編譯原理——三:Tinymoe與無歧義語法分析

陳梓瀚(vczh)發表於2014-03-23

文章中引用的程式碼均來自https://github.com/vczh/tinymoe

 

看了前面的三篇文章,大家應該基本對Tinymoe的程式碼有一個初步的感覺了。在正確分析"print sum from 1 to 100"之前,我們首先得分析"phrase sum from (lower bound) to (upper bound)"這樣的宣告。Tinymoe的函式宣告又很多關於block和sentence的配置,不過這裡並不打算將所有細節,我會將重點放在如何寫一個針對無歧義語法的遞迴下降語法分析器上。所以我們這裡不會涉及sentence和block的什麼category和cps的配置。

 

雖然"print sum from 1 to 100"無法用無歧義的語法分析的方法做出來,但是我們可以借用對"phrase sum from (lower bound) to (upper bound)"的語法分析的結果,動態構造能夠分析"print sum from 1 to 100"的語法分析器。這種說法看起來好像很高大上,但是其實並沒有什麼特別難的技巧。關於"構造"的問題我將在下一篇文章《跟vczh看例項學編譯原理——三:Tinymoe與有歧義語法分析》詳細介紹。

 

在我之前的部落格裡我曾經寫過《如何手寫語法分析器》,這篇文章講了一些簡單的寫遞迴下降語法分析器的規則,儘管很多人來信說這篇文章幫他們解決了很多問題,但實際上細節還不夠豐富,用來對程式語言做語法分析的話,還是會覺得複雜性太高。這篇文章也同時作為《如何手寫語法分析器》的補充。好了,我們開始進入無歧義語法分析的主題吧。

 

我們需要的第一個函式是用來讀token並判斷其內容是不是我們希望看到的東西。這個函式比較特別,所以單獨拿出來講。在詞法分析裡面我們已經把檔案分行,每一行一個CodeToken的列表。但是由於一個函式宣告獨佔一行,因此在這裡我們只需要對每一行進行分析。我們判斷這一行是否以cps、category、symbol、type、phrase、sentence或block開頭,如果是那Tinymoe就認為這一定是一個宣告,否則就是普通的程式碼。所以這裡假設我們找到了一行程式碼以上面的這些token作為開頭,於是我們就要進入語法分析的環節。作為整個分析器的基礎,我們需要一個ConsumeToken的函式:

 

 

作為一個純粹的C++11的專案,我們應該使用STL的迭代器。其實在寫語法分析器的時候,基於迭代器的程式碼也比基於"token在陣列裡的下表"的程式碼要簡單得多。這個函式所做的內容是這樣的,它檢視it指向的那個token,如果token的型別跟tokenType描述的一樣,他就it++然後返回true;否則就是用content和ownerToken來產生一個錯誤資訊加入errors列表裡,然後返回false。當然,如果傳進去的引數it本身就等於end,那自然要產生一個錯誤。自然,函式體也十分簡單:

 

那對於識別符號和數字怎麼辦呢?明眼人肯定一眼就看出來,這是給檢查符號用的,譬如左括號、右括號、冒號和關鍵字等。在宣告裡面我們是不需要太複雜的東西的,因此我們還需要兩外一個函式來輸入識別符號。Tinymoe事實上有兩個針對識別符號的語法分析函式,第一個是讀入識別符號,第二個不僅要讀入識別符號還要判斷是否到了行末否則報錯:

 

在這裡我需要強調一個重點,在寫語法分析器的時候,函式的各式一定要整齊劃一。Tinymoe的語法分析函式有兩個格式,分別是針對parse一行的一個部分,和parse一個檔案的一些行的。ParseToEnd和ParseToFarest就屬於parse一行的一個部分的函式。這種函式的格式如下:

  1. 返回值一定是語法樹的一個節點。在這裡我們以share_ptr<SymbolName>作為識別符號的節點。一個識別符號可以含有多個識別符號token,譬如說the Chinese people、the real Tinymoe programmer什麼的。因此我們可以簡單地推測SymbolName裡面有一個vector<CodeToken>。這個定義可以在TinymoeLexicalAnalyzer.h的最後一部分找到。
  2. 前兩個引數分別是iterator&和指向末尾的iterator。末尾通常指"從這裡開始我們不希望這個parser函式來讀",當然這通常就意味著行末。我們把"末尾"這個概念抽取出來,在某些情況下可以得到極大的好處。
  3. 最後一個token一定是vector<CodeError>&,用來存放過程中遇到的錯誤。

 

除了函式格式以外,我們還需要所有的函式都遵循某些前置條件和後置條件。在語法分析裡,如果你試圖分析一個結構但是不幸出現了錯誤,這個時候,你有可能可以返回一個語法樹的節點,你也有可能什麼都返回不了。於是這裡就有兩種情況:

  1. 你可以返回,那就證明雖然輸入的程式碼有錯誤,但是你成功的進行了錯誤恢復——實際上就是說,你覺得你自己可以猜測這份程式碼的作者實際是要表達什麼意思——於是你要移動第一個iterator,讓他指向你第一個還沒讀到的token,以便後續的語法分析過程繼續進行下去。
  2. 你不能返回,那就證明你恢復不了,因此第一個iterator不能動。因為這個函式有可能只是為了測試一下當前的輸入是否滿足一個什麼結構。既然他不是,而且你恢復不出來——也就是你猜測作者本來也不打算在這裡寫某個結構——因此iterator不能動,表示你什麼都沒有讀。

 

當你根據這樣的格式寫了很多語法分析函式之後,你會發現你可以很容易用簡單結構的語法分析函式,拼湊出一個複雜的語法分析函式。但是由於Tinymoe的宣告並沒有一個程式語言那麼複雜,所以這種巢狀結構出現的次數並不多,於是我們這裡先跳過關於巢狀的討論,等到後面具體分析"函式指標型別的引數"的時候自然會涉及到。

 

說了這麼多,我覺得也應該上ParseToEnd和ParseToFarest的程式碼了。首先是ParseToEnd:

 

我們很快就可以發現,其實語法分析器裡面絕大多數篇幅的程式碼都是關於錯誤處理的,真正處理正確程式碼的部分其實很少。ParseToEnd做的事情不多,他就是從it開始一直讀到end的位置,把所有不是識別符號的token都扔掉,然後把所有遇到的識別符號token都連起來作為一個完整的識別符號。也就是說,ParseToEnd遇到類似"the real 100 Tinymoe programmer"的時候,他會返回"the real Tinymoe programmer",然後在"100"的地方報一個錯誤。

 

ParseToFarest的邏輯差不多:

 

只是當這個函式遇到"the real 100 Tinymoe programmer"的時候,會返回"the real",然後把游標移動到"100",但是沒有報錯。

 

看了這幾個基本的函式之後,我們可以進入正題了。做語法分析器,當然還是從文法開始。跟上一篇文章一樣,我們來嘗試直接構造一下文法。但是語法分析器跟詞法分析器的文法的區別是,詞法分析其的文法可以 "定義函式"和"呼叫函式"。

 

首先,我們來看symbol的文法:

SymbolName ::= <identifier> { <identifier> }

Symbol ::= "symbol" SymbolName

 

其次,就是type的宣告。type是多行的,不過我們這裡只關心開頭的一樣:

Type ::= "type" SymbolName [ ":" SymbolName ]

 

在這裡,中括號代表可有可無,大括號代表重複0次或以上。現在讓我們來看函式的宣告。函式的生命略為複雜:

 

Function ::= ("phrase" | "sentence" | "block") { SymbolName | "(" Argument ")" } [ ":" SymbolName ]

Argument ::= ["list" | "expression" | "argument" | "assignable"] SymbolName

Argument ::= SymbolName

Argument ::= Function

 

Declaration ::= Symbol | Type | Function

 

在這裡我們看到Function遞迴了自己,這是因為函式的引數可以是另一個函式。為了讓這個引數呼叫起來更加漂亮一點,你可以把引數寫成函式的形式,譬如說:

pharse (the number) is odd : odd numbers

    return the number % 2 == 1

end

 

print all (phrase (the number) is wanted) in (numbers)

    repeat with the number in all numbers

        if the number is wanted

            print the number

        end

    end

end

 

print main

    print all odd numbers in array of (1, 2, 3, 4, 5, 6, 7, 8, 9, 10)

end

 

我們給"(the number) is odd"這個判斷一個數字是不是奇數的函式,起了一個別名叫"odd numbers",這個別名不能被呼叫,但是他等價於一個只讀的變數儲存著奇數函式的函式指標。於是我們就可以把它傳遞給"print all (…) in (…)"這個函式的第一個引數了。第一個引數宣告成函式,所以我們可以在print函式內直接呼叫這個引數指向的odd numbers函式。

 

事實上Tinymoe的SymbolName是可以包含關鍵字的,但是我為了不讓它寫的太長,於是我就簡單的寫成了上面的那條式子。那Argument是否可以包含關鍵字呢?答案當然是可以的,只是當它以list、expression、argument、assignable、phrase、sentence、block開始的時候,我們強行認為他有額外的意義。

 

現在一個Tinymoe的宣告的第一行都由Declaration來定義。當我們識別出一個正確的Declaration之後,我們就可以根據分析的結果來對後面的行進行分析。譬如說symbol後面沒有東西,於是就這麼完了。type後面都是成員函式,所以我們一直找到"end"為止。函式的函式體就更復雜了,所以我們會直接跳到下一個看起來像Declaration的東西——也就是以symbol、type、phrase、sentence、block、cps、category開始的行。這些步驟都很簡單,所以問題的重點就是,如何根據Declaration的文法來處理輸入的字串

 

為了讓文法可以真正的執行,我們需要把它做成狀態機。根據之前的描述,這個狀態及仍然需要有"定義函式"和"執行函式"的能力。我們可以先假裝他們是正規表示式,然後把整個狀態機畫出來。這個時候,"函式"本身我們把它看成是一個跟識別符號無關的輸入,然後就可以得到下面的狀態機:

 

 

這樣我們的狀態機就暫時完成了。但是現在還不能直接把它轉換成程式碼,因為當我們遇到一個輸入,而我們可以選擇呼叫函式,而且可以用的函式還不止一個的時候,那應該怎麼辦呢?答案就是要檢查我們的文法是不是有歧義。

 

文法的歧義是一個很有意思的問題。在我們真的實踐一個編譯器的時候,我們會遇到三種歧義:

  1. 文法本身就是有歧義的,譬如說C++著名的A* B;的問題。當A是一個變數的時候,這是一個乘法表示式。當A是一個型別的時候,這是一個變數宣告語句。如果我們在語法分析的時候不知道A到底指向的是什麼東西,那我們根本無從得知這一句話到底要取什麼意思,於是要麼返回錯誤,要麼兩個結果一起返回。這就是問法本身固有的歧義。
  2. 文法本身沒有歧義,但是在分析的過程中卻無法在走每一步的時候都能夠算出唯一的"下一個狀態"。譬如說C#著名的A<B>C;問題。當A是一個變數的時候,這個語句是不成立的,因為C#的一個語句的根節點不能是一個操作符(這裡是">")。當A是一個型別的時候,這是一個變數宣告語句。從結果來看,這並沒有歧義,但是當我們讀完A<B>的時候仍然不知道這個語句的結構到底是取哪一種。實際上B作為型別引數,他也可以有B<C>這樣的結構,因此這個B可以是任意長的。也就是說我們只有在">"結束之後再多讀幾個字元才能得到正確的判斷。譬如說C是"(1)",那我們知道A應該是一個模板函式。如果C是一個名字,A多半應該是一個型別。因此我們在做語法分析的時候,只能兩種情況一起考慮,並行處理。最後如果哪一個情況分析不下去了,就簡單的扔掉,剩下的就是我們所需要的。
  3. 文法本身沒有歧義,而且分析的過程中只要你每一步都往後多看最多N個token,酒可以算出唯一的"下一個狀態"到底是什麼。這個想必大家都很熟悉,因為這個N就是LookAhead。所謂的LL(1)、SLR(1)、LR(1)、LALR(1)什麼的,這個1其實就是N=1的情況。N當然不一定是1,他也可以是一個很大的數字,譬如說8。一個文法的LookAhead是多少,這是文法本身固有的屬性。一個LR(2)的狀態機你非要在語法分析的時候只LookAhead一個token,那也會遇到第二種歧義的情況。如果C語言沒有typedef的話,那他就是一個帶有LookAhead的沒有歧義的語言了。

 

看一眼我們剛才寫出來的文法,明顯就是LookAhead=0的情況,而且連左遞迴都沒有,寫起來肯定很容易。那接下來我們要做的就是給"函式"算first set。一個函式的first set,顧名思義就是,他的第一個token都可以是什麼。SymbolName、Symbol、Type、Function都不用看了,因為他們的文法第一個輸入都是token,那就是他們的first set。最後就剩下Argument。Argument的第一個token除了list、expression、argument和assignable以外,還有Function。因此Argument的first set就是這些token加上Function的first set。如果文法有左遞迴的話,也可以用類似的方法做,只要我們在函式A->B->C->…->A的時候,知道A正在計算於是返回空集就可以了。當然,只有左遞迴才會遇到這種情況。

 

然後我們檢查一下每一個狀態,可以發現,任何一個狀態出去的所有邊,他接受的token或者函式的first set都是沒有交集的。譬如Argument的0狀態,第一條邊接受的token、第二條邊接受的SymbolName的first set,和第三條邊接受的Function的first set,是沒有交集的,所以我們就可以斷定,這個文法一定沒有歧義。按照上次狀態機到程式碼的寫法,我們可以機械的寫出程式碼了。寫程式碼的時候,我們把每一個文法的函式,都寫成一個C++的函式。每到一個狀態的時候,我們看一下當前的token是什麼,然後再決定走哪條邊。如果選中的邊是token邊,那我們就跳過一個token。如果選中的邊是函式邊,那我們不跳過token,轉而呼叫那個函式,讓函式自己去跳token。《如何手寫語法分析器》用的也是一樣的方法,如果對這個過程不清楚的,可以再看一遍這個文章。

 

於是我們到了定義語法樹的時候了。幸運的是,我們可以直接從文法上看到語法樹的形狀,然後稍微做一點微調就可以了。我們把每一個函式都看成一個類,然後使用下面的規則:

  1. 對於A、A B、A B C等的情況,我們轉換成class裡面有若干個成員變數。
  2. 對於A | B的情況,我們把它做成繼承,A的語法樹和B的語法樹繼承自一個基類,然後這個基類的指標就放在class裡面做成員變數。
  3. 對於{ A },或者A { A }的情況,那個成員變數就是一個vector。
  4. 對於[ A ]的情況,我們就當A看,區別只是,這個成員變數可以填nullptr。

 

對於每一個函式,要不要用shared_ptr來裝則見仁見智。於是我們可以直接通過上面的文法得到我們所需要的語法樹:

 

首先是SymbolName:

 

其次是Symbol:

 

然後是Type:

 

接下來是Argument:

 

最後是Function:

 

大家可以看到,在Argument那裡,同時出去的三條邊就組成了三個子類,都繼承自FunctionFragment。圖中紅色的部分就是Tinymoe原始碼裡在上述的文法裡出現的那部分。至於為什麼還有多出來的部分,其實是因為這裡的文法是為了敘述方便簡化過的。至於Tinymoe關於函式宣告的所有語法可以分別看下面的四個github的wiki page:

https://github.com/vczh/tinymoe/wiki/Phrases,-Sentences-and-Blocks

https://github.com/vczh/tinymoe/wiki/Manipulating-Functions

https://github.com/vczh/tinymoe/wiki/Category

https://github.com/vczh/tinymoe/wiki/State-and-Continuation

 

在本章的末尾,我將向大家展示Tinymoe關於函式宣告的那一個Parse函式。文章已經把所有關鍵的知識點都講了,具體怎麼做大家可以上https://github.com/vczh/tinymoe 閱讀原始碼來學習。

 

首先是我們的函式頭:

 

回想一下我們之前講到的關於語法分析函式的格式:

  1. 返回值一定是語法樹的一個節點。在這裡我們以share_ptr<SymbolName>作為識別符號的節點。一個識別符號可以含有多個識別符號token,譬如說the Chinese people、the real Tinymoe programmer什麼的。因此我們可以簡單地推測SymbolName裡面有一個vector<CodeToken>。這個定義可以在TinymoeLexicalAnalyzer.h的最後一部分找到。
  2. 前兩個引數分別是iterator&和指向末尾的iterator。末尾通常指"從這裡開始我們不希望這個parser函式來讀",當然這通常就意味著行末。我們把"末尾"這個概念抽取出來,在某些情況下可以得到極大的好處。
  3. 最後一個token一定是vector<CodeError>&,用來存放過程中遇到的錯誤。

 

我們可以清楚地看到這個函式滿足上文提出來的三個要求。剩下來的引數有兩個,第一個是decl,如果不為空那代表呼叫函式的人已經幫你吧語法樹給new出來了,你應該直接使用它。領一個引數ownerToken則是為了產生語法錯誤使用的。然後我們看程式碼:

 

第一步,我們判斷輸入是否為空,然後根據需要報錯:

 

第二步,根據第一個token來確定函式的形式是phrase、sentence還是block,並記錄在成員變數type裡:

 

第三步是一個迴圈,我們根據當前的token(還記不記得之前說過,要先看一下token是什麼,然後再決定走哪條邊?)來決定我們接下來要分析的,是ArgumentFragment的兩個子類(分別是VariableArgumentFragment和FunctionArgumentFragment),還是普通的函式名的一部分,還是說函式已經結束了,遇到了一個冒號,因此開始分析別名:

 

最後就不貼了,是檢查格式是否滿足語義的一些程式碼,譬如說block的函式名必須由引數開始啦,或者phrase的引數不能是argument和assignable等。

 

這篇文章就到此結束了,希望大家在看了這片文章之後,配合wiki關於語法的全面描述,已經知道如何對Tinymoe的宣告部分進行語法分析。緊接著就是下一篇文章——Tinymoe與帶歧義語法分析了,會讓大家明白,想對諸如"print sum from 1 to 100"這樣的程式碼做語法分析,也不需要多複雜的手段就可以做出來。

相關文章