语法分析核心概念:左递归消除全解析

终结符、非终结符、产生式——从基础概念到左递归消除的完整指南

1. 语法分析基础概念

编译器的前端分为词法分析(Lexical Analysis)和语法分析(Syntax Analysis / Parsing)两个阶段。 词法分析器将源代码字符流转换为 Token 序列,语法分析器则根据文法规则,将 Token 序列组织成语法树(Parse Tree / AST)

1.1 四个核心定义

T

终结符 Terminal

文法中不可再被替换的基本符号。对应词法分析输出的 Token,如关键字 if、标识符、运算符 +、字面量 123 等。在语法树中,终结符总是出现在叶子节点。

if + id num ( )
N

非终结符 Non-Terminal

可以被展开或替换的语法变量,代表语言中的语法结构。如 <expr>(表达式)、<stmt>(语句)。在语法树中,非终结符出现在内部节点,可向下展开为子节点。

E T F S

产生式 Production

描述非终结符替换规则的形式化表示,形如 A → α。左侧必须是非终结符,右侧是由终结符和非终结符组成的符号串。一组产生式共同构成上下文无关文法(CFG)

S

开始符号 Start Symbol

文法中指定的起始非终结符,所有的推导都从此符号开始。例如,完整的程序通常以 <program>S 作为开始符号,一切语法分析从它起头。

1.2 上下文无关文法(CFG)的四元组

一个上下文无关文法 G 可以由四元组 (VT, VN, P, S) 完整表示:

VT 终结符集合:语言的基本符号,对应 Token 集。是语法树中的叶子节点。
VN 非终结符集合:语法的变量符号,表示语法结构。与 VT 不相交(VT ∩ VN = ∅)。
P 产生式集合:形式为 A → α 的规则,其中 A ∈ VN,α ∈ (VT ∪ VN)*
S 开始符号:S ∈ VN,推导的起始非终结符。

例子:经典算术表达式文法 Garith

VT = { +, *, (, ), id }
VN = { E, T, F }
S = E
产生式 P:E → E + T | TT → T * F | FF → ( E ) | id

1.3 产生式的读法

产生式读作含义
E → E + TE 定义为 E 加 T一个表达式可以是"表达式 + 项"
E → TE 定义为 T一个表达式也可以是一个项
F → idF 定义为标识符因子可以是标识符(终结符,不可再展开)
T → T * FT 定义为 T 乘 F项可以是"项 × 因子"
F → ( E )F 定义为 左括号 E 右括号因子可以是带括号的表达式

1.4 语法树推导示意

以输入 id + id * id 为例,展示从开始符号 E 出发的推导过程。点击按钮逐步查看:

E

2. 左递归详解

2.1 什么是左递归?

如果一个非终结符 A 存在推导 A ⇒+(经过一步或多步推导后又出现了自身,且出现在最左侧),则称该文法是左递归的

类型定义示例
直接左递归 产生式形如 A → Aα | β,A 的产生式右部以 A 自身开头 E → E + T | T
间接左递归 经过两步或多步推导后出现:A → Bα, B → Aβ 则 A ⇒ Bα ⇒ Aβα A → BcB → Ad
隐藏左递归 产生式中包含 ε(空串),可能与其它规则组合出左递归 A → Bα, B → ε | Aβ

2.2 为什么左递归是问题?

自顶向下(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))可以处理左递归,甚至左递归还对它们友好(能减少栈深度)。 左递归消除主要服务于自顶向下分析器。但了解左递归的本质对理解所有分析器都至关重要。

2.3 直接左递归的可视化

直接左递归导致无限循环 展示 E → E + T 产生的无限递归路径 E → E + T parse_E() 永远无法消费 任何 Token 消除左递归后 parse_T() 消耗 Token ✓ parse_E_prime() 此时可安全递归 或匹配 ε 终止

3. 消除左递归的算法

3.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 被消耗,不会无限循环。

3.2 示例:消除算术表达式的直接左递归

原始文法(含左递归)

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 规则为例):

  1. 识别模式:E → E + T | T 中,α = + Tβ = T
  2. 重写 E 的产生式:E → T E'(β 置于前)
  3. 引入 E' 处理递归部分:E' → + T E' | ε(α 后接 E',再加上 ε 作为终止条件)
  4. 对 T 同理操作:识别 T → T * F | Fα = * Fβ = F,得到 T → F T'T' → * F T' | ε

3.3 通用算法:消除文法中所有左递归

对于包含间接左递归的文法,需要系统性地消除。下面是标准算法:

输入:无环、无ε-产生式(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 节的方法消除。

3.4 间接左递归消除示例

原始文法(间接左递归)

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):

  1. i=1 (处理 S):j 从 1 到 0(无内循环)。S → Aa | b 无直接左递归。S 处理完毕。
  2. i=2, j=1 (处理 A):将 A → Sd 展开。S 的产生式为 S → Aa | b,因此替换为 A → Aad | bd。此时 A 的产生式变为:A → Ac | Aad | bd | ε
  3. 消除 A 的直接左递归:α₁=c, α₂=ad;β₁=bd, β₂=ε。转换为 A → bd A' | A' 和 A' → c A' | ad A' | ε
完整推导步骤展开

Step 1:排列 非终结符顺序:S → A₁,A → A₂

Step 2:处理 A₁ (S)

  • 原始产生式:S → Aa | b
  • 无直接左递归(不以 S 开头),跳过

Step 3:处理 A₂ (A),j=1

  • 找出 A 中右侧以 S 开头的:A → Sd
  • S 的已有产生式:S → Aa | b
  • 展开:A → Aa d | b d
  • A 全部产生式变为:A → Ac | Aad | bd | ε

Step 4:消除直接左递归

  • A → bd A' | A'(注意 ε 也算一个 β)
  • A' → c A' | ad A' | ε

4. 左递归与 LL(1) 文法的关系

4.1 LL(1) 文法的条件

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就能做出唯一确定的分析决策。

4.2 语法分析器类型对比

分析器类型推导方向能否处理左递归?能力代表算法
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)通常在内部处理或允许左递归文法。

4.3 概念关系图谱

语法分析核心概念关系图 终结符、非终结符、产生式与左递归的关系 产生式 Production A → α (A∈Vn, α∈(Vt∪Vn)*) 文法 = 产生式的集合 非终结符 (Vn) 终结符 (Vt) 左递归 Left Recursion A ⇒⁺ Aα:非终结符推导后在最左侧又出现了自身 直接左递归:A→Aα | 间接左递归:A→Bα, B→Aβ 消除左递归 A → βA' , A' → αA' | ε 将左递归转为右递归 + 引入新非终结符 产生式中 出现 转换 自顶向下分析器的 致命问题 LL(1) 文法的 必要条件

5. 总结与记忆要点

终结符 vs 非终结符

终结符是不可再分的原子符号(叶子),非终结符是可展开的语法变量(内部节点)。判断方法:能在语法树中展开的就是非终结符,不能展开的就是终结符。

产生式 = 替换规则

A → α 表示"A 可以由 α 替换"。左侧只能是非终结符,右侧可以是任意符号串。所有产生式共同构成 CFG 的 P 集合。

左递归 = 自噬

A ⇒⁺ Aα 意味着展开 A 时第一个遇到的就是 A 自己,自顶向下分析器会无限递归。消除的本质是将"先递归再消费"变为"先消费再递归"。

消除左递归口诀

直接左递归:A → βA',A' → αA' | ε。间接左递归:排序 → 代入展开 → 消除直接左递归。目标:让每次递归前都有 Token 被消耗。

常见误区提醒

1. 不是所有递归都是左递归——右递归(A → αA)对自顶向下分析器是安全的。
2. 消除左递归不改变语言——新旧文法生成的语言完全相同,只是语法树形态改变(右结合变为左结合)。
3. ε 产生式也是左递归的潜在来源——消除左递归时需先消除 ε-产生式,否则可能引入新的左递归。
4. 间接左递归容易被忽略——手动检查时要注意 A→Bα 且 B→Aβ 这种跨产生式的递归关系。