前言
在編譯原理中,語法分析可以說是編譯器前端的核心。語法分析的輸出,抽象語法樹,更是一座建立在編譯器前端和后端之間非非非非非常重要的橋梁。
我們知道,編譯器可以分為前后端,而前后端又可以分為多個模塊,每個模塊環環相扣,體現出一種過程式的編程思想。每一個模塊的輸入僅僅是上一個模塊的輸出,而語法分析的產出物,抽象語法樹,是連接前后端的唯一橋梁,所有編譯器后端的模塊都必須依靠抽象語法樹,抽象語法樹必須提供足夠的信息以供后端許多模塊使用,所以設計好一個抽象語法樹是十分重要的。一般來說,一棵抽象語法樹需要提供每條語法在源文件中的行號列號,以及文件號等諸多信息,當然了,抽象語法樹的設計很具有特殊性,沒有一套國際上通用的模式來套,而是需要設計者根據需求定制。
語法分析器以詞法分析器的產出(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$
👇
S`->.S ,$
👇
S`->.S ,$
S->.L=R ,$
S->.R ,$
👇
S`->.S ,$
S->.L=R ,$
S->.R ,$
L->.*R ,=
L->.id .=
R->.L ,$
👇
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.