Lec 3自顶向下分析(Top-Down Parsing)
Lec 03 自顶向下分析(Top-Down Parsing)
配套复习课:R2 递归下降分析器 Demo(github.com/6110-sp25/recitation2)——其核心代码已整合进本讲第 8 节 参考:Cooper et al., Ch.3 §3.3 Top-Down Parsing
TL;DR
- 递归下降分析器为每个非终结符编写一个过程,用一个 token 的前看选择产生式并构造最左推导。
- 可预测解析的前提是消除左递归、提取左公因子,并用不动点算法求 derives-ε 与 First 集。
- 解析过程可以直接返回 AST 节点,屏蔽为消歧而引入的具体语法层细节。
1. 定位(Orientation)
语言规范分两层:词法(正则表达式)、语法(文法)。本讲实现语法层的递归下降分析器 (recursive descent parser):
把分析器写成一组相互递归的过程 (mutually recursive procedures),程序结构与文法结构一一对应。
起点假设:词法分析已产出一串 token,每个 token 有类型(对应终结符)和值(读入内容)。例:Int 549(整数 token,值 549)、if(关键字,无值)、AddOp +(加法运算符,值 +)。
2. 基本方法(Basic Approach)
定义(自顶向下分析的三个动作)
从起始符出发,构建一个最左推导 (leftmost derivation):
-
若最左符号是非终结符:选一条产生式并应用(扩展,apply production)。
-
若最左符号是终结符:与输入匹配(match,消耗输入)。
-
所有终结符都匹配成功 ⇒ 接受 (accept) 这次解析。
关键难点:为每个非终结符选对产生式。
分析器实际上生成语法树的先序遍历 (preorder traversal):先访问父节点再访问子节点,兄弟从左到右。
解析用文法示例:
Start → Expr
Expr → Expr + Term Term → Term * Int
Expr → Expr - Term Term → Term / Int
Expr → Term Term → Int
例
例题(无回溯地解析 2-2*2,节选关键步)
句型 已用产生式
Start
Expr Start → Expr
Expr - Term Expr → Expr - Term
Term - Term Expr → Term
Int - Term Term → Int
2 - Term 匹配输入 2 ✓
... 继续展开右侧 Term → Term * Int → Int * Int → 匹配 2 * 2 ✓
2 - 2*2 解析完成!3. 策略问题:如何选产生式?
这是经典的策略与机制分离 (separation of policy and mechanism)。机制是上面三个动作,策略是"选哪条产生式"。
3.1 方案一:回溯(Backtracking)
把它当搜索问题:每个选择点尝试下一个候选;若当前路径明显失败,回退到上一个选择点换一个。搜索三要素:
- 搜索空间:所有语法树
- 搜索算法:解析算法
- 目标:输入程序的语法树
理想性质:若目标存在则(尽快)找到;若不存在则搜索终止。
例
例题(回溯解析 2-2*2 的失败-回退)
先试 Expr → Expr + Term,一路展开到 Int + Term,匹配 2 后下一 token 是 -,无法匹配 + ⇒ 回溯;改用 Expr → Expr - Term,匹配 2 - ... 成功,继续。
3.2 致命问题:左递归(Left Recursion)
定理(左递归 + 自顶向下 = 死循环)
若产生式形如 ,自顶向下展开最左非终结符时会无限展开 Term → Term → Term … 而永不消耗输入,导致无限递归。
对策:解析时改造文法以消除左递归。
4. 消除左递归(Eliminating Left Recursion)
定理(左递归消除的标准变换)
对形如 ( 不以 开头)的产生式,等价改写为引入新非终结符 : 原来"向左长"的树变为"向右长"的树,语言不变但消除了直接左递归。
实例:
原文法片段 新文法片段
Term → Term * Int Term → Int Term'
Term → Term / Int Term' → * Int Term'
Term → Int Term' → / Int Term'
Term' → ε
代价:改变搜索空间的探索方式、消除直接无限递归,但文法不如原来直观;好处是为预测分析铺路。
5. 预测分析(Predictive Parsing)
定义(预测分析)
回溯的替代方案:向前看 (lookahead) 输入流中接下来的 token,据此直接决定应用哪条产生式。本课用一个 token 的前看 (one token of lookahead)。编程语言可被设计得便于这样解析。
预测分析所用(已消左递归的)文法:
Start → Expr
Expr → Term Expr' Term → Int Term'
Expr' → + Term Expr' Term' → * Int Term'
Expr' → - Term Expr' Term' → / Int Term'
Expr' → ε Term' → ε
选择点示例:当前在 Term',有三条候选。看下一 token:是 * 用 Term' → * Int Term';是 / 用 Term' → / Int Term';否则用 Term' → ε。
6. 两个文法改造前提:左因子分解 与 ε 处理
6.1 公共前缀 → 左因子分解(Left Factoring)
若多条产生式右部有相同前缀,前看一个 token 无法区分:
NT → if then
NT → if then else
if ∈ First(if then) 且 if ∈ First(if then else),冲突。提取公共前缀:
NT → if then NT'
NT' → else
NT' → ε
下一 token 是 if 时不再有歧义。
6.2 非终结符开头与 ε 推导
若产生式右部以非终结符开头(NT → NT1 α1 / NT → NT2 α2),需根据 NT1/NT2 能产生的首终结符来选;若它们能推出 ε,还要看后面的 α。这就需要计算两个谓词:derives ε 与 First。
7. 两个核心不动点算法(Fixed-Point Algorithms)
7.1 谁能推出 ε(Derives ε)
定义(NT 推出 ε 的规则)
-
⇒ 推出 。
-
且对所有 , 都推出 ⇒ 推出 。 :::
不动点算法:
对所有非终结符 NT:置 "NT derives ε" = false
对所有形如 NT → ε 的产生式:置 "NT derives ε" = true
while (上一轮有某个 "NT derives ε" 改变):
对所有形如 NT → NT1 … NTn 的产生式:
if (所有 1≤i≤n 的 NTi 都 derives ε):
置 "NT derives ε" = true7.2 First 集合
当且仅当 能作为从 出发某推导的首符号。规则( 终结符, 非终结符, 任一符号, 符号序列):
-
-
-
推出 ⇒
-
⇒ :::
这些规则生成一组子集包含约束,用约束传播 (constraint propagation) 迭代到不动点求解。
例题(求 First(Term'))
文法 Term' → * Int Term' | / Int Term' | ε 生成约束:
First(* Int Term') ⊆ First(Term')
First(/ Int Term') ⊆ First(Term')
First(*) ⊆ First(* Int Term'), * ∈ First(*)
First(/) ⊆ First(/ Int Term'), / ∈ First(/)从空集开始传播到不动点:First(*)={*}, First(/)={/} → First(* Int Term')={*}, First(/ Int Term')={/} → First(Term') = {*, /}。
8. 递归下降分析器 = 预测分析 + 手写代码(R2 Demo 核心)
定义(递归下降分析器的构造规则)
-
每个非终结符 对应一个过程。
-
对产生式 ,过程检查当前输入符号 决定用哪条:若 则应用第 条。
-
应用时:消耗 中的终结符(检查匹配),对其中的非终结符递归调用对应过程。
-
当前输入符号存于全局变量
token;过程返回true(成功)/false(失败)。 :::
对文法 Term → Int Term',Term' → * Int Term' | / Int Term' | ε 的手写代码:
Boolean Term()
if (token = Int n) token = NextToken(); return TermPrime()
else return false
Boolean TermPrime()
if (token = *)
token = NextToken()
if (token = Int n) token = NextToken(); return TermPrime()
else return false
else if (token = /)
token = NextToken()
if (token = Int n) token = NextToken(); return TermPrime()
else return false
else return true // 对应 Term' → ε程序结构与文法结构同构:每个非终结符一个函数,每条产生式一个分支,每个非终结符出现一次递归调用。
9. 构建语法树 / 直接构建 AST
让每个过程返回它所解析那段串对应的子树,用异常 (exceptions) 让代码结构更干净。
直接构建 AST 更微妙:TermPrime 构造的是一棵缺最左子节点 (missing leftmost child) 的不完整树,返回 (root, incomplete),由调用者回填左子节点。
Term()
if (token = Int n)
leftmostInt = token; token = NextToken()
(root, incomplete) = TermPrime()
if (root == NULL) return leftmostInt
incomplete.leftChild = leftmostInt // 回填
return root
else throw SyntaxError
TermPrime()
if (token = * or /)
op = token; next = NextToken()
if (next = Int n)
token = NextToken()
(root, incomplete) = TermPrime()
if (root == NULL)
root = new ExprNode(NULL, op, next); return (root, root)
else
newChild = new ExprNode(NULL, op, next)
incomplete.leftChild = newChild
return (root, newChild)
else throw SyntaxError
else return (NULL, NULL)这样可把右递归的具体结构直接整形成左结合的 AST(如把 2*3*4 的具体树整成期望的左结合抽象树)。
10. 为何用手写分析器?(vs. 解析器生成器)
::: theorem 定理(手写 vs. 生成的权衡)
-
解析器生成器 (parser generator):靠"改文法"驱动;但若生成器搞不定你的文法,你几乎无能为力,复杂文法易超出其"舒适区",可能永远跑不通。
-
手写递归下降:工作量可能更大,但几乎总能让它工作——出问题就多写代码;单一语言系统,无生成代码的集成问题。
结论:若解析器开发时间相对整个项目很小,或语言确实复杂,优先选手写递归下降。
我的理解
消左递归和左因子分解是为了让选择产生式变得可预测,不是要改变程序的抽象结构。因此实现上应在构造 AST 时立即抹去这些为解析服务的临时形状。
11. 预测分析与递归下降小结
- 自顶向下 = 构最左推导,三动作:扩展非终结符 / 匹配终结符 / 接受。
- 朴素回溯遇左递归会死循环 ⇒ 先消左递归(引入
R → αR | ε)。 - 预测分析用一个 token 前看免回溯,前提是左因子分解 + 计算 derives ε 与 First(均用不动点/约束传播)。
- 递归下降分析器 = 预测分析 + 手写:每非终结符一过程,结构与文法同构;可在过程返回值上直接拼 AST。
- 工程上手写递归下降比解析器生成器更可控、风险更低。