語法分析的那些演算法
前言
在編譯原理中,語法分析可以說是編譯器前端的核心。語法分析的輸出,抽象語法樹,更是一座建立在編譯器前端和後端之間非非非非非常重要的橋樑。
我們知道,編譯器可以分為前後端,而前後端又可以分為多個模組,每個模組環環相扣,體現出一種面向過程的程式設計思想。每一個模組的輸入僅僅是上一個模組的輸出,而語法分析的產出物,抽象語法樹,是連線前後端的唯一橋樑,所有編譯器後端的模組都必須依靠抽象語法樹,抽象語法樹必須提供足夠的資訊以供後端許多模組使用,所以設計好一個抽象語法樹是十分重要的。一般來說,一棵抽象語法樹需要提供每條語法在原始檔中的行號列號,以及檔案號等諸多資訊,當然了,抽象語法樹的設計很具有特殊性,沒有一套國際上通用的模式來套,而是需要設計者根據需求定製。
語法分析器以詞法分析器的產出(TOKENS)作為輸入,在語法規則限制下,使用不同的分析演算法,產出滿足語法的抽象語法樹。而產出的語法樹還需要經過語義分析器來進行型別檢查,這才算完成了編譯器前端的工作。產出的抽象語法樹之所以需要經過一次嚴格的型別檢查,是因為語法分析的過程使用的是一種與上下文無關的語法規則,即上下文無關文法來進行分析,接下來我們給出上下文無關文法的定義。
上下文無關文法
定義:CFG(N, T, S, R)
N - Nonterminal - 非終結符
T - Terminal - 終結符
S - Start Character - 開始符號
R - Syntax Rule - 語法規則
詳細定義請參見: https://en.wikipedia.org/wiki/Context-free_grammar
簡單的說,我們在製作編譯器的過程中,會遇到的上下文無關文法是長這樣的:
arith_exp: exp PLUS exp | exp MINUS exp | exp TIMES exp | exp DIVIDE exp
好了,基礎的部分到這為止,接下來才是本文的重要內容。
前面提到,語法分析器有不同的演算法可以進行語法分析,我們就來談一談這些有印象但是不太瞭解的演算法,以及為什麼會有這麼多不同的演算法。
一般來說,語法分析的演算法分為兩種,自頂向下的演算法和自底向上的演算法,而這兩類演算法又有很多不同的實現方式,我們只談最主流的方式:
自頂向下:
-
遞迴下降演算法
-
LL(1)
自底向上:
-
LR(0)
-
LR(1)
這些演算法按具體的實現方式,又可以分為:分析棧方式、分析表方式:
其中自頂向下的方式就是分析棧的方式,自底向上的方式就是分析表的方式。所謂分析棧的方式,其實就是演算法的過程類似於樹的後序遍歷,所謂分析表的方式,就是我之前一篇文章 正則表示式匹配可以更快更簡單 (but is slow in Java, Perl, PHP, Python, Ruby, ...) 裡提到的有限自動機DFA的方式。
自頂向下
自頂向下的演算法其基本思想就是列舉、窮舉,使用樹的後序遍歷,窮舉文法可以產出的所有句子,然後跟輸入做比較,能夠匹配成功,說明語法正確。當然了,我說的窮舉所有結果不是真的把所有結果都計算出來,其中會有一些優化的,比如說後序遍歷的過程中發現產生的第一個字母和輸入的第一個字母不匹配,會直接回溯而不是還傻傻的算下去。
文字化描述:
給定文法CFG和待匹配句子s,回答s能否從CFG推匯出來?
演算法:從G的開始符號出發,隨意推出某個句子t,比較s和t:
- 若 t == s ,則回答 “是”
- 若 t != s ,則回答 “否”
程式碼描述:
tokens[];// 所有token i = 0; stack = [S] // S是開始符號 while (!stack.empty()) if (stack.top() is a terminal t) if (t == tokens[i]) pop(); //成功 i++; else backtrack(); //回溯 else if (stack.top() is a non-terminal T) pop(); push(next possible choice); // 請注意 possible
舉個例子:
給定CFG:
S -> N V N N -> s -> t -> g -> w V -> e -> d
待匹配句子 gdw
這個演算法有很多問題,首當其衝的就是回溯開銷太大,就像上圖,當發現匹配錯誤的時候,分析棧需要回溯到原來的樣子,然後再次遍歷,這是不能忍的行為。
之前我說了,在語法分析這塊有很多的演算法,他們的出現都是為了解決前一個演算法遇到的問題,接下來我們看看遞迴下降演算法解決了哪些問題。
遞迴下降演算法
首先介紹下,在語法分析這一塊,程式設計師有兩種實現方式,一種是純手工編碼來實現演算法,然後製作語法分析器,第二種方式就是利用語法生成器,比如每一臺 Linux 上都有的 Yacc Bison 等,這些自動生成器會根據一些語法規則來自動生成程式碼完成語法分析,真是爽爆啦。
不過主流的編譯器,比如 GCC LLVM ,其實現方式就是純手工編碼的方式,而在純手工編碼的方式中,最最常用的就是遞迴下降演算法,這是個很有名的演算法哦。
遞迴下降演算法具有這些優點:
- 分析高效(線性時間)
- 容易實現(方便手工編碼)
- 錯誤定位和診斷資訊準確(準確定位語法錯誤)
說了這麼多優點,來看看演算法長啥樣。遞迴下降演算法的基本思想是建立在前面自頂向下演算法之上的,前面的自頂向下演算法的最大弊端就是很多的回溯,而如果這時候問你,你有什麼解決方案?一個比較好的解決方案就是預測未來。
看看上面演算法的這一句:
push(next possible choice); // 請注意 possible
如果我能預測未來,我不是選擇 possible ,而是選擇 right ,比如我 提前看 一個符號,我發現是 g ,那麼我就直接選擇 push g。上面的演算法就可以這樣改:
parse_S() parse_N() parse_V() parse_N() parse_N() token = tokens[i++] // 前看 if (token == s || token = t ...) return;// OK error("expect s, t, but given ...") parse_V() token = tokens[i++] // 前看 if (token == e || token = d ...) return; // OK error("expect e, d, but given ...")
遞迴下降演算法的基本思想:用 前看符號 指導語法規則的選擇,對每一個非終結符構造一個分析函式。
我們看一段遞迴下降演算法的程式碼,會發現其實就是分治法(Divide and Conquer),演算法經常長這個樣子
parse_X() token = nextToken() switch(token) case 1: parse_E(); eat('+'); parse_T(); // ... case 2: // ... ... default: error("expect ..., but given ...");
我們說很多主流編譯器都是使用的遞迴下降演算法來進行語法分析,但是遞迴下降演算法就真的這麼好嗎?就無敵了嗎?
考慮以下文法:
E -> E + T -> T T -> T * F -> F F -> num
現在我的待匹配句子是 3+4*5 ,這時候該怎麼寫一個遞迴演算法?
你可能會這樣寫:
parse_E() token = tokens[i++] if (token == num) ? // 是呼叫 E + T 還是呼叫 T else error("expect ..., but given ...")
這一下子就把遞迴下降演算法給難住了,因為呼叫 E + T 和呼叫 T 都可以的,這時候唯一的解決辦法好像就是都試一遍,看看誰滿足。不過,等等,這怎麼好像回到回溯的辦法了?其實這類問題還真是一個大問題,不過對於遞迴演算法,這是一種可以避免的問題,簡單點說,這不是硬傷,而是可以通過聰明的程式設計師對語法的理解和改造足以解決的。比如對於這個文法,我的程式碼可以這樣寫:
parse_E() parse_T() token = tokens[i++] while (token == '+') parse_T() token = tokens[i++] parse_T() parse_F() token = tokens[i++] while (token == '*') parse_F() token = tokens[i++]
其實這種問題是一類比較經典的問題,就是二義性語法的問題,這麼一提,我們當然知道,消除二義性文法就是消除左遞迴和左因子嘛,OK,這些東西我們下面再談。
不過,寫到這裡還是要總結一下,遞迴下降演算法和自頂向下演算法都是樹的後序遍歷,一種是遞迴的方式,另一種是遞推的方式。用到的都是分治的思想。
以上提到的都是基於棧的實現方式,接下來我們來看看基於表的實現方式,也就是表驅動的演算法。
LL(1)
工欲善其事,必先利其器。前面說到了語法分析器可以採用手工編碼和自動生成器兩種方式,接下來的幾個演算法都是自動生成器裡最常用的演算法,會用工具的同時能夠理解工具的執行機理也是一件不錯的事,比如接下來我們要談的 LL(1) 演算法,就是 ANTLR 選擇的演算法。
首先說說這個名字,LL(1),第一個L表示從左到右讀程式,第二個L表示每次優先選擇最左邊的非終結符推導,(1)表示前看一個符號。
LL(1)演算法有以下優點:
- 分析高效(線性時間)
- 錯誤定位和診斷資訊準確
我們說LL(1)是一個表驅動的演算法,那怎麼個表驅動法呢?我們先回顧一下自頂向下的演算法:
tokens[];// 所有token i = 0; stack = [S] // S是開始符號 while (!stack.empty()) if (stack.top() is a terminal t) if (t == tokens[i]) pop(); //成功 i++; else backtrack(); //回溯 else if (stack.top() is a non-terminal T) pop(); push(next possible choice); // 請注意 possible
前面我們說來,由於最後一行的push是隨機選擇,選擇完所有的情況,因此導致回溯,但是如果我每次都選擇正確的情況,那就不需要回溯啦。這是我夢想的程式碼:
tokens[];// 所有token i = 0; stack = [S] // S是開始符號 while (!stack.empty()) if (stack.top() is a terminal t) if (t == tokens[i]) pop(); //成功 i++; else error("..."); //回溯個JB else if (stack.top() is a non-terminal T) pop(); push(next 正確的 choice); // 查表
沒錯,所謂的表驅動就是給你提供一張表,通過查表你就能決定下一步往哪走。而表驅動演算法的主要工作就是把這張表給你算出來。
那麼怎麼構造一個分析表呢?
其實很簡單,我通過肉眼就能看出來,這個表不外乎就是所有的非終結符作為行,所有的終結符作為列,然後為每一條語法規則標上行號,根據語法規則填表就完事了。比如我要填N這一行,通過觀察,我發現N可以推出 s t g w,也就是前看符號可能的情況,所以這一行可以填上1 2 3 4,其他行類似。
不過這樣不是很規範,於是乎,科學家們引入了 FIRST集 這個概念,簡單版本的計算公式如下:
窮舉全部可能的結果,找所有可能的開頭字母 foreach (N -> a...) // a開頭的終結符 FIRST(N) += a; foreach (N -> M...) // M開頭的非終結符 FIRST(N) += FIRST(M)
一個簡單版本的演算法如下:
foreach (non-terminal N) FIRST(N) = {} while (some set is changing) foreach (規則 N -> T1 T2 ...) if (T1 == a...) FIRST(N) += a; if (T1 == M...) FIRST(N) += FIRST(M)
為什麼上面說的是簡單的版本呢?考慮以下文法:
Z -> a -> X Y Z Y -> b -> X -> c -> Y
上述文法中,Y和X都有可能推匯出空,對的,上面不是寫錯了,而是真的空。如果XY都推出空,那麼計算FIRST(Z)的時候,就會有 Z->Z 的規則。因此,一般情況下,還需要計算哪些非終結符是可能推出空的,就稱其為NULLABLE集,其演算法如下:
NULLABLE = {} while (nullable is still changing) foreach (規則 N -> T1 T2 ...) if (T1 == 空) NULLABLE += N if (T1 T2 ...都可以推出空) NULLABLE += N
在我們知道哪些非終結符是NULLABLE時,計算FIRST集的時候,就需要考慮NULLABLE符號之後的字母,也就是FOLLOW集。我們先來看看一般的FIRST集求法:
foreach (nonterminal N) FIRST(N) = {} while (some set is changing) foreach (規則 N -> T1 T2 ... Tn) foreach (Ti from T1 to Tn) if (Ti == a...) // a開頭的終結符 FIRST(N) += {a} break if (Ti == M...) // M開頭的非終結符 FIRST(N) += FIRST(M) if (M不是NULLABLE) break
先來看一下之前的文法:
0: Z -> a 1:-> X Y Z 2: Y -> b 3:-> 4: X -> c 5:-> Y
現在,我們知道了FIRST集的求法,對於 Z->a | XYZ 這條規則,我們可以算出 FIRST(Z) = {a, b, c} 也就是分析表的 Z 行 a, b, c 列都是有內容的。不過這樣的演算法只是讓我們知道 Z 行哪些列有內容,但是內容不夠準確,我要的是具體使用哪一條規則(Z有兩條規則)。因此,我們換一種方式,我們依次計算每一條規則的FIRST集,比如:
0: Z -> a 的FIRST集就是 a
1: Z -> XYZ 的FIRST集就是 a b c
這下子,我們就可以準確的在表中填入:
a | b | c | |
Z | 0, 1 | 1 | 1 |
現在考慮另外一個問題,對於第3條規則,Y-> ,怎麼辦?這時候就應該看Y後面會出現什麼,比如由第1條規則可以知道,Y後面是Z,因此Y後面可以跟a b c,因此對於推匯出空的規則來說,必須還得考慮它的FOLLOW集。
FOLLOW集求法:
foreach (nonterminal N) FOLLOW(N) = {} while (some set is changing) foreach (規則 N -> T1 T2 ... Tn) tmp = FOLLOW(N) foreach (Ti from Tn to T1) if (Ti == a...) // a開頭的終結符 tmp = {a} if (Ti == M...) // M開頭的非終結符 FOLLOW(M) += tmp // 關鍵步驟 if (M 不是 NULLABLE) tmp = FIRST(M) else tmp += FIRST(M)
對於FIRST集,演算法很簡單,一看就能看明白。但是FOLLOW集就比較難看懂,當初我上編譯原理課的時候也是很難搞懂,不妨我們來模擬下演算法執行。
給定一個規則 N -> T1 T2 ... Tn a
演算法會遍歷這條規則,然後 從後向前 依次計算右手邊。
- 剛開始,tmp = FOLLOW(N) = {}
- 遇到了 a ,終結符,tmp = {a}
- 遇到了 Tn ,非終結符,FOLLOW(Tn) += tmp;
- 如果 Tn 不是NULLABLE,tmp = FIRST(Tn),轉第6條
- 如果 Tn 是NULLABLE,tmp += FiRST(Tn),轉第7條
- ----------------到此為止,FOLLOW(N) = {} ,FOLLOW(Tn) = {a}
- 遇到了 Tn-1 ,非終結符,FOLLOW(Tn-1) += tmp,到此為止,FOLLOW(Tn-1) = FIRST(Tn)
- 遇到了 Tn-1 ,非終結符,FOLLOW(Tn-1) += tmp,到此為止,FOLLOW(Tn-1) = {a} + FIRST(Tn)
這個例子跑一遍,就能輕鬆理解FOLLOW集的求法了。嘿嘿,編譯原理雖說是一門理論性非非非非非常強的課,但是要想學好她,必須實踐,動手動筆不能光動眼動腦。
現在,任給一個文法,我們都可以寫出她的FIRST集、NULLABLE集、FOLLOW集了,而且我們知道了應該按照一條一條的規則來算這些集合,才方便準確地填表。這時候,我們不妨給每一條規則再額外定義一個集合,叫做 FIRST_S 集,定義這個集合是方便程式設計。這個集合會計算每一條規則可以推出的首字母,演算法可以這樣:
foreach (規則 N) FIRST_S(N) = {} foreach (規則 N -> T1 T2 ... Tn) foreach (Ti from T1 to Tn) if (Ti == a...) // FIRST_S(N) += {a} return if (Ti == M...) // FIRST_S(N) += FIRST(M) if (M 不是 NULLABLE) return FIRST_S(N) += FOLLOW(N) // 前面都沒返回,意味T1 T2 ... Tn整體可以推出空,於是乎要加上FOLLOW集
最後總結下,給出文法的運算結果:
NULLABLE = {X, Y};
X | Y | Z | |
FIRST | {b, c} | {b} | {a, b, c} |
FOLLOW | {a, b, c} | {a, b, c} | {} |
0 | 1 | 2 | 3 | 4 | 5 | |
FIRST_S | {a} | {a, b, c} | {b} | {a, b, c} | {a, b, c} | {c} |
LL(1)分析表:
a | b | c | |
Z | 1 | 1 | 0, 1 |
Y | 3 | 2, 3 | 3 |
X | 4, 5 | 4 | 4 |
總結一下,我們現在構造了分析表,幫助了我們做正確的選擇,也就是說,原來演算法中的
push(next 正確的 choice); // 查表
變成了
push(table[T, tokens[i]); // 查表
不過,細心的讀者可能會發現,不對啊,上面的表項中,並不是一對一的,比如負對角線上的狀態都是兩個的,到時候該怎麼選擇呢???不是還要回溯嗎???
沒錯,這個確實是個問題,或者說,這個文法不是LL(1)文法。等等,那我們說了這麼多,還是沒能解決問題?確實是的,嚴格來說,LL(1)文法不能構造出有二義性的文法的分析表,也就是二義性文法的分析表通過演算法算出來,她的某些表項是有超過1的。那怎麼辦呢?
可以證明,有左遞迴或者左因子的文法都不是LL(1)文法,證明方法想一想就知道了。因此一般來說,這種問題只能交給程式設計師來解決,前面在遞迴下降演算法的時候也提到過,可以通過消除左遞迴和左因子的方法來消除文法的二義性。那麼現在我們可以來總結下LL(1)文法的缺點了:
- 能分析的文法型別有限(只能分析無二義性的LL(1)文法)
- 往往需要文法的改寫
有些朋友會說了,那改寫就改寫,我都知道了怎麼消除二義性了,消就完事了。不過有時候這是件很複雜的事情,而且修改掉的文法不具有可讀性,舉個例子,在前面我們提到了一個加減法的文法:
E -> E + T -> T T -> T * F -> F F -> num
這個文法雖然有左遞迴,不是LL(1)文法,但是可讀性很好。如果我們把左遞迴消除了,她會變成這樣:
E -> T E` E` -> + T E` -> T -> F T` T` -> * F T` -> F -> n
變醜了,表達性很差。所以,這種自頂向下的演算法貌似到頭了,無路可走了。這時候新事物就來取代舊事物,接下來,我們一起來看看另外一種更強有力的方式,自底向上的分析演算法。
自底向上
自底向上演算法也被稱作移進-規約演算法(shitf-reduce),主要是因為演算法中涉及了兩個常用的核心操作,shift和reduce。這種演算法和上面提到的自頂向下分析演算法剛好完全相反,不過和自頂向下演算法一樣,這種演算法也有執行高效、廣泛被自動生成器使用的優點。我們所熟知的YACC Bison都是使用的自底向上的分析演算法,這種自底向上的分析策略是LR系列演算法的核心思想,這種演算法相較於LL演算法,具有支援語法更多、不需要修改原來語法的左遞迴等優點。
接下來看一下演算法的思想,前面提到,演算法有兩個核心操作,shift和reduce,所謂reduce,就是根據語法規則把右邊的式子歸成左邊的非終結符,shift則不規約,繼續展開右邊的式子。具體來看一個例子:
E -> E + T -> T T -> T * F -> F F -> num
LR演算法處理 3+4*5 的順序是:
其實從下往上看,整個過程就是最右推導的逆過程。忘了解釋,這裡的LR第二個R就是最右推導的意思。上面的點號左邊表示已處理的字元,右邊表示待處理的字元。
LR(0)
在文章開頭我們提到,LR演算法的實現方式就是有限自動機DFA,而我們要構造的分析表也就是狀態轉移表。我先給出一個具體的例子來展示演算法執行過程,然後在給出具體演算法。
假設我們的文法是:
0: S -> A$ 1: A -> xxB 2: B -> y
給定輸入 xxy$ ($表示EOF),可以畫出DFA:

對應的LR(0)分析表,也就是DFA狀態轉移表:
ACTION | GOTO | ||||
狀態\符號 | x | y | $ | A | B |
1 | s2 | g6 | |||
2 | s3 | ||||
3 | s4 | g5 | |||
4 | r2 | r2 | r2 | ||
5 | r1 | r1 | r1 | ||
6 | accept |
給出演算法:
stack = [] push($) // EOF push(1) // 初始化狀態 while (true) token t = nextToken() state s = stack[top] if (ACTION[s, t] == "s"+i) push(t) push(i) else if (ACTION[s, t] == "r"+j) pop(第j條規則的右邊全部符號) state s = stack[top] push(X) // 把第j條規則的左邊非終結符入棧 push(GOTO[s, X]) // 對應的狀態入棧 else error("...")
不過LR(0)演算法也會有自己的問題,比如一段程式的可以生成的狀態會有很多,多到記憶體裝不下,想想Linux這種級別的程式碼量,而很多的狀態還會導致錯誤定位不準確。除此之外,還可能會導致一個在某一個狀態裡面,既可以選擇shift,也可以選擇reduce,這就產生了衝突。因此產生了一種SLR的演算法,不過只是解決的部分問題,感興趣的小夥伴可以自行查閱資料。我們主要還是講一些主流的演算法。接下去的LR(1)演算法才算是LR系列演算法中最被廣泛使用的演算法。
LR(1)
首先考慮一個C語言的賦值語句的一個DFA:
在2號狀態的時候,如果我們讀入 = ,那麼我們該 shift 還是 reduce 呢?我們不妨看一看R後面可不可能出現=,如果R後面出現=不滿足語法規則,那我們就能指定shift,而不是reduce。所以我們可以計算FOLLOW(R),但不幸的是,FOLLOW(R)裡面包含=(你可以自己觀察一下)。我們剛才描述的這一套做法就是SLR演算法的做法,但是我們也可以看到,計算FOLLOW集來消除shift-reduce衝突是不夠好的。而LR(1)解決了這個問題,我們可以看一下:
看狀態2,這裡有一個shift-reduce衝突,但是由於引入了後面的符號,這個在讀入=時,狀態2並不會reduce,而是進行shift。狀態2的reduce只發生在,這時候讀入的是$,也就是末尾指定的符號。
對於 R -> L. ,$
$相當於一個前看符號,只有和這個前看符號相等的輸入,才能進行reduce。
一般來說, X -> A.B ,a
表示A現在在棧頂,而剩餘的輸入能夠匹配 Ba。當狀態變成 X -> AB. ,a
時,a作為一個前看符號,能夠知道只有遇到a進行reduce,這樣才滿足語法規則。在分析表的ACTION[s, a]這一欄,會填入"reduce X-> AB"。
那為什麼加上這一項就能解決問題了呢?對於 X -> A.B ,a
,你不妨這樣來理解,當前棧頂是A,我期待看到的是Ba。為什麼期待看到的是a呢,因為這是前看符號,我從語法規則中提前看1個字母,發現a可能出現,於是乎我期待著a出現。
前看符號的計算是這樣的:
對 X->A.BC,a 推出 B->.D,b 其中 b 是 FIRST_S(Ca)
語言總是有差錯,不妨來看看這個前看符號怎麼推出來的:
給出文法:
S` -> S$ S -> L = R S -> R L -> *R L -> id R -> L
S`->S$
:point_down:
S`->.S,$
:point_down:
S`->.S,$ S->.L=R,$ S->.R,$
:point_down:
S`->.S,$ S->.L=R,$ S->.R,$ L->.*R,= L->.id.= R->.L,$
:point_down:
S`->.S,$ S->.L=R,$ S->.R,$ L->.*R,= L->.id.= R->.L,$ L->.*R,$ L->.id,$
最後一點,二義性文法無法使用LR分析演算法分析,但是有幾類特殊的二義性文法很容易理解,因此語法自動生成器也可以識別,比如優先順序、結合性等。
例如:
E->E+E. E->E.+E E->E.*E
這個時候,指定YACC對於加法進行左結合,優先順序低於乘法兩個設定,YACC就會在遇到+時,首先按第一條規則reduce,遇到*時,按第三條規則shift。
在我的 Tiger Compiler 的語法分析模組中就有這樣的設定,感興趣的小夥伴可以star一下,我還在持續開發中...
%left PLUS MINUS %left TIMES DIVIDE
總結
語法分析裡很多的演算法都是在解決前一個演算法的問題之上提出來的,十分有趣,不過理論學起來還是挺枯燥無味的,編譯原理是一門實踐+理論的課,必須自己動手算一算才能更好理解演算法的精髓。
Reference
編譯原理, 華保健, 中國科學技術大學.
Context-free grammar, https://en.wikipedia.org/wiki/Context-free_grammar ,from Wikipedia, the free encyclopedia.