「解析器生成器与 DSL 前端」

手写递归下降解析器可控但费时,解析器生成器把语法规则编译成分析表或解析函数。本文讲解 LL 与 LR 的生成算法、PEG 与解析器组合子的取舍、错误恢复与诊断设计,以及语法高亮与编辑器集成的工程要点。

1. 为什么需要解析器生成器

一句话总结: 解析器生成器把「语法规则」与「解析算法」解耦,让语言设计者只写声明式的语法,由工具生成可用的分析器,代价是错误处理与语义动作的自由度受限。

写一个语言的解析器有两条路。第一条是手写递归下降:为每条语法规则写一个函数,函数体里按顺序匹配记号、递归调用其它规则。这条路完全可控,错误信息可以精心设计,性能可以手工优化。第二条是用生成器:写一份声明式的语法文件,交给 Yacc、ANTLR、Bison 这类工具,让它生成分析器。

# 手写递归下降的典型形态
class Parser:
    def __init__(self, tokens):
        self.toks, self.pos = tokens, 0

    def peek(self):
        return self.toks[self.pos] if self.pos < len(self.toks) else None

    def expect(self, kind):
        t = self.peek()
        if t is None or t.kind != kind:
            raise ParseError(f"期望 {kind},实际 {t}", self.pos)
        self.pos += 1
        return t

    def parse_expr(self):
        """expr := term (('+' | '-') term)*"""
        left = self.parse_term()
        while self.peek() and self.peek().kind in ("PLUS", "MINUS"):
            op = self.toks[self.pos]; self.pos += 1
            left = BinOp(op.kind, left, self.parse_term())
        return left

手写解析器的优势是完全的控制权:你可以在任何地方插入最好的错误恢复、可以做上下文相关的解析、可以把语义动作直接写在解析函数里。代价是重复劳动:每加一条语法规则就要写一个函数,规则改动时函数要同步改,而且很容易写出有 bug 的解析逻辑(比如忘记处理左递归、忘记回溯)。

生成器的优势正好相反:语法即文档,规则集中在一处,分析算法由工具保证正确。代价是错误处理难以定制、语义动作与语法的耦合较松、以及调试生成代码比较痛苦。

维度手写递归下降解析器生成器
开发速度慢(规则多时线性增长)快(写语法即可)
错误恢复完全可控,可做到极好受限于生成器的钩子
性能可手工优化到极致通常够用,可调
可维护性规则与代码同步困难语法集中,改动一处
适用生产语言、错误敏感场景DSL、配置文件、原型

实践中常见的组合是:用生成器做原型与 DSL,用手写做生产语言的主解析器。Rust、Go、TypeScript 都用手写递归下降;SQL、CSS、protobuf 的语法描述用生成器;而像 GraphQL、Terraform HCL 这类 DSL 则两种都有实现。

2. 自顶向下与自底向上

一句话总结: 自顶向下从起始符号出发尝试推导,直观但不擅长处理左递归;自底向上从记号出发归约到起始符号,能处理更广的文法但分析表构造更复杂。

2.1 LL 与递归下降

一句话总结: LL 解析从左到右读记号、最左推导,每个非终结符的选择只依赖向前看 k 个记号,k=1 时用一张预测表就能驱动解析。

LL(k) 解析的名字含义是「从左到右扫描、最左推导、向前看 k 个记号」。当 k = 1 时,解析器只需要看当前一个记号就能决定用哪条产生式。判断条件是 FIRST 集与 FOLLOW 集不相交:对每个非终结符 A 的两条产生式,它们的 FIRST 集不能有交集;如果某条产生式能推出空串,则它的 FOLLOW 集也不能与其它产生式的 FIRST 集相交。

# 为 LL(1) 文法构造预测表
def build_ll1_table(grammar, first, follow):
    """grammar: {A: [[sym, ...], ...]},返回 (非终结符, 记号) -> 产生式"""
    table = {}
    for A, prods in grammar.items():
        for prod in prods:
            fs = first_of_seq(prod, first)      # 该产生式首符号的 FIRST 集
            for t in fs - {"EPSILON"}:
                table[(A, t)] = prod
            if "EPSILON" in fs:                 # 能推出空串,用 FOLLOW 填表
                for t in follow[A]:
                    table[(A, t)] = prod
    return table        # 同一格被填两次即冲突,说明不是 LL(1)

LL(1) 的局限很明显:if 语句的悬挂 else 问题、表达式文法的左递归、以及任何需要看两个记号才能决定的场景,都会导致表冲突。解决手段是提取左公因子与消除左递归,把文法改写成 LL(1) 能处理的形式。

# 消除左递归:expr := expr '+' term | term 改写成右递归
#   expr      := term expr_tail
#   expr_tail := '+' term expr_tail | EPSILON
# 代价是语法树变成右倾,需要后续旋转回左结合

ANTLR 走的是另一条路:它用 ALL(*) 算法,在解析时用动态规划模拟任意长的向前看,避免了改文法的麻烦,代价是解析速度略慢于纯 LL(1) 表驱动。

2.2 LR 与 LALR

一句话总结: LR 解析自底向上维护一个状态栈,用「移进-归约」动作把记号序列归约成起始符号,LALR 通过合并同心项集把状态数压到可用范围。

LR(k) 解析从记号流出发,用栈记录已经识别出的部分,根据当前状态与向前看记号选择移进(shift,把记号压栈)或归约(reduce,把栈顶的若干符号替换成一个非终结符)。它的能力严格强于 LL(k):所有 LL(k) 文法都是 LR(k) 文法,反之不然。左递归、左公因子对 LR 都不是问题。

构造 LR 分析表的核心是项集规范族(canonical collection of LR items)。一个项是「产生式 + 已匹配位置」的组合,用点表示:E → E · + T 表示「已经识别了 E,接下来期待 + T」。

# LR(0) 项集闭包与转移(构造 LALR 表的基础)
def closure(items, grammar):
    """对项集求闭包:点在非终结符前时,加入该非终结符的所有产生式"""
    result, changed = set(items), True
    while changed:
        changed = False
        for lhs, rhs, dot in list(result):
            if dot < len(rhs) and rhs[dot] in grammar:   # 点在非终结符前
                for prod in grammar[rhs[dot]]:
                    item = (rhs[dot], tuple(prod), 0)
                    if item not in result:
                        result.add(item); changed = True
    return frozenset(result)

def goto(items, symbol, grammar):
    """GOTO:把点越过 symbol 后求闭包"""
    moved = {(l, r, d + 1) for (l, r, d) in items if d < len(r) and r[d] == symbol}
    return closure(moved, grammar) if moved else frozenset()
变体状态数文法能力典型工具
LR(0)少弱(无向前看)教学用
SLR(1)少中(用 FOLLOW 解决冲突)早期 Yacc
LALR(1)中强(合并同心项集)Bison、Yacc
LR(1)多(可爆炸)最强少用

LALR 的实用价值在于它用「合并同心项集」把 LR(1) 的状态数从可能指数级压到与 LR(0) 同量级,代价是可能引入归约-归约冲突。Bison 默认就是 LALR(1),遇到冲突时报告具体是哪个状态、哪两条规则冲突,让人决定是用优先级声明解决还是改文法。

# Bison 的冲突诊断
bison -v parser.y            # 生成 parser.output,含完整状态机
# 输出形如:
#   State 12 conflicts: 1 shift/reduce
#   观察 .output 里该状态的项集,判断冲突来源
/* 用优先级与结合性声明解决表达式文法的冲突 */
%left  '+' '-'
%left  '*' '/'
%right UMINUS
%%
expr : expr '+' expr | expr '-' expr | expr '*' expr
     | '-' expr %prec UMINUS
     | '(' expr ')' | NUMBER
     ;

3. PEG 与解析器组合子

一句话总结: PEG 用有序选择替代歧义,天然支持回溯与无限向前看,组合子把解析器写成可组合的高阶函数,适合嵌入宿主语言。

PEG(Parsing Expression Grammar)由 Ford 在 2004 年提出,它对 CFG 做了一个关键改动:选择运算符 / 是有序的。A / B 的含义是「先试 A,成功就用 A,失败才试 B」——不存在歧义,每个输入最多有一个解析结果。

3.1 PEG 的特性与陷阱

一句话总结: PEG 的无歧义性与无限向前看让它写起来很舒服,但有序选择会掩盖错误(选了错的分支就不会回退),且需要警惕左递归导致的无限循环。

PEG 运算含义
e1 e2序列:先匹配 e1 再匹配 e2
e1 / e2有序选择:先试 e1,失败才试 e2
e* e+ e?零或多次、一或多次、可选(贪婪,不回溯)
&e !e正向与负向断言,只探测不消耗输入
# PEG 组合子:把解析器写成返回 (值, 新位置) 或 None 的函数
def literal(s):
    def parse(text, pos):
        return (s, pos + len(s)) if text.startswith(s, pos) else None
    return parse

def seq(*ps):
    def parse(text, pos):
        vals = []
        for p in ps:
            r = p(text, pos)
            if r is None:
                return None
            vals.append(r[0]); pos = r[1]
        return (vals, pos)
    return parse

def choice(*ps):
    def parse(text, pos):
        for p in ps:
            r = p(text, pos)
            if r is not None:
                return r          # 有序:第一个成功即返回,不回退
        return None
    return parse

def many(p):
    def parse(text, pos):
        vals = []
        while (r := p(text, pos)) is not None:
            vals.append(r[0]); pos = r[1]
        return (vals, pos)
    return parse

# 组装:数字列表 "1,2,3"
number = choice(literal("1"), literal("2"), literal("3"))
numlist = seq(number, many(seq(literal(","), number)))
print(numlist("1,2,3", 0))       # (['1', [',','2', ',','3']], 5)

PEG 最大的工程陷阱是有序选择的错误掩盖:如果 keyword_begin / identifier 里 keyword_begin 先试,输入 begin_x 会让 keyword_begin 匹配成功、留下 _x 给后续规则,报出一个莫名其妙的错误。正确写法是把更长的模式排前面,或者用负向断言 !ident_char 明确边界。

第二个陷阱是左递归:PEG 的 e* 和递归下降一样无法直接处理 expr := expr '+' term,会无限递归。解决办法是用迭代改写(把左递归规则改写成循环),或者用支持左递归的 Packrat 变体(如 Python 的 lark、Rust 的 pest 的部分模式)。

# 左递归的迭代改写:把 expr := expr '+' term | term 改写成循环
def parse_expr(text, pos):
    left, pos = parse_term(text, pos)
    while text.startswith("+", pos):
        right, pos = parse_term(text, pos + 1)
        left = ("add", left, right)      # 迭代构造左结合树
    return left, pos

4. 错误恢复与诊断

一句话总结: 生成器默认的错误处理通常很糟,要让报错可用必须主动设计恢复策略:同步记号集、错误产生式、局部修复,并在诊断里给出期望集合与上下文。

解析器的错误信息质量直接决定了语言的学习曲线。一个只报 syntax error 的解析器基本没有价值,好的错误信息要回答三个问题:在哪里出错、期望什么、怎么修。

# 差:  syntax error at line 12
# 好:  line 12: 期望 ')' 或 ',',实际遇到 '}'
#           for (int i = 0; i < n; i++ {
#       提示:'(' 在第 10 列打开,可能缺少匹配的 ')'

同步记号集(synchronization set)是最基础的恢复手段:出错后跳过记号,直到遇到一个「安全的」记号(如 ;、}、或语句起始关键字),然后从那里继续解析。它能让解析器在第一个错误之后继续报出后续错误,而不是直接崩溃。

SYNC = {"SEMICOLON", "RBRACE", "IF", "WHILE", "RETURN"}

def recover(self):
    """出错后跳到同步记号,继续解析后续语句"""
    while (t := self.peek()) is not None and t.kind not in SYNC:
        self.pos += 1
    if self.peek() is not None and self.peek().kind == "SEMICOLON":
        self.pos += 1        # 吃掉分号,避免重复报错

def parse_stmt_list(self):
    stmts, errors = [], []
    while self.peek() is not None and self.peek().kind != "RBRACE":
        mark = self.pos
        try:
            stmts.append(self.parse_stmt())
        except ParseError as e:
            errors.append(e); self.recover()
            if self.pos == mark:      # 没有前进,强制跳过防止死循环
                self.pos += 1
    return stmts, errors

更精细的手段是错误产生式:在语法里显式写出常见的错误模式,让解析器能识别「用户少写了分号」并给出针对性提示。Bison 的 error 记号就是为这个设计的:

/* 用 error 记号捕捉缺失分号的错误 */
stmt : expr ';'
     | expr error ';'    { yyerrok; report_missing_semicolon(); }
     ;
恢复策略实现成本恢复质量适用
直接终止无只报第一个错编译器原型
同步记号集低能继续,可能有连锁误报通用
错误产生式中可针对性提示生产语言
局部修复高可自动修正(插入分号等)IDE 场景

局部修复在编辑器场景里特别有价值:用户在打字过程中代码必然是不完整的,编辑器需要尽量给出可用的语法树。Tree-sitter 就是为这个场景设计的——它用 GLR 加错误恢复,即使输入有严重错误也能产出一棵带 ERROR 节点的树,让语法高亮与结构导航仍然可用。

5. 语法高亮与编辑器集成

一句话总结: 编辑器需要增量、容错、快速的解析,这与编译器对解析器的要求几乎正交,因此语法高亮通常用独立的增量解析器而不是编译器前端。

编译器的解析器有两个假设:输入是完整的、正确的。编辑器的输入两个都不满足——文件随时可能处于语法错误状态,而且每次按键都会变化。这导致编辑器需要一类完全不同的解析器。

需求编译器前端编辑器前端
输入完整性完整文件可能是片段
输入正确性必须正确经常有错
解析速度可接受秒级必须毫秒级
增量性不需要必需

Tree-sitter 是这类解析器的代表。它用 GLR 解析生成一个状态机,支持增量重解析:编辑后只重解析受影响的子树,其余部分复用。它的语法文件用 JavaScript 描述,编译成 C 代码,运行时无需分配即可解析,速度达到毫秒级。

// Tree-sitter 语法规则片段(grammar.js)
module.exports = grammar({
  name: 'mylang',
  extras: $ => [/\s/, $.comment],      // 空白与注释在任意位置被跳过
  rules: {
    source_file: $ => repeat($._statement),
    _statement: $ => choice($.let_stmt, $.expr_stmt),
    let_stmt: $ => seq('let', $.identifier, '=', $._expr, ';'),
    binary: $ => prec.left(1, seq($._expr, choice('+', '-'), $._expr)),
  }
});

高亮规则的写法是基于查询的模式匹配:用 S-expression 模式在语法树上匹配节点,给它们打上高亮类别。流程是 tree-sitter generate 生成 C 解析器,再由 tree-sitter highlight 按 queries/highlights.scm 着色。

; queries/highlights.scm:把语法树节点映射到高亮类别
(identifier) @variable
(let_stmt "let" @keyword)
(binary "+" @operator)

这套机制的关键优势是高亮由语法结构驱动而不是正则匹配:正则高亮会把字符串里的 let 也当作关键字,而基于语法树的高亮知道那是一个字符串字面量。代价是需要维护一份语法,以及需要处理增量更新与错误节点。

6. 工具选型与陷阱

一句话总结: 工具选型取决于语言复杂度、错误信息要求与集成场景,而最常见的陷阱是低估错误处理的工作量、以及忽视生成代码的可调试性。

工具算法语言适合场景
Bison / YaccLALR(1)、GLRC/C++生产编译器、性能敏感
ANTLRALL(*)Java 等多目标语言、教学、DSL
PEG.js / peggyPEGJavaScript浏览器内解析
pestPEGRustRust 生态 DSL
larkEarley、LALRPython快速原型
Tree-sitterGLR + 增量C(生成)编辑器、语法高亮
Nom / Chumsky组合子Rust二进制格式、解析库

ANTLR 的特点是一份 .g4 语法可生成多语言解析器(-Dlanguage=Python3 或 -Dlanguage=Go),因此常用于需要多语言客户端共享同一份语法定义的场景。常见陷阱:

陷阱表现应对
忽略左递归LL 工具直接报错消除左递归或改用 PEG 迭代写法
文法歧义冲突警告被忽略用优先级声明或改文法
错误处理后置上线后报错质量差从第一天设计恢复策略
语义动作过重语法与业务耦合用 visitor 分离
生成代码难调试断点打不进生成文件保留语法行号映射
性能未验证大文件解析超时早做基准,考虑 Packrat 缓存
# 语义动作与语法解耦:用 visitor 模式,语法只负责结构
class EvalVisitor(MyLangVisitor):
    def visitBinary(self, ctx):
        left, right = self.visit(ctx.left), self.visit(ctx.right)
        return left + right if ctx.getChild(1).getText() == "+" else left - right
    def visitNumber(self, ctx):
        return int(ctx.getText())   # 求值/检查/生成各写一个 visitor

7. 增量解析与性能

一句话总结: 解析性能的关键在回溯与内存分配,Packrat 缓存能把 PEG 的最坏复杂度从指数降到线性,增量解析则通过复用子树把编辑后的重解析压到常数级。

朴素 PEG 解析器在嵌套回溯的场景下最坏复杂度是指数级的:(a / ab) * c 这类文法会让同一个位置被反复尝试。Packrat 解析用一个记忆表缓存「(规则, 位置) 的结果」,把复杂度降到 O(n),代价是 O(n) 的内存。

def memoize(parse_fn):
    """Packrat 记忆化:缓存 (规则, 位置) 的结果,前提是解析器无副作用"""
    cache = {}
    def wrapper(text, pos):
        key = (parse_fn.__name__, pos)
        cache.setdefault(key, parse_fn(text, pos))
        return cache[key]
    return wrapper

# 基准:对比 Packrat 开关的时间与内存权衡
# hyperfine --warmup 3 'python3 -m myparser big_file.mylang'
# /usr/bin/time -l python3 -m myparser --packrat big_file.mylang

增量解析走的是另一条路:不追求单次解析更快,而是避免重新解析未改变的部分。:不追求单次解析更快,而是避免重新解析未改变的部分。Tree-sitter 的做法是维护一棵带位置信息的树,编辑发生时先找到最小的受影响子树范围,只重解析该范围,再用 GLR 的状态栈把它与旧树缝合。对于典型的编辑操作(改一个标识符、加一行),重解析的工作量是常数级的,与文件大小无关。

8. 总结

环节要点
手写与生成手写可控但费时,生成器快但错误处理受限
LL 解析自顶向下,看 k 个记号决定产生式,需消除左递归
LR 解析自底向上移进归约,LALR 合并同心项集压状态数
冲突解决优先级与结合性声明,或改写文法
PEG有序选择消除歧义,注意左递归与错误掩盖
组合子解析器作为高阶函数,天然嵌入宿主语言
错误恢复同步记号集、错误产生式、局部修复三档
编辑器集成需要增量、容错、毫秒级,Tree-sitter 是代表
性能Packrat 缓存把 PEG 降到线性,增量解析复用子树

解析器生成器与 DSL 前端这一层,是编译器里最容易被工具化、也最容易被工具误导的部分。工具让写语法变得非常轻松,以至于很多人低估了后面两件事:一是错误信息的设计,它决定了语言是否好用;二是编辑器集成,它决定了语言是否有人愿意写。真正成熟的方案往往不是「选一个生成器就完事」,而是针对编译器前端、编辑器前端、文档工具三个场景各选一个解析器,让它们共享同一份语法描述。下一篇我们从静态的语法转向动态的运行——内联缓存与动态语言优化,看看没有静态类型信息的语言如何靠运行时反馈达到接近静态语言的性能。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「compiler」更多文章

  1. MLIR 与多层次 IR
  2. 可复现构建与确定性输出
  3. 约束求解与类型类