你早已認得的文法,換上工作服
先用一個低調卻了不起的事實開場:你整個階梯一路塗寫的上下文無關文法——變數、終端符號、像 E → E + T 這樣的規則——字面上就是 Python、C、JSON 或 SQL 的參考手冊用來定義「什麼算合法程式」的同一套裝置。手冊只是把它寫成一層叫作 BNF(Backus-Naur 範式)的標準外皮,這套記號約莫在 1960 年發明,用來釘死 ALGOL 的語法。底層沒有任何新東西在發生;那套機械你早已擁有。本篇就是從黑板通往真實語法實際棲身之頁面的橋樑。
這個翻譯幾乎只是化妝。一個變數(非終端符號)被包進角括號裡,像 <expr> 或 <stmt>;重寫箭頭 → 變成 ::=(讀作「定義為」);選擇用的豎線 | 仍是你早已使用的那根豎線;終端符號——那些會存活進程式裡的字面字元——則直接寫出或加引號。所以你那條 E → E + T | T,用 BNF 表達就讀作 <expr> ::= <expr> "+" <term> | <term>。同樣的變數、同樣的終端符號、同樣的選項、同樣的遞迴。會讀文法,就會讀 BNF。
EBNF:馴服重複的語法糖
純 BNF 很快就變得笨拙。假設一個函式呼叫有零個或多個以逗號分隔的引數。在赤裸的 BNF 裡,你必須用一個遞迴的輔助變數來拼出重複:<args> ::= <arg> | <arg> "," <args>,再加一個「完全沒有引數」的空情形。它能運作,但語言裡每一種清單都繁衍出自己一條小小的遞迴規則,整頁填滿了記帳。EBNF(擴充 BNF)加進三項簡寫,直接吸收這些常見的模式。
這三項是:大括號 { X } 表示「X 的零個或多個複本」(一種 Kleene 星號式的重複);方括號 [ X ] 表示「X 是可選的——零個或一個」;圓括號 ( X | Y ) 則把選項就地分組。有了這些,引數清單塌縮成一行可讀的式子:<args> ::= [ <arg> { "," <arg> } ]——讀作「可選地:一個 arg,接著任意數量的『逗號-arg』配對」。這和遞迴的 BNF 是同一個語言;EBNF 是純粹的語法糖,會展開回普通的上下文無關規則。
兩層架構:詞法分析器餵養文法
真實的語言規格幾乎從不讓文法直接跑在原始字元上。它們把工作切成兩層,而看清為什麼這麼做是值得的。首先,一台掃描器(或稱詞法分析器、lexer)掃過字元流,把它切成一個個詞法單元(token)——語言的「單詞」:識別字 `count`、數字 `42`、關鍵字 `while`、運算子 `==`。這第一層純粹是正規的:詞法分析器本質上是一台由正規表示式建成的 DFA,正是下方那幾階的工具。它對巢狀一無所知;它只是把成串的字元分類。
接著上下文無關文法跑在那串詞法單元上,而非字母上。它的終端符號是詞法單元的種類(IDENT、NUMBER、'+'、'while'),而它的工作是真正屬於上下文無關的那部分:表達式、區塊與敘述的巢狀——也就是一台 DFA 可被證明無法處理的東西(回想 a^n b^n)。這個分工不是偷懶——它正對應你已瞥見的剖析:一個遞迴下降剖析器一邊走一條最左推導,一邊一次索取一個詞法單元。正規的活兒交給正規的工具,上下文無關的活兒交給文法。
文法做的也不僅是回答「合法與否」。乘著它所建的剖析樹,前端產出一棵抽象語法樹(AST):一棵瘦身過的樹,丟掉那些雜訊——括號、分隔符、只為逼出優先序而引入的層次——只保留真正有意義的赤裸結構。具體的剖析樹精確記錄文法是如何配對的;AST 則記錄程式是什麼。編譯器後續的階段走的是 AST,而非原始文字。
一個微型的真實文法,從頭到尾
我們把你學過的一切組裝成一門語言的一個可信片段:算術表達式、一個 `if` 敘述、以及一個區塊。看著整個階梯的技巧逐一具名登場:分層的變數(<expr>、<term>、<factor>)用來編碼「* 比 + 結合得更緊」;EBNF 的大括號用於敘述清單;遞迴 <factor> ::= "(" <expr> ")" 給出了 DFA 永遠辦不到的無界巢狀。這不是某種教學玩具版的做法——它就是手冊裡語法章節真實的模樣。
Tokens (from the lexer, a DFA): IDENT NUMBER if ( ) { } == + *
Grammar in EBNF (runs over tokens, not characters):
<block> ::= "{" { <stmt> } "}"
<stmt> ::= <if> | <expr> ";"
<if> ::= "if" "(" <expr> ")" <block>
<expr> ::= <term> { "+" <term> } // + binds loosest
<term> ::= <factor> { "*" <factor> } // * binds tighter
<factor> ::= IDENT | NUMBER | "(" <expr> ")"
Parse-tree skeleton for the token stream a + b * c :
<expr>
/ | \
<term> + <term>
| / | \
<factor> <fac> * <fac>
| | |
a b c -> groups as a + (b * c), one tree only追一下這套分層替你買到了什麼。因為每個 + 都住在 <expr> 層、每個 * 都住在更深的 <term> 層,建造 a + b * c 的唯一方式,就是先組成 <term> b * c、再把它加上去——分組 a + (b * c) 被逼出,而那條容許兩棵樹的危險扁平規則 <expr> ::= <expr> "+" <expr> | <expr> "*" <expr> 已不復存在。本來要你擱在腦袋裡的優先序,如今被烤進了規則的形狀,正是歧義那篇的解藥,只不過用 BNF 發布出來罷了。
乾淨的理論誠實起來的地方
現在來談誠實的部分,因為真實的文法不像黑板上那麼整潔。著名案例:懸盪的 else(dangling-else)。以 <stmt> ::= "if" <expr> <stmt> | "if" <expr> <stmt> "else" <stmt> | ... 而論,字串 `if a if b s else t` 有兩棵剖析樹——這個 `else` 是和裡面的 `if` 配對,還是外面的?這是貨真價實的文法歧義。像 C 與 Java 這樣的語言並不靠改寫文法來修它;它們保留那條看似歧義的規則,再外掛一條用散文寫的消歧規則:「else 與最近的、尚未配對的 if 配對。」發布出來的文法依舊歧義;一句旁註把它擺平。
還有一個更深、也更發人深省的極限。一門程式語言完整的規則集——「變數必須先宣告再使用」、「呼叫傳入的引數數量要對」、「型別要相符」——根本不是上下文無關的。經典的見證者是 a^n b^n c^n 背後的那種模式,它可被證明落在上下文無關世界之外;「先宣告再使用」有著同樣的味道。所以手冊裡的文法只捕捉了合法性的上下文無關骨架。骨架之外的一切,都交由後續的、非文法的階段(名稱解析、型別檢查)去檢查,那正是 BNF 刻意不去嘗試表達的部分。
為這一階梯收尾
回望這趟攀登。你看見了文法為何存在(有限自動機無法配對巢狀括號);你學會把推導與剖析樹當作結構的記錄;你練習了為 a^n b^n、配對括號與算術設計文法;你藉由把優先序與結合性分層而消除了歧義;如今你能讀懂真實手冊實際印出的 BNF 與 EBNF。上下文無關文法不再是一個抽象概念——它就是程式語言語法的工作定義,前面接著詞法分析器,後面連著 AST。
現在有兩條線索從這一階梯向前延伸。一條問的是:在「辨識」這一側,哪台機器能匹配這股生成的力量——答案是下推自動機,一台帶著一疊盤子的有限控制器,而你永遠只能碰最上面那個盤子;那疊堆疊正是 DFA 所欠缺的無界記憶,而結果是一個 CFG 與一台 PDA 描述完全相同的語言。另一條問的是:電腦究竟如何剖析——也就是建造你早已遇過的最左與最右推導的遞迴下降與 LR 演算法。你抵達這一階梯時只能讀字串;離開時,你能生成它們、建構它們,並用世界其餘部分通行的方式發布它們的文法。