写一个解析器有两条路:手写递归下降,或者用 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、V8Hive、Presto、Cassandra CQLPostgreSQL、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 自动修复建议)只有手写解析器给得出来。

七、常见误区

八、总结

三个解析器、三条技术路线,本文都用完整代码落到了实处:递归下降展示了文法分层如何编码优先级,Pratt 用一张绑定力表统一了 12 个运算符的优先级与结合性,SLR 则完整走通了 FIRST/FOLLOW 计算、项集构造到移进-归约驱动器的理论路线。工业界的选择已经清晰:当语言的演进速度、错误提示质量与调试效率成为主要矛盾时,手写递归下降 + Pratt 是现代编译器的标准答案;而理解 LR 表驱动的构造,仍然是理解「语法分析」这一概念不可替代的路径。

参考文献

  1. V. Pratt. Top Down Operator Precedence. POPL, 1973.
  2. A. Aho et al. Compilers: Principles, Techniques, and Tools (2nd Edition), Chapter 4. Addison-Wesley, 2006.
  3. T. Parr. The Definitive ANTLR 4 Reference (Adaptive LL(*)). Pragmatic Bookshelf, 2013.
  4. B. Nystrom. Pratt Parsers: Expression Parsing Made Easy. journal.stuffwithstuff.com, 2011.
← 返回文章列表