1975-Yacc與Lex#
案件摘要#
1975 年,Bell Labs 的 Stephen Johnson 發布 Yacc(Yet Another Compiler-Compiler),Mike Lesk 發布 Lex——隨 Unix 第六版一起出貨。這對搭檔幹的是同一件案子:把 Knuth 與 DeRemer 躺了十年的 LR 理論,變成每個程式員都能用的工具。語法規則進去,LALR 表與 parser 出來;正則表示式進去,掃描器出來。編譯器寫作從手工藝變成工程。此後無數語言——awk、SQL 前端、config 工具、乃至 GCC 的前身——都從這對搭檔的產線上出廠。
前因 -- 為什麼會有這個案子#
- 手寫 parser 的痛:1970 年代初,寫一個編譯器的語法分析器仍靠手工——遞迴下降要繞過左遞迴、運算子優先法只是經驗法則。每個新語言都要重蹈覆轍,錯誤百出、耗時數月。
- 理論躺在紙上:Knuth 的 LR(k)(1965)理論完美但不實用(見 1965-LR解析);DeRemer 的 SLR/LALR(1969/1971)把狀態數壓到可行,但建表演算法本身仍複雜,沒有人願意每次手算。
- Johnson 的洞察:與其讓每個人學會 LALR 建表,不如讓機器算——程式員只寫文法與語意動作,表與 parser 全部自動產生。
- Unix 的土壤:Bell Labs 的 Unix 提供管道、工具鏈文化;「程式產生程式」正是 Unix 哲學的極致體現。
線索與推理 -- 數學式、程式、理論#
Yacc:語法規則進去、LALR parser 出來#
Yacc 的輸入是擴充 BNF 文法,每條產生式可附語意動作(semantic action){ ... },在規約(reduce)時執行。語意值的傳遞用合成棧:
其中 $$ 是左部非終端的語意值,$n 是右部第 \(n\) 個符號的語意值——規約本身就是語意值的組合函數:
Yacc 內部跑 DeRemer 式的 LALR(1) 建表:先建 LR(0) 項目集,再用局部 lookahead 合併消除 shift/reduce 衝突(衝突時 Yacc 預設偏 shift,並發出警告)。
計算機範例:Yacc 文法#
經典的桌上型計算機文法——注意優先級與結合律如何用文法層次表達:
%token NUM
%left '+' '-' /* 同層左結合,優先級低 */
%left '*' '/' /* 同層左結合,優先級高 */
E : E '+' E { $$ = $1 + $3; } /* $1=E左, $3=E右;規約時相加 */
| E '-' E { $$ = $1 - $3; }
| E '*' E { $$ = $1 * $3; }
| '(' E ')' { $$ = $2; } /* 括號:取中間的 E */
| NUM { $$ = $1; }
;
E '+' E 這條會產生 shift/reduce 衝突(左遞迴 + 二義),Yacc 用 %left 宣告的優先級解決:遇到 * 時 shift、遇到 + 時 reduce——優先級宣告被編譯成表中的決定。這正是 Knuth 當年認為「不可行」的東西,如今只需十行文法。
Lex:正則表示式 → 掃描器#
Lesk 的 Lex 用正則表示式描述詞彙(token)模式,自動產生掃描器:
[0-9]+ { yylval = atoi(yytext); return NUM; } /* 整數 */
[ \t\n] ; /* 略過空白 */
[-+*/()] { return yytext[0]; } /* 運算子 */
正則到自動機的理論:Thompson 建構法(1968)先把正則 \(r\) 轉成 NFA \(\epsilon\) -NFA,再以 subset construction 轉成 DFA:
掃描器的策略:最長匹配(maximal munch)+ 遇到無法延伸時回退並發出最後一個接受態的 token。
Python 模擬:regex → DFA 的 subset construction#
以下把正則 (a|b)*abb 轉成 DFA——這是 Lex 內部引擎的最小版本:
# 正則 (a|b)*abb 的 NFA(epsilon-free 簡化版,手工展開)
# 狀態以 frozenset 命名(subset construction 的產物)
nfa = { # 每狀態 -> {字元 -> 目標狀態集}
0: {'a': {0, 1}, 'b': {0}},
1: {'b': {2}},
2: {'b': {3}},
3: {},
}
accept = {3}
def move(states, ch):
out = set()
for s in states:
out |= nfa[s].get(ch, set())
return frozenset(out)
def subset_construction():
start = frozenset({0})
dfa, work, done = {}, [start], set()
while work: # 不動點迭代:每個新狀態集
S = work.pop()
if S in done: continue
done.add(S)
for ch in 'ab':
T = move(S, ch)
dfa[(S, ch)] = T
if T and T not in done: work.append(T)
return dfa, start
def run_dfa(dfa, start, s):
cur = start
for ch in s:
cur = dfa.get((cur, ch), frozenset())
if not cur: return False
return bool(cur & accept)
dfa, start = subset_construction()
print(run_dfa(dfa, start, "aabb")) # True —— 以 abb 結尾 ✔
print(run_dfa(dfa, start, "abba")) # False —— 不以 abb 結尾
subset_construction 把 NFA 的狀態集合當成一個 DFA 狀態,用不動點迭代窮舉可達集合——這正是 Lex 產生掃描器的核心演算法。DFA 一次讀一個字元、無回溯,線性時間完成詞法分析。
Yacc + Lex 的產線#
組合流程:
字元流 ──→ [Lex 產生的 DFA 掃描器] ──→ token 流 ──→ [Yacc 產生的 LALR parser] ──→ 語意樹/計算
(正則 → DFA) (BNF → LALR 表)
掃描器與 parser 之間用 yylval 傳語意值——兩台自動機串成編譯器前端的整條產線。
結案 -- 後果與影響#
- 編譯器寫作民主化:寫一個新語言的前端從「數月的手工藝」變成「數天的工程」;1970 年代末大量新語言與 DSL(awk 1977、SQL 前端、配置工具)用 Yacc/Lex 建立。
- GCC 的血統:GCC 最初(1987)的 C parser 即以 Yacc 式 LALR 為基礎;GNU 的後繼者 Bison 至今仍是標準工具。
- 理論的勝利:Knuth 1965 的理論 → DeRemer 1969/71 的簡化 → Johnson 1975 的工具化——從理論到工具的完整傳播鏈,成為計算機科學「數學改變工程」的教科書案例。
- 後繼工具:ANTLR(遞迴下降 + LL(*))、Rust 的 logos、各語言的 parser combinator——皆循「聲明式規則 → 自動產生解析器」的路線;LLVM 的表格生成(TableGen)同屬此案精神。
關鍵人物與文獻#
- Stephen C. Johnson(Bell Labs):Yacc,1975;後參與 Portable C Compiler。
- Mike Lesk(Bell Labs):Lex,1975;後參與 Unix 工具鏈(tbl 等)。
- 文獻:
- S. C. Johnson, "Yacc: Yet Another Compiler-Compiler," Unix Programmer's Manual, 1975.
- M. E. Lesk, "Lex: A Lexical Analyzer Generator," Unix Programmer's Manual, 1975.
- K. Thompson, "Regular Expression Search Algorithm," CACM 11(6), 1968.