想象你是一家中式智慧餐厅的新主厨阿川。后厨有一条食材传送带,上面依次送来洋葱、蒜、鸡丁、花生、花椒——你面前摆着一本厚厚的《语法菜谱》。菜单上的每一道“复合菜品”(比如宫保鸡丁)都对应一张菜谱卡片,卡片上写着“取鸡丁、炒花生、加花椒……”;而洋葱、蒜这些“基础食材”不需要菜谱,直接就能下锅。你的任务,就是对照菜谱,把传送带上的食材一步步加工成标准的菜,最终拼成一桌完整宴席。 这个过程,就是编程世界里的“语法分析”。程序代码就是传送带上的记号流(token),《语法菜谱》就是文法(grammar),复合菜品对应“非终结符”,基础食材对应“终结符”。而“递归下降解析器”就是你这位主厨的执行方式:接到一个复合菜品任务,就翻开对应菜谱卡片;卡片上说“需要另一个复合菜品”,就递归地翻那张卡片,直到所有基础食材都被装盘。 要让流程不出乱子,主厨还需要几件工具:判断菜谱是否“一眼能定”(LL(1) 性质)、改写会死循环的菜谱(左递归消除)、准备“开篇食材清单”(FIRST 集)和“上菜后接续清单”(FOLLOW 集)、以及面对坏食材时的补救舞步(错误恢复)。读完这篇文章,你将亲手写出一台能解析算术表达式、还能给出友好报错的递归下降解析器。

岗位关联

对 AI 从业者而言,语法分析是理解 tokenizer、代码生成与结构化输出校验的底层直觉。掌握手写递归下降解析器,能让你在 prompt 解析、工具调用参数抽取等场景中,不再依赖脆弱的正则表达式。

目录
  1. 可视化演示
  2. 文法与记号
  3. LL(1)性质判断
  4. 左递归消除
  5. FIRST集计算
  6. FOLLOW集计算
  7. 预测分析表
  8. 递归下降实现
  9. 错误恢复策略
  10. AI 应用场景
  11. Python 演示
  12. 常见误区
  13. 面试题

可视化演示

展示递归下降解析器从词法分析到AST构建的完整流程、文法规则到解析函数的映射关系及错误恢复策略,切换模式掌握语法分析核心原理。

本节配有交互动效(进入视口后自动加载,约 3KB)。

递归下降解析流程 词法分析器将源码切分为Token序列,解析器按文法规则逐个消费Token构建AST 每个非终结符对应一个解析函数,函数递归调用实现自顶向下语法分析 parseExpr() -> parseTerm() -> parseFactor() -> match(token) 递归下降是自顶向下语法分析最直观的实现方式

文法与记号

阿川上班第一天,师父递给他一本《语法菜谱》:“凡是我们后厨能做的菜,上面都有配方。”他翻开第一页:宫保鸡丁 := 鸡丁 花生 葱段 干辣椒。鸡丁、花生是基础食材,直接取用;而“宫保鸡丁”是个复合菜品——要照着配方一步步来。

编程语言和菜谱一样,也需要一份“配方说明书”,这就是文法。语法分析器的输入是词法分析器切好的一串记号,输出是一棵语法树;而文法定义了“怎样的记号序列算一句合法程序”。

文法里有两类符号:终结符是不可再拆的最小记号,像数字、加号;非终结符是待展开的语法成分,如 expression、term。每条产生式 A → β 就是一句配方:非终结符 A 可以由 β 组成。用 BNF 表示算术表达式文法如下:

有了这套“菜谱”,阿川就知道每道复合菜怎么拆。但有些菜谱会让人原地打转、有些会让人犹豫不决——他需要先确认这些配方能不能“一眼定夺”,这就是 LL(1) 性质。

E→E+T∣E−T∣T,T→T×F∣F,F→num∣(E)E \to E + T \mid E - T \mid T, \qquad T \to T \times F \mid F, \qquad F \to \text{num} \mid (E)
竖线表示“或者”;E 是表达式,T 是项,F 是因子。这条文法描述了加减乘除和括号的优先级结构。
  1. 非终结符 E代表整个算术表达式的语法成分,它是推导的起点,最终由 T 和运算符组合而成。
    E→E+TE \to E + T
  2. 交替符号竖线表示“或者”,E 既可以是 E+T 也可以是单个 T,解析时二选一,这是分支选择的来源。
    ∣\mid
  3. 非终结符 T代表“项”,是乘除这一层优先级的语法成分,由因子组成。
    T→T×FT \to T \times F
  4. 终结符 num 与括号num 是具体数字记号,不再展开;(E) 表示括号内可以递归地出现任意表达式。
    F→num∣(E)F \to \text{num} \mid (E)

LL(1)性质判断

阿川做了几次菜,发现有些卡片让人抓狂:一张写着“宫保鸡丁:= 宫保鸡丁 花生”——第一步要先做一道宫保鸡丁?他永远做不完。还有一些配方,两种做法都“看起来能开头”,他得拿起食材闻一闻才知道选哪个,效率极低。

自顶向下分析最理想的节奏是:只看传送带上当前这一份食材,就能确定唯一正确的配方,绝不回头。这种“一眼定”的语法叫 LL(1) 文法。第一个 L 表示从左到右扫描输入,第二个 L 表示最左推导,1 表示只需要向前看一个记号。

LL(1) 文法的判定有三条硬性条件:无左递归、同一非终结符的各产生式 FIRST 集互不相交、且每个位置的选择是确定的。用数学语言表达:

阿川点点头:“只要菜谱满足这三条,我闭着眼都能做菜。”但现实是,程序员写出的文法常常带着左递归——下一节就来解决这个“鬼打墙”问题。

文法是 LL(1)  ⟺  无左递归∧∀A→α∣β, FIRST(α)∩FIRST(β)=∅\text{文法是 LL(1)} \iff \text{无左递归} \land \forall A \to \alpha \mid \beta,\ \mathrm{FIRST}(\alpha) \cap \mathrm{FIRST}(\beta) = \varnothing
简单说:没有“自己等自己”的规则,且任何两条候选的开头记号不能撞车,这样看一个记号就能下决定。
  1. 无左递归任何非终结符都不能在最左侧直接或间接地推出自己,否则解析函数会无限调用自身。
    不存在 A⇒+Aα\text{不存在 } A \Rightarrow^{+} A\alpha
  2. FIRST 集不相交同一个非终结符的任意两条候选产生式,开篇记号集合必须没有交集,这样看第一个记号就能选边。
    FIRST(α)∩FIRST(β)=∅\mathrm{FIRST}(\alpha) \cap \mathrm{FIRST}(\beta) = \varnothing
  3. 一个记号决策把上述条件合起来,保证任何时候只需向前看一个记号,就能唯一确定下一步动作。
    1-lookahead 决策唯一\text{1-lookahead 决策唯一}

左递归消除

阿川真的遇到了一张“鬼打墙”菜谱:“拍黄瓜:= 拍黄瓜 蒜末 醋”。按照它,做拍黄瓜得先做拍黄瓜,再做拍黄瓜……他在原地转了三个小时。这种“左侧出现自己”的规则,就是左递归。

左递归在数学上很优雅,可以表达“一个或多个”的重复;但在递归下降解析器里,它会让 parse_A 函数第一行就去调用 parse_A,永远到不了终止条件,最终调用栈爆掉。所以必须先把它改写成等价的、不绕圈子的形式。

消除直接左递归有标准套路:把 A → Aα | β 改写成 β 开头、后面用新非终结符 A' 接住剩余的重复部分。A' 的本质是把“左结合”变成“右递归”,并用 ε 表示“结束了,不再有更多”:

改写完,阿川再按新菜谱做菜:先做 β 这道垫底菜,剩下的 α 部分循环添加——不会再转圈了。聪明的他接着问:每张菜谱到底用哪几种食材开头?这就是 FIRST 集。

A→Aα∣β⟹A→βA′,A′→αA′∣εA \to A\alpha \mid \beta \quad\Longrightarrow\quad A \to \beta A',\qquad A' \to \alpha A' \mid \varepsilon
ε 表示空串,即“什么都不产生”。改写前后两种文法生成的语言完全相同,只是不再有左递归。
  1. 识别直接左递归形如 A → Aα 的产生式,右侧第一个符号就是 A 自己,这是需要消除的目标。
    A→AαA \to A\alpha
  2. 提取非左递归候选把所有不以 A 开头的候选写成 β,作为改写后产生式的开头部分。
    A→βA′A \to \beta A'
  3. 新增非终结符 A'新符号专门承接原来 α 的重复部分,每次出现表示“再来一段 α”,它自身仍能再推出自己。
    A′→αA′A' \to \alpha A'
  4. 空产生式 ε当重复结束时 A' 推导为空串,在代码里表现为“直接返回、不消费任何记号”。
    A′→εA' \to \varepsilon

FIRST集计算

阿川在菜谱旁做笔记:每道菜要准备几份“备用开局”。比如“番茄炒蛋”的第一步可能是“打蛋”,也可能是“切番茄”——但绝不可能是“放盐”。这个“所有可能的第一步”的集合,就是这道菜的 FIRST 集。

FIRST 集回答一个关键问题:如果我正在解析非终结符 A,那么当前记号 a 可能是 A 推导结果的第一个记号吗?如果是,就选中对应产生式。它是手写解析器选分支、填预测分析表的基石。

FIRST 集的计算是迭代式的:先看产生式右侧第一个符号;若是终结符直接加入;若是非终结符则并入它的 FIRST 集;若某符号能推导 ε,还要继续看下一个符号。定义如下:

阿川给每张菜谱都贴上了“开局食材清单”。但他又想到:一道菜做完,传送带上接下来可能送来什么?这个“后续清单”就是 FOLLOW 集,它帮我们处理 ε 产生式的选路问题。

FIRST(X)={a∣X⇒∗aγ}∪{ε∣X⇒∗ε}\mathrm{FIRST}(X) = \{ a \mid X \Rightarrow^{*} a\gamma \} \cup \{ \varepsilon \mid X \Rightarrow^{*} \varepsilon \}
直观理解:FIRST(X) 就是“从 X 出发能推出的所有串的首个终结符”的集合,若 X 还能推出空串,ε 也入集。
  1. 终结符起步若产生式 X → aβ 以终结符 a 开头,那么这个终结符直接属于 FIRST(X)。
    a∈FIRST(X) (X→aβ)a \in \mathrm{FIRST}(X)\ (X \to a\beta)
  2. 非终结符传播若 X → Yβ,则 FIRST(Y) 中除 ε 以外的所有记号都要并入 FIRST(X),因为 Y 开头就是 X 开头。
    FIRST(Y)∖{ε}⊆FIRST(X)\mathrm{FIRST}(Y) \setminus \{\varepsilon\} \subseteq \mathrm{FIRST}(X)
  3. ε 的连锁反应若 Y 能推导 ε,则 X 的开头可能轮到 β,需继续看 β 的首符号,直到某个符号不能推出 ε 为止。
    Y⇒∗ε⇒FIRST(β)⊆FIRST(X)Y \Rightarrow^{*} \varepsilon \Rightarrow \mathrm{FIRST}(\beta) \subseteq \mathrm{FIRST}(X)

FOLLOW集计算

阿川在传送带末端装了一个小摄像头,记录每道菜装盘后紧接着到来的食材。“宫保鸡丁”后面通常跟着下一道菜,可能是米饭、汤,也可能是上菜的“结束铃”(句尾标记)。这些“后面可能出现的记号”,就是 FOLLOW 集。

FOLLOW 集在两种场景至关重要:一是判断 ε 产生式是否该被选择——如果当前记号已经“不属于这道菜”,说明这道菜该收尾了;二是填预测分析表时,ε 产生式要填到 FOLLOW 集的每一列。

FOLLOW 集的定义是:从开始符号出发能推导出的、紧跟在 A 后面的终结符集合。计算时不断扫描所有产生式右侧,把 A 后面的记号并入、再把后续符号能推出的记号传播进来,直到集合不再变化:

有了 FIRST 和 FOLLOW 两张清单,阿川就能整理出最终的“查找索引”——预测分析表。这张表告诉他:此刻这道菜该翻哪张菜谱。

FOLLOW(A)={a∣S⇒∗αAaβ},$∈FOLLOW(S)\mathrm{FOLLOW}(A) = \{ a \mid S \Rightarrow^{*} \alpha A a \beta \},\qquad \$ \in \mathrm{FOLLOW}(S)
$ 是输入结束标记。把 $ 放进开始符号的 FOLLOW 集,相当于告诉主厨“整桌菜上完”也是一种合法的后继。
  1. 开始符号规则把结束标记 $ 放入开始符号 S 的 FOLLOW 集,表示“程序结束”也是一种后继事件。
    $∈FOLLOW(S)\$ \in \mathrm{FOLLOW}(S)
  2. 后继终结符扫描产生式 B → αAβ,若 β 非空,则 FIRST(β) 中除 ε 外都属于 FOLLOW(A),因为 A 后面直接跟着 β。
    FIRST(β)∖{ε}⊆FOLLOW(A)\mathrm{FIRST}(\beta) \setminus \{\varepsilon\} \subseteq \mathrm{FOLLOW}(A)
  3. ε 传播若 β 能推导 ε,那么 B 后面能出现的记号也会紧跟在 A 后面,所以 FOLLOW(B) 并入 FOLLOW(A)。
    β⇒∗ε⇒FOLLOW(B)⊆FOLLOW(A)\beta \Rightarrow^{*} \varepsilon \Rightarrow \mathrm{FOLLOW}(B) \subseteq \mathrm{FOLLOW}(A)

预测分析表

阿川把后厨墙上的所有菜谱整理成一个索引柜:竖列是“当前要做的复合菜品”,横排是“传送带上的当前食材”,柜子里每格放着一张菜谱卡片。他要做的就是:看到横排的食材,抽出竖列对应的卡片。

预测分析表 M[A][a] 正是这样一个二维表:行是非终结符 A,列是终结符 a,格子内容是一个产生式。自顶向下分析时查表即可决定下一步;如果某格子被填了两个产生式,叫冲突,说明文法不是 LL(1)。

填表规则有两条:对每个产生式 A → α,先看 α 的 FIRST 集;若 α 能推导 ε,再用 A 的 FOLLOW 集补填。写成公式:

索引柜装好了。但阿川不想每次做菜都去翻柜子——他决定把每道菜的做法直接背下来,变成“肌肉记忆流程”。这就是把预测分析表翻译成一个个解析函数。

M[A][a]=A→α(a∈FIRST(α))M[A][a] = A \to \alpha \quad (a \in \mathrm{FIRST}(\alpha))
M[A][a]=A→α(ε∈FIRST(α)∧a∈FOLLOW(A))M[A][a] = A \to \alpha \quad (\varepsilon \in \mathrm{FIRST}(\alpha) \land a \in \mathrm{FOLLOW}(A))
第一条规则覆盖“正常开局”,第二条规则覆盖“空串收尾”的边界情况。两条规则把 FIRST 和 FOLLOW 都用了进去。
  1. FIRST 集填表对产生式 A → α,将 α 中每个开篇终结符 a 对应的格子都填入该产生式。
    a∈FIRST(α)⇒M[A][a]=A→αa \in \mathrm{FIRST}(\alpha) \Rightarrow M[A][a] = A \to \alpha
  2. ε 补填若 α 可以推空,则对 FOLLOW(A) 中的每个记号 b 也填入该产生式,表示“该收尾时选择这条路”。
    ε∈FIRST(α)⇒M[A][b]=A→α (b∈FOLLOW(A))\varepsilon \in \mathrm{FIRST}(\alpha) \Rightarrow M[A][b] = A \to \alpha\ (b \in \mathrm{FOLLOW}(A))
  3. 冲突判定若同一格子被填了两次,说明存在二义选择,该文法不是 LL(1),需要改写文法或更换解析方法。
    ∃M[A][a] 中产生式数≥2⇒非 LL(1)\exists M[A][a]\text{ 中产生式数} \ge 2 \Rightarrow \text{非 LL(1)}

递归下降实现

阿川与师父商量后,决定不再每次查柜子,而是把每道复合菜的做法写成一份“操作脚本”:E 有 E 的脚本,T 有 T 的脚本,脚本里需要别的菜就喊一声对应的脚本。整个后厨就像一个互相调用的函数家族。

递归下降解析器的核心思想正是:每个非终结符对应一个解析函数,函数体按产生式右部逐项匹配——遇到终结符就检查并吃掉当前记号,遇到非终结符就调用对应的函数。它把“查表选产生式”变成了“函数体里的 if/while 结构”。

以算术表达式为例,E 的脚本是“先执行 T 脚本,然后只要看到 + 或 - 就吃掉它再执行一次 T 脚本”。T 的脚本同理处理 * 和 /,F 的脚本则处理数字与括号。伪代码如下:

当 E、T、F 这些函数写完并串起来,阿川的“肌肉记忆”就成型了:传送带上的记号被一个个吃掉,函数调用栈自动画出语法树。下一步要解决的是坏食材问题——错误恢复。

E()≡T(); while lookahead∈{+,−}: consume(); T()E() \equiv T();\ \text{while lookahead} \in \{+, -\}:\ \text{consume}();\ T()
T()≡F(); while lookahead∈{×,/}: consume(); F()T() \equiv F();\ \text{while lookahead} \in \{\times, /\}:\ \text{consume}();\ F()
while 循环正是消除左递归后 A' → αA' | ε 的代码形态:条件成立就再来一遍,不成立就“空串收尾”。
  1. 先调用下级函数E() 的第一行调用 T(),因为加减优先级低于乘除,必须先处理更内层的项。
    T()T()
  2. 循环吃操作符while 检查前瞻记号,只要还是 + 或 -,说明这一层还没结束,要继续拼接。
    while lookahead∈{+,−}\text{while lookahead} \in \{+, -\}
  3. 吃掉记号consume() 推进记号流,把当前操作符丢弃,让 lookahead 指向下一个记号。
    consume()\text{consume}()
  4. 再调用下级函数吃掉操作符后递归地解析右操作数,然后回到第 2 步继续检查,直到没有更多操作符。
    T()T()

错误恢复策略

意外来了:传送带上混进一片西瓜皮。阿川按照 F 的脚本期待一个数字,结果手里是个"?"。他不能一摔铲子罢工——否则整桌宴席都完了。他要做的是:跳过西瓜皮,回到能继续做菜的节奏。

错误恢复让解析器在遇到语法错误后还能继续工作,一次性报告多个错误。最简单有效的是 panic 模式:解析函数发现记号对不上时,就丢弃记号,直到遇到“同步记号”——通常是当前非终结符的 FOLLOW 集中的记号或结束符 $。

同步记号选得好,错误报告就友好:报错位置准确、后续错误不连环爆炸。例如解析括号表达式时缺少 ),因子函数报错后,应跳过直到看到 ) 或 $ 等 FOLLOW 集中的记号再继续。伪代码如下:

靠着这套“跳过 + 报告 + 继续”的舞步,阿川就算遇到一筐坏食材也能把宴席撑到底。至此,他集齐了菜谱、索引、肌肉记忆和应急舞步——一台完整的递归下降解析器诞生了。

panic(A):while lookahead∉FOLLOW(A)∪{$}: advance()\text{panic}(A):\quad \text{while lookahead} \notin \mathrm{FOLLOW}(A) \cup \{ \$ \}:\ \text{advance}()
同步集是“重新对齐”的目标:只把垃圾记号丢到结构边界为止,而不是漫无目的地乱跳。
  1. 确定同步集当前非终结符 A 的 FOLLOW 集并上结束符 $,构成“重新对齐”的目标集合。
    FOLLOW(A)∪{$}\mathrm{FOLLOW}(A) \cup \{ \$ \}
  2. 循环跳错只要当前记号不在同步集里,就持续推进记号流,丢弃一切不匹配的垃圾记号。
    advance()\text{advance}()
  3. 回到正轨遇到同步记号后返回调用者,由上层函数继续正常解析,避免一个错误引发连锁误报。
    重新进入主流程\text{重新进入主流程}

AI 应用场景

模型配置文件的“配方校验器”

阶段:数据接入与解析(结构化配置读取)

解决的问题:MLOps 平台的模型配置文件常包含嵌套字段、条件块和列表,正则表达式难以处理嵌套括号;配置一旦写错,下游训练直接崩溃。使用递归下降解析器能按文法精确解析,并给出“第 3 行缺少 ')'”这样的友好报错。

具体案例:某团队用自研 DSL 描述模型超参数搜索空间,如 search(batch in [32,64], lr in 1e-3..1e-1)。他们手写递归下降解析器,把文本解析为超参数树,再交给自动调参框架生成训练任务,解析失败时能精确指出第几行第几个符号出错。

大模型工具调用的结构化输出校验

阶段:AI Agent 的 tool call 参数解析

解决的问题:LLM 输出的函数参数偶发多一个逗号、少一个括号,直接交给 eval 有安全风险,用正则补丁永远补不全。递归下降解析器把“必须符合语法”变成“解析失败即可重试”,还能报告具体错误位置。

具体案例:Agent 被要求输出 weather(city="北京") 这样的 DSL 命令。解析器逐项检查前瞻记号,失败时把错误信息回传给模型,引导它自我纠正后重新生成,大幅提升工具调用的成功率。

Python 演示

python81 行
#!/usr/bin/env python3"""递归下降解析器 - 算术表达式求值演示 文法(已消除左递归, 等价于 E->T{(+|-)T}, T->F{(*|/)F}):E  -> T {(+|-) T}T  -> F {(*|/) F}F  -> 数字 | '(' E ')'"""  def tokenize(s):    out = []    i = 0    while i < len(s):        ch = s[i]        if ch.isspace():            i += 1        elif ch.isdigit():            j = i            while j < len(s) and (s[j].isdigit() or s[j] == '.'):                j += 1            out.append(s[i:j])            i = j        else:            out.append(ch)            i += 1    out.append('$')    return out  class Parser:    def __init__(self, toks):        self.toks = toks        self.pos = 0     def peek(self):        return self.toks[self.pos]     def consume(self):        t = self.toks[self.pos]        self.pos += 1        return t     def expect(self, ch):        if self.peek() != ch:            raise SyntaxError("期望 %s, 实际 %s" % (ch, self.peek()))        return self.consume()     def parse_E(self):        val = self.parse_T()        while self.peek() in ('+', '-'):            op = self.consume()            r = self.parse_T()            val = val + r if op == '+' else val - r        return val     def parse_T(self):        val = self.parse_F()        while self.peek() in ('*', '/'):            op = self.consume()            r = self.parse_F()            val = val * r if op == '*' else val / r        return val     def parse_F(self):        if self.peek() == '(':            self.consume()            val = self.parse_E()            self.expect(')')            return val        return float(self.consume())  def main():    s = input("输入表达式, 如 2+3*(4-1): ")    parser = Parser(tokenize(s))    print("求值结果:", parser.parse_E())  if __name__ == '__main__':    main()
目的:完整实现了一个递归下降解析器,能解析并求值带括号与四则运算的算术表达式,对应正文的 E/T/F 三层文法和 LL(1) 分支选择。
  1. out.append('$')给记号流末尾加哨兵,让递归函数有“输入已结束”的边界感知。
  2. def peek(self):“只看不吃”的向前看操作,正是 LL(1) 中"1"的代码体现。
  3. def expect(self, ch):吃掉期望记号的封装,记号和预期不符时抛出带上下文的异常。
  4. while self.peek() in ('+', '-'):用 while 循环实现“零个或多个”,这是左递归消除后 A' 的落地形态。
  5. r = self.parse_T()递归下降的核心动作:调用下一层函数,调用栈向下生长出语法树。
  6. if self.peek() == '(':用前瞻记号在 F 的两个候选分支间做 LL(1) 决策。
  7. return float(self.consume())终结符动作:吃掉数字记号并转为数值,相当于“基础食材入锅”。

常见误区

误区“我还没有算出 FIRST 集和 FOLLOW 集,不敢开始写代码,怕选错分支、导致解析全乱。”

正解手写递归下降时,分支选择靠“观察”就足够:看产生式右侧开头是什么记号,直接用字符串比较即可。FIRST/FOLLOW 是填预测分析表和自动生成工具所需的严谨计算,不是手写解析器的必经门槛。阿川是“看着菜谱直接做”,不等于他必须先把索引柜建好。

为何常见:教材通常按“算集合 → 填表 → 表驱动”的路线讲 LL(1),让人误以为所有解析器都要先建表才能动手。

误区“我的文法是 E → E+T,我已经改成 E → T {+ T} 了,肯定没有左递归了。”

正解还有间接左递归:例如 S → A b、A → S c,S 通过 A 绕一圈又回到自己。消除时要画非终结符之间的依赖图,把所有能绕回自身的路径一并改写,只改显式形式会漏。

为何常见:初学者只识别“第一行就是自己”的直接形式,忽略了多步推导同样能让函数无限自调用。

误区“我的文法里有 A' → ε,这一行在 Python 里怎么写?是不是要 `return None`?”

正解ε 产生式的代码就是“什么都不做、直接返回”,常常对应 while 循环条件不成立就自然退出。比如 parse_E 里 while 判断 peek 不再是 + 或 - 就结束,这正是 A' → ε 的运行时表现——ε 表示“缺席”,不是一个空对象。

为何常见:把语法符号与运行时动作一一对应,忘了 ε 描述的是“没有更多输入”这件事本身。

误区“panic 模式就是遇到错误时跳到任意一个看起来顺眼的记号继续。”

正解panic 模式必须选对同步集——通常是当前非终结符的 FOLLOW 集再并上 $。跳错方向会把有效记号当垃圾丢掉,引发连锁报错。同步记号的意义是在“结构边界”重新对齐,而不是随便对齐;就像阿川跳过西瓜皮,但不能把旁边的鸡丁也扔了。

为何常见:只记住“跳过”两个字,没理解同步集的选择逻辑,导致恢复后错得更厉害。

误区“手写解析器性能差,生产环境还是得用 yacc/LALR 生成器。”

正解只要文法是 LL(1),递归下降是线性时间——每个记号恰好被消费一次,开销极小;且报错可控、代码可读、易于嵌入语义动作。工业界大量真实编译器与 SQL 引擎的前端都采用递归下降,它是生产级选择。

为何常见:把“手写”与“低效”划等号,忽略了线性扫描的本质成本。

面试题

Q1. 递归下降解析器中“递归”主要体现在哪里?

单选题 · easy

  • A.语法树被递归遍历打印
  • B.非终结符对应的函数相互调用,包括直接或间接调用自身← 正确答案
  • C.记号流在 while 循环里反复扫描
  • D.内存被递归地动态分配

答案:B。“下降”指从高层非终结符(如 E)调用低层非终结符(如 T、F),“递归”指这些函数会直接或间接地调用自己。正因为调用栈随嵌套深度生长,它才叫递归下降。while 循环只负责重复,不是递归本体。

Q2. 在文法 E → E + T | T 中,下列哪个是终结符?

单选题 · easy

  • A.E
  • B.T
  • C.num← 正确答案
  • D.expr

答案:C。终结符是不可再拆的基础记号,比如数字 num、加号 +。E、T、expr 都是非终结符,需要在产生式左侧展开。类比菜谱:num 是鸡丁这种基础食材,E 是“宫保鸡丁”这种复合菜品。

Q3. 消除直接左递归 A → Aα | β 后,正确的等价文法是?

单选题 · mid

  • A.A → αA' | βA'
  • B.A → βA',且 A' → αA' | ε← 正确答案
  • C.A → A'β,且 A' → αA'
  • D.A → αA | β

答案:B。标准套路是提取不以 A 开头的候选 β 作为开头,再用新非终结符 A' 承接重复的 α。A' → αA' | ε 中 ε 表示重复结束。改写前后的语言相同,但不再有“第一行就调用自己”的死循环。

Q4. LL(1) 文法的必要判定条件包括哪些?

多选题 · mid

  • A.无直接或间接左递归← 正确答案
  • B.同一非终结符的各产生式 FIRST 集互不相交← 正确答案
  • C.文法中不允许出现任何终结符
  • D.文法的每个非终结符都只对应一个产生式

答案:AB。LL(1) 要求无左递归且候选产生式之间 FIRST 集不冲突,这样向前看一个记号就能唯一决策。C 显然是错的,终结符是句子基础;D 太绝对,一个非终结符可以有多个产生式,只要它们互不冲突。

Q5. 关于 FIRST 集,下列说法正确的是?

单选题 · mid

  • A.FIRST(A) 是 A 后面可能出现的终结符集合
  • B.FIRST(A) 是 A 能推导出的所有串的首个终结符的集合← 正确答案
  • C.FIRST(A) 只包含非终结符
  • D.FIRST(A) 由词法分析器直接给出

答案:B。FIRST 集是“从 A 出发能推出的串的开头终结符”集合,它为分支选择服务。选项 A 描述的是 FOLLOW 集;C 错在集合元素是终结符;D 错在它由文法计算而来,不是词法产物。

Q6. 对开始符号 S,其 FOLLOW 集中一定包含哪个特殊记号?

单选题 · easy

  • A.$← 正确答案
  • B.ε
  • C.S 本身
  • D.所有终结符

答案:A。约定把输入结束标记 $ 放入开始符号的 FOLLOW 集,表示“整段程序结束”也是合法的后继事件。这个约定是 FOLLOW 集计算的第一步规则,也是表中 ε 产生式能正确收尾的基石。

Q7. 填预测分析表时,若 ε ∈ FIRST(α),则产生式 A → α 需要填入 M[A][b] 的条件是?

多选题 · mid

  • A.b ∈ FIRST(α)
  • B.b ∈ FOLLOW(A)← 正确答案
  • C.b 可以是 $(当 $ ∈ FOLLOW(A))← 正确答案
  • D.b 必须等于 α 的最后一个符号

答案:BC。ε 在 FIRST(α) 表示 α 可能为空,此时“选择这条路”等价于“让 A 空串收尾”,所以凡是在 FOLLOW(A) 里的记号(包括 $)都应填入。选项 A 是第一条填表规则的范畴;D 与规则无关。

Q8. 输入 100000 层嵌套括号 ((((…1…)))) 时,手写递归下降解析器最可能发生什么?

单选题 · extreme

  • A.解析结果静默错误
  • B.调用栈溢出← 正确答案
  • C.陷入死循环
  • D.自动切换为迭代解析

答案:B。每层括号都会触发 parse_F → parse_E → parse_T → parse_F 的递归,深度与嵌套成正比。极深嵌套会耗尽调用栈导致栈溢出。这是递归下降基于调用栈的固有代价,生产代码常设深度上限。左递归未消除才会死循环,此处文法本身没问题。

Q9. 手写递归下降解析 parse_T 处理 2*3*4 这种连续乘法的经典结构是?

单选题 · mid

  • A.if 语句判断一次
  • B.while 循环反复匹配 * 和因子← 正确答案
  • C.无条件递归调用 parse_T 自身
  • D.goto 跳回函数开头

答案:B。while 循环对应文法 T → F {(* F)} 中花括号的“零个或多个”含义,也正对应消除左递归后 A' → αA' | ε 的代码形态。if 只能处理一次;C 在未消除左递归时才会出现;D 不是结构化写法。

Q10. 下列哪些属于递归下降解析器的优点?

多选题 · hard

  • A.每个非终结符对应一个函数,代码与文法一一对应,可读性强← 正确答案
  • B.可以手工控制错误报错信息,报错友好← 正确答案
  • C.天然支持任意左递归文法,无需预处理
  • D.容易在解析过程中嵌入语义动作(如求值、建 AST)← 正确答案

答案:ABD。递归下降把文法结构直接映射为函数结构,天然可读、可定制报错、可顺手加入求值等语义动作,这是它被工业界广泛采用的原因。C 错:递归下降无法处理左递归,必须先消除,否则函数无限自调用。