Archipelago

Lec 5语法分析 III — First 集与建树(Parsing:Systematic Predictive Parsing)

讲义完成更新于 2026-09-16

Lec 5 语法分析 III — First 集与建树(Parsing:Systematic Predictive Parsing)

本讲:系统化预测分析——derives-ε、First 集、左因子分解、构建语法树/AST

TL;DR

  • derives-ε 和 First 集用不动点 / 约束传播系统计算,让解析器能在产生式以非终结符开头时做出正确选择。
  • 共享前缀会让一个 token 的前看无法区分分支,左因子分解通过提取前缀恢复可预测性。
  • 递归下降过程可以在匹配时直接构造 AST,并将为消左递归而引入的右递归具体树还原成正确的结合性。

1. 回顾与今日目标

自顶向下解析有两个设计选择 + 一个实现关切:

定义(自顶向下解析的三要素)

  • 展开哪个非终结符?→ 最左推导。

  • 选哪条产生式?→ 预测分析。

  • 算法会终止吗?→ 消除左递归。

昨天是快速概览,今天系统化处理预测分析的核心:如何严格地"看一个 token 就选对产生式"。


2. 非终结符开头的产生式

若产生式右部以非终结符开头:NT → NT1 α1、NT → NT2 α2,须根据 NT1/NT2 能生成的首终结符来选;若它们能推出 ε,还要看其后的 α。这就需要两个谓词:derives ε 与 First。

2.1 谁能推出 ε

定义(derives ε 规则)

  • NT→εNT \to \varepsilon ⟹ NT 推出 ε;

  • NT→NT1⋯NTnNT \to NT_1 \cdots NT_n 且所有 NTiNT_i 都推出 ε ⟹ NT 推出 ε。 :::

不动点算法:

对所有 NT: derivesε[NT] = false
对所有 NT → ε: derivesε[NT] = true
while 上轮有变化:
    对所有 NT → NT1…NTn:
        if 所有 NTi 都 derivesε: derivesε[NT] = true

2.2 First 集

T∈First(β)T \in \text{First}(\beta) 当 T 能作为从 β 出发某推导的首符号。规则(T 终结符,NT 非终结符,S 任一符号):

  • T∈First(T)T \in \text{First}(T)

  • First(S)⊆First(Sβ)\text{First}(S) \subseteq \text{First}(S\beta)

  • NT 推出 ε ⟹ First(β)⊆First(NT β)\text{First}(\beta) \subseteq \text{First}(NT\,\beta)

  • NT→SβNT \to S\beta ⟹ First(Sβ)⊆First(NT)\text{First}(S\beta) \subseteq \text{First}(NT) :::

这些规则生成一组子集包含约束,用约束传播迭代到不动点。

文法 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') = {, /}**。

据此,递归下降中 TermPrime() 看到 * 走第一支、/ 走第二支、否则走 ε。


3. 公共前缀 → 左因子分解(Left Factoring)

定理(左因子分解)

若多条产生式右部有相同前缀,前看一个 token 无法区分(如 NT → if then 与 NT → if then else,if 同属两者的 First)。提取公共前缀:

NT  → if then NT'
NT' → else | ε

所有选择被统一到一条产生式,下一 token 是 if 时不再有冲突。

3.1 至此走过的文法变换链

原始(歧义):  Expr → Expr Op Expr | Int,  Op=(*|/)
消歧/优先级: Term → Term*Int | Term/Int | Int
消左递归:    Term → Int Term';  Term' → *Int Term' | /Int Term' | ε

4. 构建语法树 / 直接构建 AST

让每个过程返回它所解析那段串对应的子树,用异常使代码干净。

直接构建 AST 更微妙:TermPrime 构造一棵缺最左子节点的不完整树,返回 (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)

如此可把 2*3*4 的具体树整成期望的左结合 AST。


5. 为何用手写解析器(vs. 生成器)

定理(手写 vs . 生成的权衡)

  • 解析器生成器:靠"改文法"驱动;但若生成器搞不定你的(复杂)文法,几乎无能为力,可能永远跑不通。

  • 手写递归下降:工作量更大,但几乎总能让它工作(出问题就多写代码);单一语言系统,无生成代码集成问题。

若解析器开发时间相对整个项目很小、或语言确实复杂,优先手写递归下降。


6. 本讲小结

  • 处理"非终结符开头"的选择需 derives-ε(不动点)与 First 集(约束传播到不动点)。
  • 公共前缀用左因子分解消除;文法变换链:歧义 → 消歧/优先级 → 消左递归 → 左因子分解。
  • 构建 AST:过程返回子树,TermPrime 用"不完整树 + 回填左子"把右递归整成左结合。
  • 工程上手写递归下降比生成器更可控、风险更低。