從頂端剖析:讓樹往下生長
在第一篇裡,掃描器已經做完它的工作:它把原始字元嚼成一道乾淨的詞元流(一個識別字、一個 `+`、一個數字、一個左括號)。剖析器現在的任務,是判斷這道詞元流是否吻合文法;若吻合,就建出展示「如何吻合」的剖析樹。由上而下剖析以最樂觀的方式做這件事:它從標記著起始符號的根節點開始,讓樹「往下」朝詞元生長,一次展開一個變數,總是在看完整個輸入之前就先押注一條規則。
把這直接接回上一階。從頂端讓樹生長、總是攻擊最左邊未完成的變數、由左到右地比對詞元,這恰恰就是在建造輸入的最左推導。所以由上而下剖析並不是什麼新的數學物件——它就是你已經認識的最左推導,只是當成演算法來跑、而且輸入事先就被釘死了。整場遊戲就是:在每個變數處,猜哪一條規則的右側最終會拼出前方躺著的那些詞元。
遞迴下降:每條規則一個函式
寫由上而下剖析器最直接的方式是遞迴下降,而這個訣竅愉快地直白:把文法的每個非終端符號變成一個函式。要剖析變數 A,你就呼叫函式 `parseA`。在它內部,你由左到右沿著 A 規則的右側走;遇到終端符號就檢查當前詞元是否吻合、吻合則消耗它(推進輸入),遇到非終端符號 B 就單純地呼叫 `parseB`。文法的遞迴變成了程式的遞迴——一條提及自身的規則,變成一個呼叫自身的函式。
Grammar (tokens: id + ( ) ):
E -> T E'
E' -> + T E' | epsilon
T -> id | ( E )
parseE(): parseEprime(): parseT():
parseT() if peek == '+': if peek == 'id':
parseEprime() match('+') match('id')
parseT() elif peek == '(':
parseEprime() match('(')
# else epsilon: return parseE()
match(')')
else: ERROR注意 `parseEprime` 必須做的事:面對規則 `E' -> + T E' | epsilon`,它有兩個選擇,並透過「窺看」下一個詞元來抉擇。若看到 `+`,就取第一個替代式;否則取空(epsilon)替代式,什麼也不消耗就返回。這個「窺看再決定」正是高效由上而下剖析的核心。當一個剖析器總能只看下一個詞元就挑對規則——絕不亂猜、絕不回頭——我們就稱它為預測式剖析。回報極大:每個詞元只被消耗一次,所以對長度 n 的輸入,剖析器以 O(n) 的線性時間執行。
LL(1):用一個詞元的前瞻來預測
預測式剖析器只靠一次窺看就能處理的文法類別,有個精確的名字:LL(1)。照字面讀這個標籤。第一個 L 表示我們由左到右(Left to right)掃描輸入;第二個 L 表示我們產生最左(Leftmost)推導;而 (1) 表示我們向前看恰好 1 個詞元來決定每一步。(LL(k) 允許 k 個詞元的前瞻,但 LL(1) 是主力。)一個 LL(1) 文法,是指在每個變數處,那單一個前瞻詞元總是足以毫無歧義、毫無回溯地挑出正確的規則。
與其手寫函式,我們可以把所有決策收進一張「剖析表」:列是非終端符號,欄是前瞻詞元,每個格子寫明該用哪一條規則。接著一個小小的、以堆疊驅動的迴圈就推動整場剖析——把起始符號壓入堆疊,反覆查看堆疊頂端與下一個詞元,據以決定要比對一個終端符號、還是透過表展開一個非終端符號。這在精神上就是一台確定型下推自動機:堆疊保存著句型中尚待比對的部分,正是下推自動機那一階「堆疊即記憶」的想法被具體化了。
對這裡的天花板要誠實。LL(1) 確實有用,給出乾淨、易讀、快速的剖析器——但它嚴格弱於你接下來會遇到的 LR 家族。某些完全合理的上下文無關語言根本沒有任何 LL(1) 文法,因為一個詞元的前瞻就是不足以下定決心。當一個 LL(1) 剖析器必須在兩條規則之間抉擇、而那單一個詞元無法把它們區分開時,剖析表裡就有一個塞了兩筆資料的格子——一個衝突——於是該文法被判定不是 LL(1)。幾乎所有這類衝突都由兩種文法習性造成,而本篇其餘部分就是要學會發現並修正它們。
FIRST 與 FOLLOW:讓預測安全的兩張地圖
剖析器究竟如何從一個窺看到的詞元,知道該觸發哪條規則?它從文法預先算出兩張小地圖——FIRST 與 FOLLOW 集合。對於符號串 α,FIRST(α) 是「可作為由 α 推導出的某個字串之第一個詞元」的終端符號集合。(若 α 能推導出空字串,我們就記下 α 是「可空的(nullable)」,並把 epsilon 加進 FIRST(α)。)直觀地說,FIRST 回答的是:「若我認定這條規則,下一個可見詞元可能會是什麼?」
光有 FIRST 還不夠,原因出在 epsilon(空)規則。當剖析器停在一個可空的變數 A、而下一個詞元「不」在 FIRST(A) 裡時,套用 A -> epsilon 並悄悄前進可能仍是對的——但前提是那個詞元是合法地能出現在 A「之後」的東西。這正是 FOLLOW(A) 所記錄的:在某個句型中能緊接在 A 右側出現的終端符號集合。FOLLOW 就是剖析器用來判斷「抹去一個變數、讓周圍語境接續下去」是否安全的依據。
- 對每條規則 A -> α,計算 FIRST(α):能作為「α 所生成之物」開頭的詞元(穿過可空的片段往後看,把下一個片段的 FIRST 加進來,直到碰上一個不可空的為止)。
- 對 FIRST(α) 中的每個終端符號 t,把規則 A -> α 放進剖析表的格子 [A, t]。前瞻為 t 時,那條規則就是預測。
- 若 α 可空(能推導出 epsilon),則「另外」對 FOLLOW(A) 中的每個 t(合法地能出現在 A 之後、示意「在此走空分支」的詞元)把 A -> α 放進格子 [A, t]。
- 若這個程序曾試圖把「兩條」不同的規則寫進同一個格子,那就是衝突:單一個前瞻詞元有歧義,於是該文法不是 LL(1)。
LL(1) 無法消化的兩件事(以及如何修正)
第一帖毒藥是左遞迴:像 E -> E + T 這樣的規則,變數把自己當作「自己的」最左符號來呼叫。遞迴下降的 `parseE` 會在沒消耗任何詞元的情況下立刻呼叫 `parseE`,永遠迴圈下去——而剖析表也會出現衝突,因為那個遞迴替代式的 FIRST 與一切都重疊。解藥是 左遞迴消除:把規則改寫成往右遞迴。經典的變換把 A -> A α | β 變成 A -> β A',其中 A' -> α A' | epsilon。同一個語言,但現在遞迴朝右生長,往下走是安全的。
第二帖毒藥是兩個共享同一前綴的替代式,例如 S -> if E then S | if E then S else S。一個窺看到的詞元 `if` 無法把兩者區分開——它們直到很後面才看起來不同——所以 `if` 那個表格子裡同時塞了兩條規則:衝突。解藥是 左因式分解:把共享前綴提到前面、把抉擇延後。改寫成 S -> if E then S S',其中 S' -> else S | epsilon。如今剖析完共同部分後,單一個前瞻(是否為 `else`)就乾淨地選定尾巴,文法也變得對 LL(1) 友善。
兩點誠實的提醒。第一,這些變換保留了「語言」(被接受的字串集合),卻重塑了文法與剖析樹,所以一個要建抽象語法樹的工具,通常得額外費工才能事後復原自然的結構。第二,修掉左遞迴與共享前綴會讓文法「對預測友善」,卻不保證 LL(1):一個真正歧義的文法(上面的懸吊 else 就是著名例子)仍可能留下任何因式分解都消不掉的衝突,因為那歧義是真實的、不是表面的。此時你必須更動語言原意、加一條消歧規則,或改用更強的由下而上剖析器。
你現在握有什麼,以及第三篇走向何處
退一步看清全貌。由上而下剖析讓剖析樹從起始符號往下生長,這不過是針對固定輸入建造一個最左推導。遞迴下降把它編碼成每個非終端符號一個函式;預測式剖析靠窺看一個詞元讓它免於回溯;而 LL(1) 正是那單一個窺看總是足夠的精確文法類別,由 FIRST 與 FOLLOW 集合填滿剖析表並標出衝突。它是線性時間、可讀,也是許多真實手寫前端背後的方法。
但我們一再撞上天花板:左遞迴礙手礙腳,共享前綴需要因式分解,而有些上下文無關語言無論怎麼努力都沒有 LL(1) 文法。第三篇藉由翻轉整個方向來抬高那片天花板。由下而上剖析器不在看見規則內容之前就預測它,而是把詞元「移入(shift)」一個堆疊,等到一個完整的右側完全現身了才「歸約(reduce)」它——事後才辨認出那個把手(handle)。正是這份耐心,使得 LR 家族嚴格地比 LL 更強,也使它成為你在本階梯尾聲會遇到的剖析器產生器(yacc、bison)的底層基礎。