1. 程式人生 > 其它 >編譯原理:語法分析概述

編譯原理:語法分析概述

本文對編譯原理中語法分析的基本內容作簡要介紹。鑑於各種考慮,本文在語言風格上以易讀為導向,並以犧牲一定的嚴謹性與完整性為代價。閱讀本文建議配合經典教材食用,也希望本文能為閱讀教材中的讀者帶來些許靈感與洞見,起到拋磚引玉的作用。時間倉促,不免疏漏,懇請讀者批評指正。

本文對編譯原理中語法分析的基本內容作簡要介紹。鑑於各種考慮,本文在語言風格上以易讀為導向,並以犧牲一定的嚴謹性與完整性為代價。閱讀本文建議配合經典教材食用,也希望本文能為閱讀教材中的讀者帶來些許靈感與洞見,起到拋磚引玉的作用。時間倉促,不免疏漏,懇請讀者批評指正。

背景

語法分析器的輸入是一個單詞流,其中每個單詞都被標註了語法範疇。

語法分析器的職責是識別語法,即尋找給定文法下輸入串的推導。

語法分析器分為自頂向下和自底向上兩類:

  • 自頂向下文法分析器試圖通過在各個點上預測下一個單詞,依照語法的產生式來匹配輸入流。

  • 自底向上語法分析器從實際單詞序列開始,不斷積累上下文資訊並進行規約,最終得到文法開始符號,以得出一個反向的推導。

正則文法雖然方便分析但表達能力有限,因此我們引入上下文無關文法 CFG。

CFG 的產生式形如 \(A\to \alpha\),其中 \(A\) 總是可以被 \(\alpha\) 替換而不用考慮上下文,因此稱為上下文無關文法。

CFG 是可能具有二義性的文法,因此我們需要在語法分析前,進行二義性消除操作。

  • 所謂二義性是指對於一個句子,可能存在多種不同的最左合法推導或最右合法推導。

  • 二義性未必是可消除的。

  • 我們無法在多項式時間內,判別一個文法是否具有二義性或者消除它的二義性。

本文中討論的所有語法分析器都針對 CFG 或它的子集。

自頂向下語法分析

介紹

自頂向下文法分析器試圖通過在各個點上預測下一個單詞,依照語法的產生式來匹配輸入流。

具體而言,在分析過程中,分析器維護了一棵不完整的語法分析樹。

每一步,分析器選擇一個(當前的)葉子 \(A\),根據某個產生式,為它新增孩子。

下圖展示了一個典型的過程:

這個過程可以用遞迴實現,也可以用非遞迴實現。

回溯:一種暴力

如果我們有多個左側同為 \(A\) 的產生式,我們難以確定,該用哪個產生式去推導。

我們不妨先忽略這個問題。就像寫一個搜尋演算法那樣,如果錯了,就回溯。

考慮一個用棧實現的方案,在棧中按序存放所有的葉子,不斷對棧頂進行推導擴充套件,如果匹配了終結符就彈棧。

嗯,看起來一切都好。只是你或許會有些不詳的預感,尤其是當看到一個形如 \(A\to Aa\) 的產生式時。

約束:無左遞迴

一個 CFG 產生式的右側第一個符號與左側相同,稱為(直接)左遞迴。

一個 CFG 非終結符可以通過多次推導得到一個串,其第一個符號與原非終結符相同,稱為(間接)左遞迴。

左遞迴會導致無限迴圈。

幸運的是,我們可以通過程式化的方法,將左遞迴轉化為右遞迴。

對於直接左遞迴,參考如下方法:

一個典型的例子是對加減運算表示式的文法轉換:

對於間接左遞迴,我們需要為符號欽定一種出現的順序,轉化掉所有逆序產生式,進而消除左遞迴。

其演算法思路可以概括為:欽定一種拓撲序,邊展所有逆序式,邊消直接左遞迴。

約束:無回溯

雖然消滅了左遞迴,但我們距離一個高效的語法分析器還有很遠。

沒錯,就是它,回溯!

然而回溯並不是一個好解決的問題。為了繼續做下去,只能委曲求全,對語法本身加以限制。

我們稱一種 CFG 是無回溯文法,當且僅當,語法分析器可以在至多前瞻一個單詞的情況下,總是能夠選擇正確的產生式。

也就是說,每一步應該選擇哪個產生式必須是顯然的。

直覺上想,既然我們可以順理成章地往後看一個符號(終結符),那就好好利用起來。如果能算出每個產生式最終推匯出的所有語句的開頭終結符的集合,如果對於同一個非終結符,它的各產生式的這種集合互不相交,就會做了。

為了確切地描述無回溯條件,我們需要一些概念。

定義終結首符集 \(First(\alpha)\) 表示由語法符號 \(\alpha\) 推匯出的所有句子的首符號(終結符)。

然而僅有 \(First\) 是不夠的。如果 \(\alpha\) 推匯出了一個空串,我們似乎需要一些更多的資訊。

定義後隨終結符集 \(Follow(A)\) 表示在本語言的所有句型中,可能緊跟在非終結符 \(A\) 後方的終結符集合。

現在我們可以為 \(\epsilon \in First(\alpha)\) 的情況提供一些補救,只差雙劍合璧。

增強終結首符集 \(First^+(A\to\alpha)\)\(\epsilon \in First(\alpha)\) 時定義為 \(First(\alpha) \bigcup Follow(A)\),否則定義為 \(First(\alpha)\)

由此,無回溯條件可以表達為:對於任意 \(A\),所有 \(A\to\alpha\)\(First^+\) 互不相交。這樣的文法稱為無回溯文法。

剛才擱置了一件事:怎樣計算終結首符集和後隨終結符集?

我們的思路是迭代合併。迴圈去考慮每個產生式,不斷計算它右端的 First 合併到左端的集合中,迭代直到演算法收斂。

實現上,我們的 First 是針對單個符號定義的,所以計算時需要多做一點列舉。

Follow 的計算也是類似但有更多遞推的思路。欸,怎麼有點最大子段和的感覺。

那麼,不滿足無回溯條件的文法就真的沒救了嗎?能否像消除左遞迴那樣,消除回溯呢?

提取左因子或許會有所幫助。它指的是對一組產生式,提取並隔離公共字首的過程。一個典型的例子:

遺憾的是,這種方法的能力是有限的。事實上,無論我們做出包括前瞻多個符號在內的多少努力,都無法將領土擴張到整個 CFG。自頂向下語法分析器具有其天然的能力限制,而這種限制,也將會成為我們研究自底向上分析的動力。

在更進一步之前,先去思考一下無回溯文法分析的實現。

實現:遞迴下降

遞迴下降分析器是一種非常直觀的實現。

我們為每個非終結符編寫一個過程,過程之間相互呼叫以完成推導,在決策時考查前瞻符號屬於哪一個增強終結首符集以決定呼叫哪一個過程,遞迴邊界為匹配終結符並讓輸入指標前進。

一個識別表示式的例子如下:

實現:表驅動 LL(1)

編寫上述程式令人倍感厭煩。事實上,我們可以將其中影響決策的資訊提取出來,製成表格,以此驅動解析器運作。

我們一直討論的這種分析器從向右啃食輸入串,吐出一個最推導,每次前瞻個符號,因此成為 LL(1) 分析器。

LL(1) 分析表中需要包括的資訊,無非是棧頂是某個非終結符時,看到(前瞻)某個終結符,應當採取哪個產生式。

一個典型的 LL(1) 分析表如下所示:

編寫分析程式的思路與最開始給出的基於棧的搜尋方法類似。在棧中按序存放所有的葉子,不斷對棧頂進行推導擴充套件,如果匹配了終結符就彈棧。

對了,差點忘了構造分析表的程式。既然已經有了增強終結首符集集,其實這部分工作也不復雜。

小結與討論

本節我們討論了自頂向下語法分析器。

  • 自頂向下分析的過程是語法分析樹暫時的葉子不斷展開成子樹的過程,也是最左推導的構建過程。
  • 首先我們需要通過一種程式化的方法消除左遞迴,以避免程式陷入死迴圈。
  • 我們固然可以用回溯去處理產生式的選取問題,但為了高效起見,我們儘可能只考慮不需要回溯的文法。
  • 描述無回溯文法需要終結首符集和後隨終結符集以及它們的合體作為工具,
  • 它們的意義不僅式概念上的,其計算也為實現提供幫助。
  • 有回溯文法未必總是能轉換為無回溯,而提取左因子有時能提供一些幫助。
  • 最後我們展示了兩種典型的實現,遞迴下降分析法比較直觀但編寫工作繁複,表驅動 LL(1) 分析法是更加精巧而合適的選擇。

雖然無回溯文法的要求並不會對程式語言的設計產生嚴重的限制,但我們仍然希望能做所突破。

同時,左遞迴是一種自然的存在,我們期盼它的歸來。

在自底向上語法分析中,這些問題會在一定程度上得到解決。

自底向上語法分析

介紹

自底向上語法分析開始於森林,通過不斷新增非終結符節點以合併樹,縮減構建中的語法分析樹的上邊緣,並最終結束於一棵樹。

在某個有效推導的意義下,對於一個句型,其下一步應當進行的規約為將 \(k\) 位置的 \(\beta\) 替換為 \(A\),則稱 \((A\to \beta, k)\) 是它的控制代碼。實現自底向上語法分析只需要一個控制代碼查詢器。

LR 分析即從向右輸入,輸出最推導。根據超前檢視字元的情況不同來區別各種型別的分析器。

移進-規約

LR 分析的核心思路是移進-規約。

類似那種經典的簡單表示式分析方法,演算法將維護一個棧。

移進操作代表讓新符號進棧,規約操作將彈出棧頂的若干個元素並將其替換為一個元素後重新壓進去。

但這還不夠,演算法同時維護了執行在一個特別的有限狀態自動機上的狀態。

移進操作會導致一次轉移,規約操作將導致一次返回到棧中某個狀態的撤退行為,並接著引發一次轉移。

此時,聯想 AC 自動機的運作過程或許會有所幫助。

設計上述機制的關鍵動機是去回答一個問題:對當前狀態(棧和尚未輸入的序列,下稱局面),怎樣查詢控制代碼?

換言之,面對一個新的待移進符號,我們需要做移進還是規約,採用哪個產生式規約?

一個重要的觀察是:當你等待移進一個非終結符的時候,你就是在等待某個它展開後的串的開始元素。

這種關係被稱為狀態的等價性,它是我們進行閉包運算的重要緣由。

上面囉嗦了很多飄渺的東西。接下來按照一種邏輯演進的順序介紹四種基礎的 LR 分析器。

LR(0)

LR(0) 只需要檢視棧就可以確定該如何轉移,不需要前瞻符號。

因此它很容易遇上它無法處理的移進-規約衝突或規約-規約衝突。

是一條文法規則加上一種棧頂的可能位置。

自動機的每個節點是一個項集

求項集的過程稱為求閉包。它將通過迭代,使得集合 \(I\) 內,每個項 \(A\to \alpha.X\beta\) 的棧頂標記 \(.\) 後緊鄰的非終結符 \(X\) 發出的所有產生式 \(X\to \gamma\) 的棧頂在開頭的項 \(X\to .\gamma\) 也都在集合內。

在此基礎上,考察一個節點的出邊,就是要考察其項集內所有項 \(A\to \alpha.X\beta\) 中棧頂標記 \(.\) 後緊鄰的非終結符 \(X\) 的。

而經某個 \(X\) 的出邊所到達的節點,則是將所有形如 \(? \to \alpha .X \beta\) 的項的棧頂標記位置後移一位後(i.e. \(A\to \alpha .X \beta \Rightarrow A \to \alpha X. \beta\))得到的項的閉包。

好繞啊,看程式碼吧!

一個文法的自動機以及分析表的示例:

基於這張表,LR 分析引擎的演算法維護了一個棧,棧中即存放符號,又存放對應的狀態。

更加程式化一點的表述:

這個程式我們會一直用下去。之後的改進幾乎完全發生在建表之前的階段。

LR(0) 是很容易發生衝突的。從表中可以觀察到:具有規約動作狀態對於任意的輸入符號總是進行規約。

直覺上,這樣是不好的。

LR(0) 之所以戰火紛紛,就是因為規約動作的放置過於任性,過於膨脹。

實際上,某個狀態並不是遇到所有符號都需要規約。即使規約,也未必對所有符號都採用同樣的產生式規約。

但在沒有前瞻符號的情況下,好像也沒什麼辦法。

面對這樣的情況,我們可以考慮引入前瞻符號,對狀態進行細分。

在真的去細分狀態之前,不妨考慮,能否在 LR(0) 的分析表的基礎上,加一點 trick 拯救一下?

SLR(1)

如果你還記得有個東西叫 Follow 集:定義後隨終結符集 \(Follow(A)\) 表示在本語言的所有句型中,可能緊跟在非終結符 \(A\) 後方的終結符集合。

前面說到,LR(0) 中膨脹的規約動作霸佔了所有的出邊,令移入和其它的規約動作無處容身。

我們希望能有一種森破的方式,更加精確地定義每個規約動作的啟用條件,從而讓分析表中容納更多的動作。

目光投向了角落裡的前瞻符號。

對於一個狀態,它可以有若干個移進專案 \(A_1 \to \alpha_1 .X_1 \beta_1, A_2 \to \alpha_2 .X_2 \beta_2, ...\),有若干個規約專案 \(B_1 \to \gamma_1., B_2 \to \gamma_2.,...\)

它們對應了不同的前瞻符號(對於 \(X_i\in V_N\) 的直接忽略,它們顯然不會衝突),分別是 \(X_i, Follow(B_i)\)

只要這些前瞻符號(集)互不相交,我們就可以通過前瞻符號,洞察需要進行哪個操作。

這裡有個可能會迷惑的地方:前瞻符號和轉移用的符號有什麼區別?唔,一時間好像有點難以回答。至少,前瞻符號一定是一個終結符,來自輸入串。轉移符號則不然。

這就是我們的 SLR,Simple 的 LR(1) 分析。

我們幾乎只需要修改 LR(0) 中的建表演算法就可以得到 SLR(1)。

直覺上它還是有點弱,事實上也確實如此。

有些麻煩,終究還是逃不開。

LR(1)

LR(1) 將項的形式修改為 \([A\to \alpha . \beta, a]\),其中第二項是前瞻符號。它的涵義是,僅僅在前瞻符號為 \(a\) 時才利用該項進行規約。

由前面 SLR 的經驗可知,這樣的 \(a\) 總是 \(Follow(A)\) 的子集,且通常是真子集。

前瞻符號只對那些規約動作項是有意義的,而對移入動作沒有意義。

【今晚寫不完了,明天會補】

LALR(1)

LALR 在 LR 的基礎上,對同心項集進行了合併。

所謂同心,就是不看前瞻符號的情況下,兩個項集長得一模一樣。

這樣合併可能會導致規約-規約衝突。如果發生了這樣的事情,LALR 無能為力,我們只能去把文法怒斥一番。

LALR 的好處是,它讓 LR 往 LR(0) 的方向倒退了一點,卻能對狀態的數量進行大幅度的壓縮。

小結

【今晚寫不完了,明天會補】