上回我們說到語法分析使用的上下文無關語言,以及描述上下文無關文法的產生式、產生式推導和語法分析樹等概念。今天我們就來討論實際編寫語法分析器的方法。今天介紹的這種方法叫做遞迴下降(recursive descent)法,這是一種適合手寫語法編譯器的方法,且非常簡單。遞迴下降法對語言所用的文法有一些限制,但遞迴下降是現階段主流的語法分析方法,因為它可以由開發人員高度控制,在提供錯誤資訊方面也很有優勢。就連微軟c#官方的編譯器也是手寫而成的遞迴下降語法分析器。
使用遞迴下降法編寫語法分析器無需任何類庫,編寫簡單的分析器時甚至連前面學習的詞法分析庫都無需使用。我們來看乙個例子:現在有一種表示二叉樹的字串表示式,它的文法是:
n → a ( n, n )
n → ε
其中終結符a表示任意乙個英文本母,ε表示空。這個文法的含義是,二叉樹的節點要麼是空,要麼是乙個字母開頭,並帶有一對括號,括號中逗號左邊是這個節點的左兒子,逗號右邊是這個節點的右兒子。例如字串a(b(,c(,)),d(,))就表示這樣一棵二叉樹:
注意,文法規定節點即使沒有兒子(兒子是空),括號和逗號也是不可省略的,所以只有乙個節點的話也要寫成a(,)。現在我們要寫乙個解析器,輸入這種字串,然後在記憶體中建立起這棵二叉樹。其中記憶體中的二叉樹是用下面這樣的類來表示的:
這是一道微軟面試題,曾經難倒了不少參加面試的候選人。不知在座各位是否對寫出這段程式有信心呢?不少參選者想到了要用棧,或者用遞迴,去尋找逗號的位置將字串拆解開來等等方法。但是若是使用遞迴下降法,這個程式寫起來非常容易。我們來看看編寫遞迴下降語法分析器的一般步驟:使用乙個索引來記錄當前掃瞄的位置。通常將它做成乙個整數字段。
為每個非終結符編寫乙個方法。
如果乙個非終結符有超過乙個的產生式,則在這個方法中對採用哪個產生式進行分支**。
處理單一產生式時,遇到正確終結符則將第一步建立的掃瞄索引位置向前移動;如遇到非終結符則呼叫第二步中建立的相應方法。
如果需要產生解析的結果(比如本例中的二叉樹),在方法返回之前將它構造出來。
我們馬上來試驗一下。首先建立乙個類,然後存放乙個索引變數來儲存當前掃瞄位置。然後要為每乙個非終結符建立乙個方法,我們的文法中只有乙個非終結符n,所以只需建立乙個方法:
回到剛才的產生式,我們看到非終結符n有兩個產生式,所以在parsenode方法的一開始我們必須做出分支**。分支**的方法是超前檢視(look ahead)。就是說我們先「**」當前位置前方的字元,然後判斷應該用哪個產生式繼續分析。非終結符n的兩個產生式其中乙個會產生a(n, n)這個的結構,而另乙個則直接產生空字串。那現在知道,起碼有一種可能就是會遇到乙個字母,這時候應該採用n → a(n, n)這個產生式繼續分析。那麼什麼時候應該採用n → ε進行分析呢?我們觀察產生式右側所有出現n的地方,倘若n是空字串,那麼n後面的字元就會直接出現,也就是逗號和右括號。於是這就是我們的分支**:如果超前檢視遇到英文本母,**分支n → a(n, n)
如果超前檢視遇到逗號、右括號**分支n → ε
轉化成**就是這樣:
接下來我們分別來看兩個分支怎麼處理。先來看n → ε,這種情況下非終結符是個空字串,所以我們不需要移動當前索引,直接返回null表示空節點。再來看n → a(n, n) 分支,倘若輸入的字串沒有任何語法錯誤,那就應該依次遇到字母、左括號、n、逗號、n右括號。根據上面的規則,凡是遇到終結符,就移動當前索引,直接向前掃瞄;而要是遇到非終結符,就遞迴呼叫相應節點的方法。所以(不考慮語法錯誤)的完整方法**如下: 因為存在語法約束,所以一旦我們完成了分支**,就能清楚地知道下乙個字元或非終結符一定是什麼,無需再進行任何判斷(除非要進行語法錯誤檢查)。因此根本就不需要尋找逗號在什麼位置,我們解析到逗號時,逗號一定就在那,這種感覺是不是很棒?只需要寥寥幾行**就已經寫出了乙個完整的parser。大家感興趣可以繼續補全一些輔助**,然後用真正的字串輸入試驗一下,是否工作正常。前面假設輸入字串的語法是正確的,但真實世界的程式總會寫錯,所以編譯器需要能夠幫助檢查語法錯誤。在上述程式中加入語法錯誤檢查非常容易,只要驗證每個位置的字元,是否真的等於產生式中規定的終結符就可以了。這就留給大家做個練習吧。上面我們採用的分支**法是「人肉觀察法」,編譯原理書裡一般都有一些計算first集合或follow集合的演算法,可以算出乙個產生式可能開頭的字元,這樣就可以用自動的方法寫出分支**,從而實現遞迴下降語法分析器的自動化生成。antlr就是用這種原理實現的乙個著名工具。有興趣的同學可以去看編譯原理書。其實我覺得「人肉觀察法」在實踐中並不困難,因為程式語言的文法都特別有規律,而且我們天天用程式語言寫**,都很有經驗了。
f → id
f → ( e )
e → f * f
e → f / f
當我們編寫非終結符e的解析方法時,需要在兩個e產生式中進行分支**。然而兩個e產生式都以f開頭,而且f本身又可能是任意長的表示式,無論超前檢視多少字元,都無法判定到底應該用乘號的產生式還是除號的產生式。遇到這種情況,我們可以用提取左公因式的方法,將它轉化為ll(k)的文法:
f → id
f → ( e )
g → * f
g → / f
e → fg
我們將乙個左公因式f提取出來,然後將剩下的部分做成乙個新的產生式g。在解析g的時候,很容易進行分支**。而解析e的時候則無需再進行分支**了。在實踐中,提取左公因式不僅可以將文法轉化為ll(k)型,還能有助於減少重複的解析,提高效能。
下面我們來看ll(k)文法的第二個重要的限制——不支援左遞迴。所謂左遞迴,就是產生式產生的第乙個符號有可能是該產生式本身的非終結符。下面的文法是乙個直截了當的左遞迴例子:
f → id
e → e + f
e → f
這個表示式類似於我們上篇末尾得到的無歧義二元運算子的文法。但這個文法存在左遞迴:e產生的第乙個符號就是e本身。我們想像一下,如果在編寫e的遞迴下降解析函式時,直接在函式的開頭遞迴呼叫自己,輸入字串完全沒有消耗,這種遞迴呼叫就會變成一種死迴圈。所以,左遞迴是必須要消除的文法結構。解決的方法通常是將左遞迴轉化為等價的右遞迴形式:
f → id
e → fg
g → + fg
g → ε
大家應該牢牢記住這個例子,這不僅僅是個例子,更是解除大部分左遞迴的萬能公式!我們將要在編寫minisharp語法分析器的時候一次又一次地用到這種變換。
由於ll(k)文法不能帶有左遞迴和左公因式,很多常見的文法轉化成ll(k)之後顯得不是那麼優雅。有許多程式設計師更喜歡使用lr(k)文法的語法分析器。lr代表從左到右掃瞄和最右推導。lr型的文法允許左遞迴和左公因式,但是並不能用於遞迴下降的語法分析器,而是要用移進-歸約型的語法分析器,或者叫自底向上的語法分析器來分析。我個人認為lr型語法分析器的原理非常優雅和精妙,但是限於本篇的定位我不準備介紹它。我想任何一本編譯原理書裡都有詳細介紹。當然如果未來我的vbf庫支援了lr型語法分析器,我也許會追加一些特別篇,誰知道呢?
希望大家繼續關注我的vbf專案: 和我的微博: 多謝大家支援!
自己動手寫編譯器 鏈結器致謝
自己動手寫編譯器 鏈結器 本書投稿後,有幸請csdn暨 程式設計師 雜誌總編 劉江老師閱讀了本書的初稿,並為本書作序,在此向劉老師表示最衷心的感謝。本書臨近出版之際,承蒙清華大學王生原老師閱讀了本書終稿,並對書稿做了中肯評價 本書特色鮮明,內容有深度,文筆也很不錯,很值得出版。本書最大的特色是所選的...
自己動手寫編譯器 鏈結器致謝
本書投稿後,有幸請csdn暨 程式設計師 雜誌總編 劉江老師閱讀了本書的初稿,並為本書作序,在此向劉老師表示最衷心的感謝。本書臨近出版之際,承蒙清華大學王生原老師閱讀了本書終稿,並對書稿做了中肯評價 本書特色鮮明,內容有深度,文筆也很不錯,很值得出版。本書最大的特色是所選的目標平台,即x86處理器以...
打造自己的編譯器
為了更深入的了解程式設計的底層,就想研究下編譯。找了幾本編譯原理看,什麼正規表示式 自動機把我都搞糊塗了。把複雜的問題弄得更複雜,這不是我希望的。還是自己做個東西模擬下編譯過程。任務 讀入原始碼,輸出彙編。為什麼不直接生成機器碼?彙編可以直接看出編譯是否錯誤。先設定乙個語言模型 只有運算子 識別符號...