写一个解析器有两条路:手写递归下降,或者用 Yacc/ANTLR 这类生成器。GCC 从 Bison 转向手写递归下降,Go、Rust、Swift 全都手写,而 Clang 甚至在递归下降里嵌入了 Pratt 解析表达式。为什么工业界集体抛弃了生成器?本文用三个完整可运行的 Python 解析器回答这个问题:教科书式递归下降(处理优先级靠文法分层)、Pratt 解析器(用绑定力统一处理 12 个运算符)、以及一个 SLR 表驱动解析器的完整构造过程——包括 FIRST/FOLLOW 集计算与 ACTION/GOTO 表生成。
一、共同的词法分析器
三种解析器共享同一个 tokenizer,把 1 + 2 * -3 切成 token 流:
import re
from dataclasses import dataclass
@dataclass
class Token:
kind: str # 'num' | 'op' | 'lparen' | 'rparen' | 'eof'
text: str
pos: int
def tokenize(src: str) -> list:
spec = [
('num', r'\d+(\.\d+)?'),
('op', r'[+\-*/%^=<>!&|]+'),
('lparen', r'\('),
('rparen', r'\)'),
('skip', r'\s+'),
]
regex = '|'.join(f'(?P<{n}>{p})' for n, p in spec)
tokens = []
for m in re.finditer(regex, src):
if m.lastgroup == 'skip':
continue
tokens.append(Token(m.lastgroup, m.group(), m.start()))
tokens.append(Token('eof', '', len(src)))
return tokens
二、实现一:递归下降解析器
2.1 用文法分层表达优先级
递归下降的经典做法是每个优先级一层函数:表达式 → 项 → 因子 → 原子。优先级低的运算在上层,调用链越深优先级越高。乘除在加减的下层,因此天然先结合:
# AST 节点用元组表示: ('binop', '+', left, right) / ('num', 42)
class RDParser:
def __init__(self, tokens):
self.toks = tokens
self.i = 0
def peek(self):
return self.toks[self.i]
def eat(self, text=None):
t = self.toks[self.i]
if text and t.text != text:
raise SyntaxError(
f"位置 {t.pos}: 期望 '{text}',得到 '{t.text}'")
self.i += 1
return t
# expr := term (('+' | '-') term)*
def expr(self):
left = self.term()
while self.peek().text in ('+', '-'):
op = self.eat().text
right = self.term()
left = ('binop', op, left, right)
return left
# term := factor (('*' | '/') factor)*
def term(self):
left = self.factor()
while self.peek().text in ('*', '/'):
op = self.eat().text
right = self.factor()
left = ('binop', op, left, right)
return left
# factor := '-' factor | atom (一元负号,右递归保证右结合)
def factor(self):
if self.peek().text == '-':
self.eat('-')
return ('neg', self.factor())
return self.atom()
# atom := num | '(' expr ')'
def atom(self):
t = self.peek()
if t.kind == 'num':
self.eat()
return ('num', float(t.text))
if t.text == '(':
self.eat('(')
e = self.expr()
self.eat(')')
return e
raise SyntaxError(f"位置 {t.pos}: 意外的 '{t.text}'")
def parse_rd(src):
p = RDParser(tokenize(src))
ast = p.expr()
if p.peek().kind != 'eof':
raise SyntaxError(f"位置 {p.peek().pos}: 多余输入 '{p.peek().text}'")
return ast
# parse_rd('1 + 2 * -3')
# → ('binop', '+', ('num', 1.0),
# ('binop', '*', ('num', 2.0), ('neg', ('num', 3.0))))
这个解析器正确、直观,但有个明显的扩展性问题:每加一个优先级就要加一层函数、改一条调用链。C 语言有 15 级优先级,纯分层写法则需要 15 个几乎一模一样的函数——这正是 Pratt 解析器要消灭的重复。
三、实现二:Pratt 解析器(自上而下算符优先)
Pratt 解析器(Vaughan Pratt, 1973)的核心思想:不为优先级分层,而是给每个 token 两个数值——左绑定力(lbp)与右绑定力(rbp)。解析循环不断比较「当前表达式的右上下文强度」与「下一个运算符的左绑定力」,决定继续吸收还是返回。优先级、结合性、前缀/中缀/后缀全部统一在这套数值机制里:
# 绑定力表: (左绑定力 lbp, 右结合时 rbp = lbp - 1)
BINDING = {
'=': (10, 9), # 右结合: a = b = c
'|': (20, 20),
'&': (30, 30),
'==': (40, 40), '!=': (40, 40),
'<': (50, 50), '>': (50, 50),
'+': (60, 60), '-': (60, 60),
'*': (70, 70), '/': (70, 70), '%': (70, 70),
'^': (90, 89), # 幂运算右结合: 2^3^2 = 2^(3^2)
}
class PrattParser:
def __init__(self, tokens):
self.toks = tokens
self.i = 0
def peek(self):
return self.toks[self.i]
def eat(self):
t = self.toks[self.i]
self.i += 1
return t
def parse(self, rbp=0):
"""rbp: 当前右上下文要求的最低绑定力。"""
t = self.eat()
# ===== nud (null denotation): token 出现在表达式开头 =====
if t.kind == 'num':
left = ('num', float(t.text))
elif t.text == '-': # 前缀负号,绑定力 80
left = ('neg', self.parse(80))
elif t.text == '!': # 前缀逻辑非
left = ('not', self.parse(80))
elif t.text == '(':
left = self.parse(0)
close = self.eat()
if close.text != ')':
raise SyntaxError(f"位置 {close.pos}: 缺少 ')' ")
else:
raise SyntaxError(f"位置 {t.pos}: '{t.text}' 不能开头")
# ===== led (left denotation): 循环吸收更强的中缀运算符 =====
while self.peek().text in BINDING and \
BINDING[self.peek().text][0] > rbp:
op = self.eat()
lbp, op_rbp = BINDING[op.text]
right = self.parse(op_rbp)
left = ('binop', op.text, left, right)
return left
def parse_pratt(src):
p = PrattParser(tokenize(src))
ast = p.parse()
if p.peek().kind != 'eof':
raise SyntaxError(f"位置 {p.peek().pos}: 多余输入")
return ast
# 验证结合性:
# parse_pratt('1 - 2 - 3') → ((1-2)-3) 左结合
# parse_pratt('2 ^ 3 ^ 2') → (2^(3^2)) 右结合
# parse_pratt('a = b = 1') → (a=(b=1)) 右结合
绑定力机制如何同时表达结合性?左结合运算符的 rbp 等于 lbp:解析完 1 - 2 后回到循环,下一个 - 的 lbp(60) 不大于当前 rbp(60),循环退出,结果向左折叠。右结合运算符(如 ^,lbp=90, rbp=89)在递归解析右操作数时传入 rbp=89,下一个 ^ 的 lbp(90) > 89,继续向右深入——右结合自然涌现。新增一个运算符只需在表里加一行,这是 GCC/Clang/Rust 表达式解析的真实结构。
四、实现三:SLR 表驱动解析器
表驱动解析(Yacc/Bison 的路线)把语法编译成两张表:ACTION(遇到某 token 移进还是归约)与 GOTO(归约后状态转移)。运行时是一个通用状态机,与具体语法无关。以文法 E → E + T | T; T → T * F | F; F → num 为例,完整构造过程:
4.1 FIRST 集与 FOLLOW 集计算
from collections import defaultdict
rules = [
("E'", ['E']),
('E', ['E', '+', 'T']),
('E', ['T']),
('T', ['T', '*', 'F']),
('T', ['F']),
('F', ['num']),
]
nonterms = {"E'", 'E', 'T', 'F'}
# FIRST 集不动点迭代
first = defaultdict(set)
changed = True
while changed:
changed = False
for lhs, rhs in rules:
before = len(first[lhs])
if rhs[0] in nonterms:
first[lhs] |= first[rhs[0]]
else:
first[lhs].add(rhs[0])
changed |= len(first[lhs]) != before
# FOLLOW 集不动点迭代
follow = defaultdict(set)
follow["E'"].add('$')
changed = True
while changed:
changed = False
for lhs, rhs in rules:
for i, sym in enumerate(rhs):
if sym not in nonterms:
continue
before = len(follow[sym])
if i + 1 < len(rhs):
nxt = rhs[i + 1]
follow[sym] |= (first[nxt] if nxt in nonterms else {nxt})
else:
follow[sym] |= follow[lhs]
changed |= len(follow[sym]) != before
4.2 LR(0) 项集族与表构造
def closure(items):
"""闭包:把 '.' 后是非终结符的项展开"""
items = set(items)
work = list(items)
while work:
(lhs, rhs, dot) = work.pop()
if dot < len(rhs) and rhs[dot] in nonterms:
for l, r in rules:
if l == rhs[dot]:
it = (l, tuple(r), 0)
if it not in items:
items.add(it)
work.append(it)
return frozenset(items)
def goto(items, sym):
moved = {(l, r, d + 1) for (l, r, d) in items
if d < len(r) and r[d] == sym}
return closure(moved) if moved else frozenset()
# 构造项集族 C = {I0, I1, ...} 与转移
C = [closure([("E'", ('E',), 0)])]
trans = {}
syms = ['E', 'T', 'F', 'num', '+', '*']
i = 0
while i < len(C):
for s in syms:
j = goto(C[i], s)
if j and j not in C:
C.append(j)
if j:
trans[(i, s)] = C.index(j)
i += 1
# 填充 ACTION/GOTO 表
action, gotot = {}, {}
for i, I in enumerate(C):
for (lhs, rhs, dot) in I:
if dot < len(rhs) and rhs[dot] not in nonterms:
# 移进
action[(i, rhs[dot])] = ('shift', trans[(i, rhs[dot])])
elif dot == len(rhs):
if lhs == "E'":
action[(i, '$')] = ('accept',)
else:
for a in follow[lhs]: # SLR: 按 FOLLOW 集归约
action[(i, a)] = ('reduce', (lhs, rhs))
for nt in nonterms:
if (i, nt) in trans:
gotot[(i, nt)] = trans[(i, nt)]
4.3 通用驱动器与运行轨迹
def parse_slr(src):
toks = [(t.text if t.kind != 'num' else 'num') for t in tokenize(src)]
toks.append('$')
stack = [0] # 状态栈
vals = [] # 语义值栈(存放 AST)
pos = 0
while True:
state = stack[-1]
act = action.get((state, toks[pos]))
if act is None:
raise SyntaxError(f"SLR 解析失败: 状态 {state} 遇到 {toks[pos]}")
if act[0] == 'shift':
stack.append(act[1])
vals.append(toks[pos])
pos += 1
elif act[0] == 'reduce':
lhs, rhs = act[1]
n = len(rhs)
children = vals[-n:] if n else []
del stack[-n:], vals[-n:]
stack.append(gotot[(stack[-1], lhs)])
# 归约时构建 AST 节点
vals.append((lhs, *children) if n > 1 else children[0])
else: # accept
return vals[0]
# parse_slr('1+2*3') 的完整移进-归约轨迹:
# 状态栈动作序列: shift(num) → reduce(F→num) → reduce(T→F)
# → reduce(E→T) → shift(+) → shift(num) → reduce(F→num)
# → shift(*) → shift(num) → reduce(F→num) → reduce(T→T*F)
# → reduce(E→E+T) → accept
这条轨迹展示了 LR 解析的本质:自底向上——先把 num 归约为 F、T,再在看到完整 T * F 后才归约出乘法子树,最后归约加法。与递归下降「从 E 开始向下预测」的方向恰好相反,这也是 LR 能处理左递归文法(E → E + T 在递归下降中会死循环,在 LR 中却是最自然的写法)的原因。
五、三种方案的全方位对决
| 维度 | 递归下降(+Pratt) | LL(k) 生成器(ANTLR) | LR 生成器(Bison) |
|---|---|---|---|
| 解析方向 | 自顶向下 | 自顶向下 | 自底向上 |
| 文法限制 | 不能左递归,需手工消除 | 不能左递归(ANTLR4 支持直接左递归表达式) | 几乎无限制(LALR 冲突需排查) |
| 优先级处理 | Pratt 绑定力,一行一个运算符 | 文法内声明 | %left/%right 声明 |
| 错误报告质量 | 极佳(可手写上下文相关提示) | 良好 | 一般(状态机报错位置模糊) |
| 错误恢复 | 可定制 panic-mode 任意策略 | 自动同步点恢复 | 工具生成,定制困难 |
| 性能 | 高(直接函数调用) | 中高 | 高(表跳转,缓存友好) |
| 可调试性 | 直接单步调试源码 | 生成的代码可读性差 | 需要理解状态机才能调试 |
| 代表用户 | GCC、Clang、Go、Rust、Swift、V8 | Hive、Presto、Cassandra CQL | PostgreSQL、Ruby、PHP |
六、错误恢复实战:panic-mode 实现
生产编译器不能在第一个语法错误就退出——用户希望一次看到所有错误。递归下降的 panic-mode 恢复:报错后跳到同步 token(分号、右括号等)继续解析:
SYNC_TOKENS = {';', ')', '}', 'def', 'return'}
class RecoveringParser(PrattParser):
def parse_statement(self):
try:
return self.parse()
except SyntaxError as e:
self.errors.append(str(e))
# panic-mode: 丢弃 token 直到同步点
while self.peek().kind != 'eof' and \
self.peek().text not in SYNC_TOKENS:
self.eat()
if self.peek().text == ';':
self.eat() # 吃掉同步 token,继续下一条语句
return ('error',)
# GCC 著名的 "expected ';' before ..." 提示正是手写解析器
# 在 eat(';') 失败点附加上下文信息后输出的。
表驱动解析器也能做错误恢复(Bison 的 error 产生式),但恢复点必须在文法设计时预留,灵活性远不及手写。这是「为什么工业界转向手写」问题下最真实的答案:错误消息质量。Clang 那闻名业界的诊断(精确到列的波浪线、fix-it 自动修复建议)只有手写解析器给得出来。
七、常见误区
- 误区一:生成器一定比手写快。实测中手写递归下降通常更快:没有表查询间接层,热路径是直接的函数调用与分支预测;Go 编译器从 yacc 迁移到手写后解析速度提升了约 10%。
- 误区二:LR 强于一切。LR(1) 能解析的文法类确实大于 LL(1),但真实语言几乎都不是上下文无关的(C 的 typedef 歧义需要符号表反馈),理论表达能力优势在实践中被语义处理吃掉大半。
- 误区三:Pratt 只能解析表达式。绑定力机制可推广到语句级(Rust 的宏解析、Wren 语言的完整语法都是 Pratt 风格)。
- 误区四:PEG/Packrat 是银弹。PEG 无歧义且易于组合,但 Packrat 的内存消耗与输入成线性(缓存全部中间结果),且左递归需要额外变换——Zig 与部分新语言用它,主流编译器仍是递归下降。
八、总结
三个解析器、三条技术路线,本文都用完整代码落到了实处:递归下降展示了文法分层如何编码优先级,Pratt 用一张绑定力表统一了 12 个运算符的优先级与结合性,SLR 则完整走通了 FIRST/FOLLOW 计算、项集构造到移进-归约驱动器的理论路线。工业界的选择已经清晰:当语言的演进速度、错误提示质量与调试效率成为主要矛盾时,手写递归下降 + Pratt 是现代编译器的标准答案;而理解 LR 表驱动的构造,仍然是理解「语法分析」这一概念不可替代的路径。
参考文献
- V. Pratt. Top Down Operator Precedence. POPL, 1973.
- A. Aho et al. Compilers: Principles, Techniques, and Tools (2nd Edition), Chapter 4. Addison-Wesley, 2006.
- T. Parr. The Definitive ANTLR 4 Reference (Adaptive LL(*)). Pragmatic Bookshelf, 2013.
- B. Nystrom. Pratt Parsers: Expression Parsing Made Easy. journal.stuffwithstuff.com, 2011.