语法分析是编译器的第二个阶段——它接收词法分析器输出的 Token 流,构建出结构化的抽象语法树(AST)。如果说词法分析是”认字”,语法分析就是”断句”——理解 Token 之间的结构关系。
接下来拆解语法分析——上下文无关文法如何描述语言结构?LL 分析和 LR 分析有什么区别?递归下降为什么是现代编译器的首选?AST 如何设计才能既简洁又信息丰富?
一、语法分析的任务与定位
1.1 从 Token 流到 AST
语法分析器的核心任务:
- 结构化:将线性的 Token 流组织为树形结构
- 验证:检查 Token 序列是否符合语法规则
- 构建 AST:生成抽象语法树,供后续阶段使用
- 错误报告:发现语法错误时,给出有意义的错误信息
1.2 解析树 vs AST
| 特性 | 解析树 | AST |
|---|---|---|
| 包含信息 | 所有语法细节(括号、分号等) | 只有语义相关信息 |
| 大小 | 大 | 小 |
| 用途 | 语法分析过程记录 | 后续编译阶段使用 |
| 构建者 | 语法分析器自动生成 | 语法分析器手动/自动构建 |
二、上下文无关文法(CFG)
2.1 CFG 的定义
上下文无关文法是描述编程语言语法的数学工具,由四元组 G = (V, T, P, S) 定义:
- V:非终结符(变量)集合,如
Expr、Stmt - T:终结符(Token)集合,如
INT、IDENT、+ - P:产生式规则集合,如
Expr → Expr + Term - S:起始符号
# 一个简单的算术表达式文法# E → E + T | E - T | T# T → T * F | T / F | F# F → ( E ) | NUMBER | IDENT
# 用 Python 表示文法grammar = { 'E': [['E', '+', 'T'], ['E', '-', 'T'], ['T']], 'T': [['T', '*', 'F'], ['T', '/', 'F'], ['F']], 'F': [['(', 'E', ')'], ['NUMBER'], ['IDENT']],}2.2 文法的歧义性
一个文法是歧义的,如果存在一个句子可以推导出两棵不同的解析树:
# 歧义文法:E → E + E | E * E | NUMBER# 输入:3 + 4 * 5
# 推导 1:(3 + 4) * 5 = 35# 推导 2:3 + (4 * 5) = 23 ← 数学上正确消除歧义的方法:
| 方法 | 说明 | 示例 |
|---|---|---|
| 引入优先级层次 | 不同优先级的运算符用不同的非终结符 | E → E + T, T → T * F |
| 结合性规则 | 左结合用左递归,右结合用右递归 | E → E + T(左结合) |
| 优先级声明 | 在文法外声明运算符优先级 | yacc/bison 的 %left, %right |
2.3 左递归与右递归
# 左递归(适合左结合运算符)E → E + T | T
# 右递归(适合右结合运算符,如赋值)E → T + E | T
# 左递归的问题:递归下降分析器会无限递归!# 解决:消除左递归# E → T E'# E' → + T E' | ε# 消除左递归的算法def eliminate_left_recursion(grammar): """消除文法的直接左递归""" new_grammar = {} for A in grammar: # 分离左递归和非左递归产生式 left_rec = [prod for prod in grammar[A] if prod[0] == A] non_left_rec = [prod for prod in grammar[A] if prod[0] != A]
if left_rec: # 创建新的非终结符 A' A_prime = A + "'" new_grammar[A] = [prod + [A_prime] for prod in non_left_rec] new_grammar[A_prime] = [prod[1:] + [A_prime] for prod in left_rec] + [['ε']] else: new_grammar[A] = grammar[A]
return new_grammar三、递归下降分析
递归下降是最直观的语法分析方法——每个非终结符对应一个函数,函数之间相互递归调用。现代编译器(GCC、Clang、Go、Rust)几乎都使用递归下降。
3.1 基本原理
3.2 完整的递归下降分析器
from dataclasses import dataclassfrom typing import Union
# AST 节点定义@dataclassclass NumberLit: value: int
@dataclassclass Identifier: name: str
@dataclassclass BinaryOp: op: str left: 'Expr' right: 'Expr'
# 其他节点类型遵循相同模式(UnaryOp、CallExpr、VarDecl、ReturnStmt、Block、FuncDecl 等)
Expr = Union[NumberLit, Identifier, BinaryOp]
class Parser: def __init__(self, tokens): self.tokens = tokens self.pos = 0
def _peek(self): if self.pos < len(self.tokens): return self.tokens[self.pos] return Token(TokenType.EOF, '', 0, 0)
def _advance(self): token = self._peek() self.pos += 1 return token
def _expect(self, token_type): token = self._advance() if token.type != token_type: raise SyntaxError( f"Expected {token_type}, got {token.type} " f"'{token.value}' at {token.line}:{token.col}" ) return token
def _match(self, *token_types): if self._peek().type in token_types: return self._advance() return None
# 表达式解析(算符优先) def parse_expr(self) -> Expr: return self._parse_comparison()
def _parse_comparison(self) -> Expr: left = self._parse_addition() while self._peek().type in (TokenType.LT, TokenType.GT, TokenType.EQ, TokenType.NEQ): op = self._advance().value right = self._parse_addition() left = BinaryOp(op, left, right) return left
def _parse_addition(self) -> Expr: left = self._parse_multiplication() while self._peek().type in (TokenType.PLUS, TokenType.MINUS): op = self._advance().value right = self._parse_multiplication() left = BinaryOp(op, left, right) return left
def _parse_multiplication(self) -> Expr: left = self._parse_unary() while self._peek().type in (TokenType.STAR, TokenType.SLASH): op = self._advance().value right = self._parse_unary() left = BinaryOp(op, left, right) return left
def _parse_unary(self) -> Expr: if self._peek().type == TokenType.MINUS: self._advance() operand = self._parse_unary() return UnaryOp('-', operand) return self._parse_primary()
def _parse_primary(self) -> Expr: token = self._peek() if token.type == TokenType.INTEGER: self._advance() return NumberLit(int(token.value)) elif token.type == TokenType.IDENTIFIER: self._advance() if self._peek().type == TokenType.LPAREN: return self._parse_call(token.value) return Identifier(token.value) elif token.type == TokenType.LPAREN: self._advance() # ( expr = self.parse_expr() self._expect(TokenType.RPAREN) # ) return expr else: raise SyntaxError( f"Unexpected token {token.type} '{token.value}' " f"at {token.line}:{token.col}" )
def _parse_call(self, func_name: str) -> CallExpr: self._expect(TokenType.LPAREN) args = [] if self._peek().type != TokenType.RPAREN: args.append(self.parse_expr()) while self._match(TokenType.COMMA): args.append(self.parse_expr()) self._expect(TokenType.RPAREN) return CallExpr(func_name, args)3.3 递归下降的优缺点
| 优点 | 缺点 |
|---|---|
| 代码直观,易于理解和维护 | 左递归文法需要改写 |
| 错误信息精确,可自定义 | 需要手动处理优先级和结合性 |
| 可以在解析过程中构建 AST | 回溯可能导致性能问题 |
| 支持任意上下文相关语法 | 不如 LR 分析器严格 |
| 几乎所有现代编译器使用 | 需要手动提取左公因子 |
四、LL 分析与 LR 分析
4.1 分析策略的分类
4.2 LL 分析 vs LR 分析
| 特性 | LL 分析 | LR 分析 |
|---|---|---|
| 方向 | 自顶向下 | 自底向上 |
| 推导 | 最左推导 | 最右推导的逆过程 |
| 构建顺序 | 从根到叶 | 从叶到根 |
| 文法限制 | 不能有左递归 | 允许左递归 |
| 文法范围 | LL 文法 ⊂ LR 文法 | 更大的文法类 |
| 实现方式 | 递归下降或预测表 | 移进-归约自动机 |
| 错误检测 | 较早 | 较晚 |
| 典型工具 | 递归下降、ANTLR | yacc/bison、LALR |
| 典型用户 | GCC、Clang、Go、Rust | GCC(旧版)、Python |
4.3 LL(1) 分析
LL(1) 分析使用一个前瞻 Token决定选择哪个产生式:
# LL(1) 预测分析表# 文法:# E → T E'# E' → + T E' | ε# T → F T'# T' → * F T' | ε# F → ( E ) | NUMBER
predict_table = { ('E', '('): ['T', "E'"], ('E', 'NUMBER'): ['T', "E'"], ("E'", '+'): ['+', 'T', "E'"], ("E'", ')'): ['ε'], ("E'", '$'): ['ε'], ('T', '('): ['F', "T'"], ('T', 'NUMBER'): ['F', "T'"], ("T'", '*'): ['*', 'F', "T'"], ("T'", '+'): ['ε'], ("T'", ')'): ['ε'], ("T'", '$'): ['ε'], ('F', '('): ['(', 'E', ')'], ('F', 'NUMBER'): ['NUMBER'],}4.4 LR 分析:移进-归约
LR 分析使用栈和状态机,核心操作是移进(Shift)和归约(Reduce):
# 简化的 LR 分析器def lr_parse(tokens, action_table, goto_table): stack = [0] # 状态栈 pos = 0
while True: state = stack[-1] token = tokens[pos]
action = action_table.get((state, token.type))
if action is None: raise SyntaxError(f"Syntax error at {token}")
if action[0] == 'shift': stack.append(token) stack.append(action[1]) # 新状态 pos += 1
elif action[0] == 'reduce': prod_len = action[2] # 产生式右部长度 non_terminal = action[1] # 产生式左部 # 弹出 2*prod_len 个元素 for _ in range(2 * prod_len): stack.pop() state = stack[-1] stack.append(non_terminal) stack.append(goto_table[(state, non_terminal)])
elif action[0] == 'accept': return "Parse successful!"4.5 SLR vs LALR vs CLR 对比
| 特性 | SLR(1) | LALR(1) | CLR(1) |
|---|---|---|---|
| 全称 | Simple LR | Look-Ahead LR | Canonical LR |
| 状态数 | 少 | 少(与 SLR 相同) | 多(可能很大) |
| 文法范围 | 最小 | 中等 | 最大 |
| 实现复杂度 | 简单 | 中等 | 复杂 |
| 冲突可能性 | 高 | 中 | 低 |
| 典型工具 | — | yacc/bison | — |
| 实际使用 | 教学用 | 最广泛 | 理论研究 |
LALR(1) 是实际使用最广泛的 LR 分析方法。yacc/bison 默认生成 LALR(1) 分析器。LALR(1) 的状态数与 SLR 相同,但通过更精确的 lookahead 集合减少了冲突。
五、算符优先分析
算符优先分析是一种简化版的 LR 分析,专门用于处理表达式:
5.1 优先级表
# 运算符优先级表precedence = { ('+', '+'): '>', ('+', '-'): '>', ('+', '*'): '<', ('+', '/'): '<', ('-', '+'): '>', ('-', '-'): '>', ('-', '*'): '<', ('-', '/'): '<', ('*', '+'): '>', ('*', '-'): '>', ('*', '*'): '>', ('*', '/'): '>', ('/', '+'): '>', ('/', '-'): '>', ('/', '*'): '>', ('/', '/'): '>', # '$' 是结束标记 ('$', '+'): '<', ('$', '*'): '<', ('+', '$'): '>', ('*', '$'): '>',}
# '<' : 栈顶运算符优先级低,移进# '>' : 栈顶运算符优先级高,归约# '=' : 优先级相同(用于括号匹配)5.2 Pratt 解析器
Pratt 解析器是递归下降的一种变体,通过绑定力(binding power)优雅地处理优先级:
class PrattParser: def __init__(self, tokens): self.tokens = tokens self.pos = 0
# 绑定力度表:值越大优先级越高 PREFIX_BP = { '-': 7, '!': 7, }
INFIX_BP = { '=': (2, 1), # 右结合:左绑定力 2,右绑定力 1 '+': (5, 6), # 左结合:左绑定力 5,右绑定力 6 '-': (5, 6), '*': (7, 8), '/': (7, 8), }
def parse_expr(self, min_bp=0): # 前缀操作 left = self.parse_prefix()
# 中缀操作 while True: op = self._peek().value if op not in self.INFIX_BP: break (left_bp, right_bp) = self.INFIX_BP[op] if left_bp < min_bp: break self._advance() right = self.parse_expr(right_bp) left = BinaryOp(op, left, right)
return left
def parse_prefix(self): token = self._advance() if token.type == TokenType.INTEGER: return NumberLit(int(token.value)) elif token.type == TokenType.IDENTIFIER: return Identifier(token.value) elif token.value in self.PREFIX_BP: bp = self.PREFIX_BP[token.value] operand = self.parse_expr(bp) return UnaryOp(token.value, operand) elif token.type == TokenType.LPAREN: expr = self.parse_expr(0) self._expect(TokenType.RPAREN) return expr else: raise SyntaxError(f"Unexpected token: {token}")六、AST 设计
6.1 AST 节点类型体系
6.2 AST 的访问者模式
class ASTVisitor: """AST 访问者基类"""
def visit(self, node): method_name = f'visit_{type(node).__name__}' method = getattr(self, method_name, self.generic_visit) return method(node)
def generic_visit(self, node): raise NotImplementedError( f"No visit_{type(node).__name__} method" )
class PrettyPrinter(ASTVisitor): """AST 美化打印"""
def visit_BinaryOp(self, node): left = self.visit(node.left) right = self.visit(node.right) return f"({left} {node.op} {right})"
def visit_NumberLit(self, node): return str(node.value)
def visit_Identifier(self, node): return node.name
def visit_UnaryOp(self, node): operand = self.visit(node.operand) return f"({node.op}{operand})"
def visit_VarDecl(self, node): init = f" = {self.visit(node.init)}" if node.init else "" return f"{node.type_name} {node.name}{init};"
# 使用ast = VarDecl("int", "x", BinaryOp("+", NumberLit(3), NumberLit(4)))printer = PrettyPrinter()print(printer.visit(ast)) # int x = (3 + 4);6.3 不同语言的 AST 对比
| 语言 | AST 特点 | 特殊节点 |
|---|---|---|
| C | 简洁,与语法结构对应 | 预处理器指令、逗号表达式 |
| Java | 丰富,包含类型信息 | 注解、泛型、Lambda |
| Python | 缩进敏感 | Suite(缩进块)、ListComp |
| Rust | 类型丰富 | 生命周期标注、模式匹配 |
| Go | 简洁 | 多赋值、defer、go |
七、错误恢复
7.1 语法错误的类型
| 错误类型 | 示例 | 说明 |
|---|---|---|
| 缺少 Token | int x = 42(缺少分号) | 最常见的错误 |
| 多余 Token | int x = = 42; | 多了一个等号 |
| Token 顺序错误 | x int = 42; | 类型和名称反了 |
| 结构不匹配 | { if (x) } | if 缺少语句体 |
7.2 错误恢复策略
7.3 恐慌模式恢复
恐慌模式是最常用的错误恢复策略——当发现错误时,跳过 Token 直到遇到同步 Token(通常是语句结束符 ; 或块结束符 }):
class ParserWithErrorRecovery(Parser): # 同步 Token 集合 SYNC_TOKENS = {TokenType.SEMI, TokenType.RBRACE, TokenType.EOF}
def _synchronize(self): """跳过 Token 直到遇到同步点""" while self._peek().type not in self.SYNC_TOKENS: self._advance() # 跳过同步 Token 本身 if self._peek().type == TokenType.SEMI: self._advance()
def parse_stmt(self): try: return super().parse_stmt() except SyntaxError as e: self.errors.append(e) self._synchronize() return None # 返回空节点错误恢复是一个困难的问题——恢复后可能产生”级联错误”(一个错误导致后续多个虚假错误)。好的错误恢复应该尽量减少级联错误,并在第一个错误后继续报告有意义的错误。
八、语法分析工具
8.1 主流语法分析工具对比
| 工具 | 分析方法 | 输入语言 | 输出语言 | 特点 |
|---|---|---|---|---|
| yacc/bison | LALR(1) | C | C | 经典工具,POSIX 标准 |
| ANTLR | ALL(*) | 多语言 | Java/Python/C++/Go | 自适应 LL,强大 |
| Lark | Earley/LALR | Python | Python | 简洁易用 |
| Tree-sitter | GLR | C | C | 增量解析,编辑器集成 |
| pest | PEG | Rust | Rust | Rust 生态首选 |
| nom | 组合子 | Rust | Rust | 函数式风格 |
8.2 ANTLR 使用示例
# 安装 ANTLRpip install antlr4-python3-runtime
# 创建语法文件cat > Expr.g4 << 'EOF'grammar Expr;
expr: expr ('*' | '/') expr | expr ('+' | '-') expr | NUMBER | IDENT | '(' expr ')' ;
NUMBER: [0-9]+;IDENT: [a-zA-Z_][a-zA-Z0-9_]*;WS: [ \t\r\n]+ -> skip;EOF
# 生成 Python 解析器antlr4 -Dlanguage=Python3 Expr.g4九、动手实践
9.1 实验一:手写递归下降分析器
# 使用上面的 Parser 类from lexer import Lexer, TokenType
source = "int x = 3 + 4 * 5;"lexer = Lexer(source)tokens = lexer.tokenize()
parser = Parser(tokens)ast = parser.parse_var_decl()print(ast) # VarDecl(type_name='int', name='x', init=BinaryOp(op='+', ...))9.2 实验二:用 Clang 查看 AST
# 查看 Clang 的 AST 输出cat > test.c << 'EOF'int add(int a, int b) { return a + b;}EOF
clang -ast-dump test.c 2>/dev/null9.3 实验三:可视化 AST
import graphviz
def visualize_ast(node, dot=None, counter=[0]): if dot is None: dot = graphviz.Digraph() dot.attr(rankdir='TB')
node_id = f"n{counter[0]}" counter[0] += 1
if isinstance(node, BinaryOp): dot.node(node_id, f"BinaryOp\\n{node.op}") left_id = visualize_ast(node.left, dot, counter) right_id = visualize_ast(node.right, dot, counter) dot.edge(node_id, left_id) dot.edge(node_id, right_id) elif isinstance(node, NumberLit): dot.node(node_id, f"NumberLit\\n{node.value}") elif isinstance(node, Identifier): dot.node(node_id, f"Identifier\\n{node.name}")
return node_id
# 使用ast = BinaryOp('+', BinaryOp('*', NumberLit(4), NumberLit(5)), NumberLit(3))dot = visualize_ast(ast)dot.render('ast', format='png', cleanup=True)十、本章小结
在上一章中,我们看到字符流如何通过正则表达式、NFA、DFA 的转换链被分解为结构化的 Token 流。但 Token 流仍然是线性的——它只告诉编译器”有哪些词”,却不告诉编译器”这些词如何组成句子”。要理解 Token 之间的结构关系,就需要语法分析将它们组织为抽象语法树。
| 概念 | 要点 |
|---|---|
| 语法分析任务 | Token 流 → AST,结构化、验证、构建、报告错误 |
| CFG | 上下文无关文法,描述语言语法的数学工具 |
| 歧义性 | 同一句子多棵解析树,需通过优先级和结合性消除 |
| 递归下降 | 每个非终结符一个函数,直观、灵活、现代编译器首选 |
| LL 分析 | 自顶向下,需消除左递归,LL(1) 最常用 |
| LR 分析 | 自底向上,移进-归约,LALR(1) 最常用 |
| Pratt 解析 | 绑定力度处理优先级,优雅高效 |
| AST 设计 | 节点类型体系 + 访问者模式 |
| 错误恢复 | 恐慌模式最常用,注意级联错误 |
以上就是语法分析的核心内容。下一章进入 语义分析,看看 AST 如何获得类型信息和语义含义。
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






