终结符、非终结符、产生式——从基础概念到左递归消除的完整指南
编译器的前端分为词法分析(Lexical Analysis)和语法分析(Syntax Analysis / Parsing)两个阶段。 词法分析器将源代码字符流转换为 Token 序列,语法分析器则根据文法规则,将 Token 序列组织成语法树(Parse Tree / AST)。
文法中不可再被替换的基本符号。对应词法分析输出的 Token,如关键字 if、标识符、运算符 +、字面量 123 等。在语法树中,终结符总是出现在叶子节点。
可以被展开或替换的语法变量,代表语言中的语法结构。如 <expr>(表达式)、<stmt>(语句)。在语法树中,非终结符出现在内部节点,可向下展开为子节点。
描述非终结符替换规则的形式化表示,形如 A → α。左侧必须是非终结符,右侧是由终结符和非终结符组成的符号串。一组产生式共同构成上下文无关文法(CFG)。
文法中指定的起始非终结符,所有的推导都从此符号开始。例如,完整的程序通常以 <program> 或 S 作为开始符号,一切语法分析从它起头。
一个上下文无关文法 G 可以由四元组 (VT, VN, P, S) 完整表示:
VT = { +, *, (, ), id }
VN = { E, T, F }
S = E
产生式 P:E → E + T | T;T → T * F | F;F → ( E ) | id
| 产生式 | 读作 | 含义 |
|---|---|---|
E → E + T | E 定义为 E 加 T | 一个表达式可以是"表达式 + 项" |
E → T | E 定义为 T | 一个表达式也可以是一个项 |
F → id | F 定义为标识符 | 因子可以是标识符(终结符,不可再展开) |
T → T * F | T 定义为 T 乘 F | 项可以是"项 × 因子" |
F → ( E ) | F 定义为 左括号 E 右括号 | 因子可以是带括号的表达式 |
以输入 id + id * id 为例,展示从开始符号 E 出发的推导过程。点击按钮逐步查看:
如果一个非终结符 A 存在推导 A ⇒+ Aα(经过一步或多步推导后又出现了自身,且出现在最左侧),则称该文法是左递归的。
| 类型 | 定义 | 示例 |
|---|---|---|
| 直接左递归 | 产生式形如 A → Aα | β,A 的产生式右部以 A 自身开头 |
E → E + T | T |
| 间接左递归 | 经过两步或多步推导后出现:A → Bα, B → Aβ 则 A ⇒ Bα ⇒ Aβα |
A → Bc,B → Ad |
| 隐藏左递归 | 产生式中包含 ε(空串),可能与其它规则组合出左递归 | A → Bα, B → ε | Aβ |
自顶向下(Top-Down)分析器(如递归下降分析器、LL(1) 分析器)遇到左递归文法会陷入无限递归:
E → E + T | T
parse_E():
parse_E() ← 无限递归!
match('+')
parse_T()
parse_E() 第一条指令就调用了自身,且从未消耗任何 Token,直接导致栈溢出。
E → T E'
E' → + T E' | ε
parse_E():
parse_T()
parse_E_prime() ← 先消耗再递归
先调用 parse_T() 消耗 Token,再决定是否进入循环。每次递归前一定有 Token 被消费。
自底向上(Bottom-Up)分析器(如 LR(1)、LALR(1))可以处理左递归,甚至左递归还对它们友好(能减少栈深度)。 左递归消除主要服务于自顶向下分析器。但了解左递归的本质对理解所有分析器都至关重要。
对于形如 A → Aα1 | Aα2 | ... | Aαm | β1 | β2 | ... | βn 的产生式(其中每个 β 不以 A 开头),引入新的非终结符 A',转换为:
A → β1 A' | β2 A' | ... | βn A' A' → α1 A' | α2 A' | ... | αm A' | ε
原始产生式中,α 部分出现在 A 的左侧(A → Aα),导致 parse_A 一开始就递归调用自身。
转换后,β 出现在开头(A → βA'),parse_A 先消费 β 对应的 Token,再调用 A' 对应的函数。
A' 的递归也是"先消费 α 再递归"的模式,因此每次递归前都有 Token 被消耗,不会无限循环。
E → E + T | T T → T * F | F F → ( E ) | id
E → T E' E' → + T E' | ε T → F T' T' → * F T' | ε F → ( E ) | id
推导过程分解(以 E 规则为例):
E → E + T | T 中,α = + T,β = T
E → T E'(β 置于前)
E' → + T E' | ε(α 后接 E',再加上 ε 作为终止条件)
T → T * F | F 中 α = * F,β = F,得到 T → F T' 和 T' → * F T' | ε
对于包含间接左递归的文法,需要系统性地消除。下面是标准算法:
输入:无环、无ε-产生式(A → ε)的文法 G 输出:等价的无左递归文法 G' 1. 将非终结符按任意顺序排列为 A1, A2, ..., An 2. for i = 1 to n: 3. for j = 1 to i - 1: 4. 将每个形如 Ai → Ajγ 的产生式替换为 Ai → δ1γ | δ2γ | ... | δkγ 其中 Aj → δ1 | δ2 | ... | δk 是 Aj 的所有产生式 5. 消除 Ai 产生式中的直接左递归
外层循环(i=1..n):按顺序处理每个非终结符,确保被处理过的非终结符不再出现在后续产生式的"开头位置"。
内层循环(j=1..i-1):将 Ai → Ajγ 展开为 Ai → δγ,其中 δ 是 Aj 已经处理过的右侧——这样 Ai 的右侧就不会以"序号更小"的非终结符开头。
第5步:此时 Ai 可能仍有"Ai → Aiα" 形式的直接左递归,用 3.1 节的方法消除。
S → A a | b A → A c | S d | ε
S → Aa → Sda,存在间接左递归
S → A a | b A → b d A' | A' A'→ c A' | a d A' | ε
推导过程(排序:S = A1,A = A2):
Step 1:排列 非终结符顺序:S → A₁,A → A₂
Step 2:处理 A₁ (S)
Step 3:处理 A₂ (A),j=1
Step 4:消除直接左递归
LL(1) 文法必须不含左递归,这是 LL(1) 的三个必要条件之一:
| # | 条件 | 说明 |
|---|---|---|
| 1 | 无左递归 | 任何非终结符都不能经过推导又出现在自身的最左侧——否则递归下降分析器会无限循环 |
| 2 | 无回溯(FIRST 不相交) | 对于 A → α | β,FIRST(α) ∩ FIRST(β) = ∅——一眼就能确定用哪条产生式 |
| 3 | FOLLOW 条件 | 若 ε ∈ FIRST(α),则 FIRST(α) ∩ FOLLOW(A) = ∅——避免歧义 |
LL(1) 分析器的"LL"含义:Left-to-right(从左到右扫描输入)、Leftmost derivation(最左推导)。
"1" 表示向前看 1 个 Token就能做出唯一确定的分析决策。
| 分析器类型 | 推导方向 | 能否处理左递归? | 能力 | 代表算法 |
|---|---|---|---|---|
| LL(1) | 自顶向下 (Top-Down) | 不能 | 较弱,文法受限 | 递归下降、预测分析表 |
| LL(k), k>1 | 自顶向下 | 不能 | 较 LL(1) 强 | ANTLR (LL(*)) |
| LR(0) | 自底向上 (Bottom-Up) | 可以 | 最弱 LR | 基础移进-归约 |
| SLR(1) | 自底向上 | 可以 | 较 LR(0) 强 | 简单 LR |
| LALR(1) | 自底向上 | 可以 | 实用,状态数少 | Yacc、Bison |
| LR(1) | 自底向上 | 可以 | 最强确定性 LR | 规范 LR |
虽然 LR 类分析器可以处理左递归,但在手写递归下降分析器(如 GCC/Clang 的 C 前端、Go 编译器、Rust 编译器初期)时,仍然需要手动消除左递归。 自动生成的分析器(Yacc/Bison/ANTLR)通常在内部处理或允许左递归文法。
终结符是不可再分的原子符号(叶子),非终结符是可展开的语法变量(内部节点)。判断方法:能在语法树中展开的就是非终结符,不能展开的就是终结符。
A → α 表示"A 可以由 α 替换"。左侧只能是非终结符,右侧可以是任意符号串。所有产生式共同构成 CFG 的 P 集合。
A ⇒⁺ Aα 意味着展开 A 时第一个遇到的就是 A 自己,自顶向下分析器会无限递归。消除的本质是将"先递归再消费"变为"先消费再递归"。
直接左递归:A → βA',A' → αA' | ε。间接左递归:排序 → 代入展开 → 消除直接左递归。目标:让每次递归前都有 Token 被消耗。
1. 不是所有递归都是左递归——右递归(A → αA)对自顶向下分析器是安全的。
2. 消除左递归不改变语言——新旧文法生成的语言完全相同,只是语法树形态改变(右结合变为左结合)。
3. ε 产生式也是左递归的潜在来源——消除左递归时需先消除 ε-产生式,否则可能引入新的左递归。
4. 间接左递归容易被忽略——手动检查时要注意 A→Bα 且 B→Aβ 这种跨产生式的递归关系。