想象你是一家中式智慧餐厅的新主厨阿川。后厨有一条食材传送带,上面依次送来洋葱、蒜、鸡丁、花生、花椒——你面前摆着一本厚厚的《语法菜谱》。菜单上的每一道“复合菜品”(比如宫保鸡丁)都对应一张菜谱卡片,卡片上写着“取鸡丁、炒花生、加花椒……”;而洋葱、蒜这些“基础食材”不需要菜谱,直接就能下锅。你的任务,就是对照菜谱,把传送带上的食材一步步加工成标准的菜,最终拼成一桌完整宴席。 这个过程,就是编程世界里的“语法分析”。程序代码就是传送带上的记号流(token),《语法菜谱》就是文法(grammar),复合菜品对应“非终结符”,基础食材对应“终结符”。而“递归下降解析器”就是你这位主厨的执行方式:接到一个复合菜品任务,就翻开对应菜谱卡片;卡片上说“需要另一个复合菜品”,就递归地翻那张卡片,直到所有基础食材都被装盘。 要让流程不出乱子,主厨还需要几件工具:判断菜谱是否“一眼能定”(LL(1) 性质)、改写会死循环的菜谱(左递归消除)、准备“开篇食材清单”(FIRST 集)和“上菜后接续清单”(FOLLOW 集)、以及面对坏食材时的补救舞步(错误恢复)。读完这篇文章,你将亲手写出一台能解析算术表达式、还能给出友好报错的递归下降解析器。
对 AI 从业者而言,语法分析是理解 tokenizer、代码生成与结构化输出校验的底层直觉。掌握手写递归下降解析器,能让你在 prompt 解析、工具调用参数抽取等场景中,不再依赖脆弱的正则表达式。
可视化演示
展示递归下降解析器从词法分析到AST构建的完整流程、文法规则到解析函数的映射关系及错误恢复策略,切换模式掌握语法分析核心原理。
本节配有交互动效(进入视口后自动加载,约 3KB)。
递归下降解析流程 词法分析器将源码切分为Token序列,解析器按文法规则逐个消费Token构建AST 每个非终结符对应一个解析函数,函数递归调用实现自顶向下语法分析 parseExpr() -> parseTerm() -> parseFactor() -> match(token) 递归下降是自顶向下语法分析最直观的实现方式
文法与记号
阿川上班第一天,师父递给他一本《语法菜谱》:“凡是我们后厨能做的菜,上面都有配方。”他翻开第一页:宫保鸡丁 := 鸡丁 花生 葱段 干辣椒。鸡丁、花生是基础食材,直接取用;而“宫保鸡丁”是个复合菜品——要照着配方一步步来。
编程语言和菜谱一样,也需要一份“配方说明书”,这就是文法。语法分析器的输入是词法分析器切好的一串记号,输出是一棵语法树;而文法定义了“怎样的记号序列算一句合法程序”。
文法里有两类符号:终结符是不可再拆的最小记号,像数字、加号;非终结符是待展开的语法成分,如 expression、term。每条产生式 A → β 就是一句配方:非终结符 A 可以由 β 组成。用 BNF 表示算术表达式文法如下:
有了这套“菜谱”,阿川就知道每道复合菜怎么拆。但有些菜谱会让人原地打转、有些会让人犹豫不决——他需要先确认这些配方能不能“一眼定夺”,这就是 LL(1) 性质。
- 非终结符 E代表整个算术表达式的语法成分,它是推导的起点,最终由 T 和运算符组合而成。
- 交替符号竖线表示“或者”,E 既可以是
E+T也可以是单个T,解析时二选一,这是分支选择的来源。 - 非终结符 T代表“项”,是乘除这一层优先级的语法成分,由因子组成。
- 终结符 num 与括号num 是具体数字记号,不再展开;
(E)表示括号内可以递归地出现任意表达式。
LL(1)性质判断
阿川做了几次菜,发现有些卡片让人抓狂:一张写着“宫保鸡丁:= 宫保鸡丁 花生”——第一步要先做一道宫保鸡丁?他永远做不完。还有一些配方,两种做法都“看起来能开头”,他得拿起食材闻一闻才知道选哪个,效率极低。
自顶向下分析最理想的节奏是:只看传送带上当前这一份食材,就能确定唯一正确的配方,绝不回头。这种“一眼定”的语法叫 LL(1) 文法。第一个 L 表示从左到右扫描输入,第二个 L 表示最左推导,1 表示只需要向前看一个记号。
LL(1) 文法的判定有三条硬性条件:无左递归、同一非终结符的各产生式 FIRST 集互不相交、且每个位置的选择是确定的。用数学语言表达:
阿川点点头:“只要菜谱满足这三条,我闭着眼都能做菜。”但现实是,程序员写出的文法常常带着左递归——下一节就来解决这个“鬼打墙”问题。
- 无左递归任何非终结符都不能在最左侧直接或间接地推出自己,否则解析函数会无限调用自身。
- FIRST 集不相交同一个非终结符的任意两条候选产生式,开篇记号集合必须没有交集,这样看第一个记号就能选边。
- 一个记号决策把上述条件合起来,保证任何时候只需向前看一个记号,就能唯一确定下一步动作。
左递归消除
阿川真的遇到了一张“鬼打墙”菜谱:“拍黄瓜:= 拍黄瓜 蒜末 醋”。按照它,做拍黄瓜得先做拍黄瓜,再做拍黄瓜……他在原地转了三个小时。这种“左侧出现自己”的规则,就是左递归。
左递归在数学上很优雅,可以表达“一个或多个”的重复;但在递归下降解析器里,它会让 parse_A 函数第一行就去调用 parse_A,永远到不了终止条件,最终调用栈爆掉。所以必须先把它改写成等价的、不绕圈子的形式。
消除直接左递归有标准套路:把 A → Aα | β 改写成 β 开头、后面用新非终结符 A' 接住剩余的重复部分。A' 的本质是把“左结合”变成“右递归”,并用 ε 表示“结束了,不再有更多”:
改写完,阿川再按新菜谱做菜:先做 β 这道垫底菜,剩下的 α 部分循环添加——不会再转圈了。聪明的他接着问:每张菜谱到底用哪几种食材开头?这就是 FIRST 集。
- 识别直接左递归形如
A → Aα的产生式,右侧第一个符号就是 A 自己,这是需要消除的目标。 - 提取非左递归候选把所有不以 A 开头的候选写成 β,作为改写后产生式的开头部分。
- 新增非终结符 A'新符号专门承接原来 α 的重复部分,每次出现表示“再来一段 α”,它自身仍能再推出自己。
- 空产生式 ε当重复结束时 A' 推导为空串,在代码里表现为“直接返回、不消费任何记号”。
FIRST集计算
阿川在菜谱旁做笔记:每道菜要准备几份“备用开局”。比如“番茄炒蛋”的第一步可能是“打蛋”,也可能是“切番茄”——但绝不可能是“放盐”。这个“所有可能的第一步”的集合,就是这道菜的 FIRST 集。
FIRST 集回答一个关键问题:如果我正在解析非终结符 A,那么当前记号 a 可能是 A 推导结果的第一个记号吗?如果是,就选中对应产生式。它是手写解析器选分支、填预测分析表的基石。
FIRST 集的计算是迭代式的:先看产生式右侧第一个符号;若是终结符直接加入;若是非终结符则并入它的 FIRST 集;若某符号能推导 ε,还要继续看下一个符号。定义如下:
阿川给每张菜谱都贴上了“开局食材清单”。但他又想到:一道菜做完,传送带上接下来可能送来什么?这个“后续清单”就是 FOLLOW 集,它帮我们处理 ε 产生式的选路问题。
- 终结符起步若产生式
X → aβ以终结符 a 开头,那么这个终结符直接属于 FIRST(X)。 - 非终结符传播若
X → Yβ,则 FIRST(Y) 中除 ε 以外的所有记号都要并入 FIRST(X),因为 Y 开头就是 X 开头。 - ε 的连锁反应若 Y 能推导 ε,则 X 的开头可能轮到 β,需继续看 β 的首符号,直到某个符号不能推出 ε 为止。
FOLLOW集计算
阿川在传送带末端装了一个小摄像头,记录每道菜装盘后紧接着到来的食材。“宫保鸡丁”后面通常跟着下一道菜,可能是米饭、汤,也可能是上菜的“结束铃”(句尾标记)。这些“后面可能出现的记号”,就是 FOLLOW 集。
FOLLOW 集在两种场景至关重要:一是判断 ε 产生式是否该被选择——如果当前记号已经“不属于这道菜”,说明这道菜该收尾了;二是填预测分析表时,ε 产生式要填到 FOLLOW 集的每一列。
FOLLOW 集的定义是:从开始符号出发能推导出的、紧跟在 A 后面的终结符集合。计算时不断扫描所有产生式右侧,把 A 后面的记号并入、再把后续符号能推出的记号传播进来,直到集合不再变化:
有了 FIRST 和 FOLLOW 两张清单,阿川就能整理出最终的“查找索引”——预测分析表。这张表告诉他:此刻这道菜该翻哪张菜谱。
- 开始符号规则把结束标记 $ 放入开始符号 S 的 FOLLOW 集,表示“程序结束”也是一种后继事件。
- 后继终结符扫描产生式
B → αAβ,若 β 非空,则 FIRST(β) 中除 ε 外都属于 FOLLOW(A),因为 A 后面直接跟着 β。 - ε 传播若 β 能推导 ε,那么 B 后面能出现的记号也会紧跟在 A 后面,所以 FOLLOW(B) 并入 FOLLOW(A)。
预测分析表
阿川把后厨墙上的所有菜谱整理成一个索引柜:竖列是“当前要做的复合菜品”,横排是“传送带上的当前食材”,柜子里每格放着一张菜谱卡片。他要做的就是:看到横排的食材,抽出竖列对应的卡片。
预测分析表 M[A][a] 正是这样一个二维表:行是非终结符 A,列是终结符 a,格子内容是一个产生式。自顶向下分析时查表即可决定下一步;如果某格子被填了两个产生式,叫冲突,说明文法不是 LL(1)。
填表规则有两条:对每个产生式 A → α,先看 α 的 FIRST 集;若 α 能推导 ε,再用 A 的 FOLLOW 集补填。写成公式:
索引柜装好了。但阿川不想每次做菜都去翻柜子——他决定把每道菜的做法直接背下来,变成“肌肉记忆流程”。这就是把预测分析表翻译成一个个解析函数。
- FIRST 集填表对产生式
A → α,将 α 中每个开篇终结符 a 对应的格子都填入该产生式。 - ε 补填若 α 可以推空,则对 FOLLOW(A) 中的每个记号 b 也填入该产生式,表示“该收尾时选择这条路”。
- 冲突判定若同一格子被填了两次,说明存在二义选择,该文法不是 LL(1),需要改写文法或更换解析方法。
递归下降实现
阿川与师父商量后,决定不再每次查柜子,而是把每道复合菜的做法写成一份“操作脚本”:E 有 E 的脚本,T 有 T 的脚本,脚本里需要别的菜就喊一声对应的脚本。整个后厨就像一个互相调用的函数家族。
递归下降解析器的核心思想正是:每个非终结符对应一个解析函数,函数体按产生式右部逐项匹配——遇到终结符就检查并吃掉当前记号,遇到非终结符就调用对应的函数。它把“查表选产生式”变成了“函数体里的 if/while 结构”。
以算术表达式为例,E 的脚本是“先执行 T 脚本,然后只要看到 + 或 - 就吃掉它再执行一次 T 脚本”。T 的脚本同理处理 * 和 /,F 的脚本则处理数字与括号。伪代码如下:
当 E、T、F 这些函数写完并串起来,阿川的“肌肉记忆”就成型了:传送带上的记号被一个个吃掉,函数调用栈自动画出语法树。下一步要解决的是坏食材问题——错误恢复。
- 先调用下级函数
E()的第一行调用T(),因为加减优先级低于乘除,必须先处理更内层的项。 - 循环吃操作符while 检查前瞻记号,只要还是 + 或 -,说明这一层还没结束,要继续拼接。
- 吃掉记号
consume()推进记号流,把当前操作符丢弃,让 lookahead 指向下一个记号。 - 再调用下级函数吃掉操作符后递归地解析右操作数,然后回到第 2 步继续检查,直到没有更多操作符。
错误恢复策略
意外来了:传送带上混进一片西瓜皮。阿川按照 F 的脚本期待一个数字,结果手里是个"?"。他不能一摔铲子罢工——否则整桌宴席都完了。他要做的是:跳过西瓜皮,回到能继续做菜的节奏。
错误恢复让解析器在遇到语法错误后还能继续工作,一次性报告多个错误。最简单有效的是 panic 模式:解析函数发现记号对不上时,就丢弃记号,直到遇到“同步记号”——通常是当前非终结符的 FOLLOW 集中的记号或结束符 $。
同步记号选得好,错误报告就友好:报错位置准确、后续错误不连环爆炸。例如解析括号表达式时缺少 ),因子函数报错后,应跳过直到看到 ) 或 $ 等 FOLLOW 集中的记号再继续。伪代码如下:
靠着这套“跳过 + 报告 + 继续”的舞步,阿川就算遇到一筐坏食材也能把宴席撑到底。至此,他集齐了菜谱、索引、肌肉记忆和应急舞步——一台完整的递归下降解析器诞生了。
- 确定同步集当前非终结符 A 的 FOLLOW 集并上结束符 $,构成“重新对齐”的目标集合。
- 循环跳错只要当前记号不在同步集里,就持续推进记号流,丢弃一切不匹配的垃圾记号。
- 回到正轨遇到同步记号后返回调用者,由上层函数继续正常解析,避免一个错误引发连锁误报。
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 演示
#!/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()
out.append('$')给记号流末尾加哨兵,让递归函数有“输入已结束”的边界感知。def peek(self):“只看不吃”的向前看操作,正是 LL(1) 中"1"的代码体现。def expect(self, ch):吃掉期望记号的封装,记号和预期不符时抛出带上下文的异常。while self.peek() in ('+', '-'):用 while 循环实现“零个或多个”,这是左递归消除后 A' 的落地形态。r = self.parse_T()递归下降的核心动作:调用下一层函数,调用栈向下生长出语法树。if self.peek() == '(':用前瞻记号在 F 的两个候选分支间做 LL(1) 决策。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. 递归下降解析器中“递归”主要体现在哪里?
- A.语法树被递归遍历打印
- B.非终结符对应的函数相互调用,包括直接或间接调用自身← 正确答案
- C.记号流在 while 循环里反复扫描
- D.内存被递归地动态分配
答案:B。“下降”指从高层非终结符(如 E)调用低层非终结符(如 T、F),“递归”指这些函数会直接或间接地调用自己。正因为调用栈随嵌套深度生长,它才叫递归下降。while 循环只负责重复,不是递归本体。
Q2. 在文法 E → E + T | T 中,下列哪个是终结符?
- A.E
- B.T
- C.num← 正确答案
- D.expr
答案:C。终结符是不可再拆的基础记号,比如数字 num、加号 +。E、T、expr 都是非终结符,需要在产生式左侧展开。类比菜谱:num 是鸡丁这种基础食材,E 是“宫保鸡丁”这种复合菜品。
Q3. 消除直接左递归 A → Aα | β 后,正确的等价文法是?
- 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) 文法的必要判定条件包括哪些?
- A.无直接或间接左递归← 正确答案
- B.同一非终结符的各产生式 FIRST 集互不相交← 正确答案
- C.文法中不允许出现任何终结符
- D.文法的每个非终结符都只对应一个产生式
答案:AB。LL(1) 要求无左递归且候选产生式之间 FIRST 集不冲突,这样向前看一个记号就能唯一决策。C 显然是错的,终结符是句子基础;D 太绝对,一个非终结符可以有多个产生式,只要它们互不冲突。
Q5. 关于 FIRST 集,下列说法正确的是?
- A.FIRST(A) 是 A 后面可能出现的终结符集合
- B.FIRST(A) 是 A 能推导出的所有串的首个终结符的集合← 正确答案
- C.FIRST(A) 只包含非终结符
- D.FIRST(A) 由词法分析器直接给出
答案:B。FIRST 集是“从 A 出发能推出的串的开头终结符”集合,它为分支选择服务。选项 A 描述的是 FOLLOW 集;C 错在集合元素是终结符;D 错在它由文法计算而来,不是词法产物。
Q6. 对开始符号 S,其 FOLLOW 集中一定包含哪个特殊记号?
- A.$← 正确答案
- B.ε
- C.S 本身
- D.所有终结符
答案:A。约定把输入结束标记 $ 放入开始符号的 FOLLOW 集,表示“整段程序结束”也是合法的后继事件。这个约定是 FOLLOW 集计算的第一步规则,也是表中 ε 产生式能正确收尾的基石。
Q7. 填预测分析表时,若 ε ∈ FIRST(α),则产生式 A → α 需要填入 M[A][b] 的条件是?
- A.b ∈ FIRST(α)
- B.b ∈ FOLLOW(A)← 正确答案
- C.b 可以是 $(当 $ ∈ FOLLOW(A))← 正确答案
- D.b 必须等于 α 的最后一个符号
答案:BC。ε 在 FIRST(α) 表示 α 可能为空,此时“选择这条路”等价于“让 A 空串收尾”,所以凡是在 FOLLOW(A) 里的记号(包括 $)都应填入。选项 A 是第一条填表规则的范畴;D 与规则无关。
Q8. 输入 100000 层嵌套括号 ((((…1…)))) 时,手写递归下降解析器最可能发生什么?
- A.解析结果静默错误
- B.调用栈溢出← 正确答案
- C.陷入死循环
- D.自动切换为迭代解析
答案:B。每层括号都会触发 parse_F → parse_E → parse_T → parse_F 的递归,深度与嵌套成正比。极深嵌套会耗尽调用栈导致栈溢出。这是递归下降基于调用栈的固有代价,生产代码常设深度上限。左递归未消除才会死循环,此处文法本身没问题。
Q9. 手写递归下降解析 parse_T 处理 2*3*4 这种连续乘法的经典结构是?
- A.if 语句判断一次
- B.while 循环反复匹配
*和因子← 正确答案 - C.无条件递归调用 parse_T 自身
- D.goto 跳回函数开头
答案:B。while 循环对应文法 T → F {(* F)} 中花括号的“零个或多个”含义,也正对应消除左递归后 A' → αA' | ε 的代码形态。if 只能处理一次;C 在未消除左递归时才会出现;D 不是结构化写法。
Q10. 下列哪些属于递归下降解析器的优点?
- A.每个非终结符对应一个函数,代码与文法一一对应,可读性强← 正确答案
- B.可以手工控制错误报错信息,报错友好← 正确答案
- C.天然支持任意左递归文法,无需预处理
- D.容易在解析过程中嵌入语义动作(如求值、建 AST)← 正确答案
答案:ABD。递归下降把文法结构直接映射为函数结构,天然可读、可定制报错、可顺手加入求值等语义动作,这是它被工业界广泛采用的原因。C 错:递归下降无法处理左递归,必须先消除,否则函数无限自调用。