自制指令碼語言(8) 從LR(1) 到 GLR parser generator
摘要:升級前文的LR(1) parser generator為GLR,為自制指令碼語言加入面向物件、泛型模板和函數語言程式設計等語法做好準備。
原始碼地址: www.github.com/nklofy/Compiler
LR(1) parser為前文的自制指令碼語言立下了汗馬功勞。但是指令碼語言不能只滿足於C的子集,至少應該OOP型別繼承、泛型要加入吧,甚至lambda表示式和函數語言程式設計也要加入吧。那樣LR(1)就不夠用了。一個比較快捷的方法是,LR(1)升級到GLR。parser generator只需要少量修改,parser需要將原來的符號棧改為棧圖。目前,parser generator和parser已經完成。
GLR parser,是可以容許shift-reduce衝突、reduce-reduce衝突的LR解析方法。它的基本思想是:每次讀入一個token作為前瞻符號,執行reduce和shift,如果遇到shift-reduce衝突或reduce-reduce衝突,就暫時保留所有可能的parsing路線,當遇到reduce或shift都不能執行,則去掉該parsing線路。當最終讀取eof並規約到goal時,parsing結束。比起LR(1),因為有大量的可能路線同時存在,全部都要執行shift和reduce,其實可能只有一條正確的路線,這樣就導致大量時間浪費,效率差。
在Elkhound的論文<A fast, parctical GLR parser generator>中,提出了幾種優化GLR的辦法。其中最重要的是GLR和LR的切換。在某些情況下,只有單線parsing向前推進,這種情況下,reduce可以採用LR的規約方式,即直接向前查詢。當遇到分岔路時,必須採用GLR的規約方式。另外,Elkhound提到了一些其他技巧,包括公共子樹重用,合併二義性,物件管理等。我設計的parser generator,主要就是應用了GLR/LR自動切換,另外有一個HashMap,把活動的棧頂狀態按照狀態編號放入,能夠及時識別和合並相同狀態。因為Java語言有GC,所以就不用像Elkhound的論文中那麼特意關注物件管理的問題。下面詳細介紹我這裡設計的parser generator.
首先是與LR變化不大的action表與goto表。LR的shift和reduce都是確定性的,而GLR是非確定性的。在前面的LR generator中,根據語法定義,很可能出現shift-reduce衝突或reduce-reduce衝突,GLR的對策是將衝突全部記錄下來。比如原來s18或r19,現在可能是s18r19,或者r19r31,甚至s18r13r17r19. 相應的,parser中讀取action表時,需要用兩個表記錄reduce。第一個是list<Integer>, 第二個是list<list<Integer>>,第一個list是在無reduce-reduce衝突時使用,用-1表示無reduce項,-2表示出現reduce-reduce衝突而需要使用第二個表格。全部的reduce規則都連續記錄到第二個表格的第二個list中。
ArrayList<ArrayList<Integer>> shift_table
ArrayList<ArrayList<Integer>> goto_table
ArrayList<ArrayList<Integer>> reduce_table
ArrayList<ArrayList<ArrayList<Integer>>> reduces_table
HashMap<Integer,ParseState> states_active
class Symbol{ //解析的每個狀態,生成symbol來儲存已解析的資訊,尤其是reduce的路徑,這裡保留了路徑的頭、尾、長度
AST ast
int line
ParseState path_start //起點
ParseState path_end //終點
int path_count //規約路徑的長度
String type
String name
String value
}
class ParseState{ //解析棧圖的狀態結點
boolean fixed=true //解析是否確定
ParseState pre_state //在解析棧圖的前結點
LinkedList<ParseState> pre_states //在解析棧圖的多個前結點
Symbol symbol //規約的symbol
LinkedList<Symbol> symbols //多個可能規約的symbol
int state_sn
int det_depth; //直接LR的深度,用於GLR/LR切換
boolean addLink(ParseState pre_state,Symbol symbol) //建立前後結點的連結
}
這裡要說棧圖的概念。LR是一個符號棧。shift時入棧,reduce時按語法產生式的符號數出棧,然後按照棧頂狀態和goto表產生新狀態併入棧。開始parsing時,初始狀態為0。結束時,終結狀態也是0. 而GLR把棧擴充套件為棧圖,意即多個可能的狀態,都用圖的方式表示而不是棧。shift時,新狀態作為新結點,連結到前面的狀態後。reduce時,不是出棧而是根據語法產生式的符號數向前回溯,同樣查詢goto表產生新狀態,把新狀態連結到回溯的位置處。棧圖維持一個HashMap,表示活動的棧頂狀態。reduce和shift執行後,新狀態加入HashMap。HashMap的作用還有一條就是及時合併不同路徑中的相同狀態。
Parser在不斷迴圈的過程中,每次讀取一個token。首先執行reduce。查表得到存在或不存在reduce,或者有reduce-reduce衝突。儘可能的把所有可規約的語法都用上,得到新的狀態。新狀態加入棧圖後,嘗試shift操作。若可以shift(即action table中查shift不等於-1),則執行shift,並把新狀態放入active HashMap。下一輪讀取新token,執行新reduce和shift,是從active HashMap中取出各個元素來執行的。
這裡面有幾個關鍵點。
1,空轉移。比如static public class A。static和public可以省略,在語法中就可以為空,記作ε(程式中寫為e)。在我的parser裡面,執行reduce之前首先執行e-shift,就是儘量嘗試可行的e轉移。按照shift table中e符號下的轉移狀態轉移後,再執行reduce,以及reduce後的新狀態再嘗試e-shift。這裡需要一個list,儲存待執行reduce的狀態。e-shift後的狀態都加入這個list後面。
2,可能有shift-reduce衝突或reduce-reduce衝突。導致了棧圖的分叉與合攏。這個就要靠前面提到的reduce list了。reduce後狀態為s_r, shift後狀態為s_s,s_s加入active map,但是s_r要放入reduce list,因為s_r可能除了shift以外也能繼續reduce。對reduce-reduce衝突的情況,因為reduce table記錄了所有reduce的語法,所以對多條語法產生式只需要依次進行reduce操作,並加入到reduce list即可。而活動棧頂的HashMap,保證了相同狀態能夠及時合併。在我的parser裡面,合攏動作只在shift時進行。因為只要保證每一輪reduce-shift能及時合攏就足夠了。並不需要reduce時合一次,shift時再合一次。合攏動作需要HashMap,兩個Map在我看來是沒有必要的。
3,如何判斷GLR和LR的切換。棧圖中,每個狀態結點都有個屬性depth,表示從該結點可以無歧義的reduce的最大深度。例如,當兩個狀態shift或reduce後,進行到同一個狀態,則這個終點的depth設為0. 之後如果是單線前進,則depth每一步加1. 遇到reduce時,只要當前depth大於等於語法產生式的符號數量,則可知回溯必定為一條單線。則執行普通的LR reduce。但是如果depth小於語法產生式的符號數量,則首先單線回溯depth個狀態,然後進入GLR reduce過程。GLR reduce過程是,一步一步向前,每一步把可能的狀態放入一個HashSet。可能的狀態是指狀態儲存的符號名與語法產生式的符號名一致。HashSet每步清空一次,然後尋找,並把所有可能的狀態加入。直到回溯完成語法產生式的符號數量。
下面是GLR棧圖的示意圖:
從狀態0出發,shift時加入新結點和邊,reduce時向前回溯,並按照goto表,得到新狀態和加入新邊。shift和goto的新結點是圓形,shift和goto過程用實線箭頭表示。reduce的結點是方形,reduce過程用虛線表示。活動棧頂儲存在active states裡面,用紅色虛線指向活動的棧頂結點。注意到結點7出現一個shift-reduce衝突。