写语法分析器之前,我一直觉得“上下文无关文法”这六个字特别劝退。好像一提到文法,第一反应就是教科书里的E -> E + T、FIRST集合、FOLLOW集合,整个人都开始犯困。但等你真正动手写过几个解析器,比如给JSON写个解释器、给配置文件写个读取器、或者给自己的DSL做语法解析,你才会意识到,上下文无关文法(Context-Free Grammar,CFG)就是语法分析的骨架。没有它,你连“这个输入合不合法”这种基本问题都回答不了。
这篇文章是“语法分析”系列的第二篇,重点聊一聊上下文无关文法。我尽量不讲天书,用能落地的角度去拆为什么文法能“随地大小变”——也就是它能表达递归嵌套结构、能让你自由地定义语言形状、也能在解析时灵活地生成任意深度的语法树。看完之后,你会知道怎么设计文法、怎么用文法指导写解析器、以及最常见的坑都在哪里。
1. 先搞清楚:文法到底在描述什么
1.1 四条基本规则:终结符、非终结符、产生式、开始符号
上下文无关文法这名字很高大上,拆开来看就是一套“重写规则”。一条文法由四个部分组成:
- 终结符(Terminal):语言的词法单元,比如关键字
if、运算符+、括号(、数字123。它们是推导的终点,不能再被替换。 - 非终结符(Non-terminal):用来表示语法成分的抽象符号,比如
Expr、Stmt、Block。它们可以被产生式替换成别的符号串。 - 产生式(Production):形如
A -> α的规则,意思是“非终结符 A 可以被替换成 α”。α 是由终结符和非终结符组成的符号串,也可以是空串 ε。 - 开始符号(Start Symbol):文法规定的起始非终结符,所有推导都从它出发。
这四样东西组合起来,就定义了一门语言:从开始符号出发,反复应用产生式,直到全部变成终结符,得到的终结符序列就是这门语言的一个合法句子。
举个例子。定义一个“问候语”文法:
code复制Greeting -> Hello Name
Hello -> "你好" | "嗨"
Name -> "小明" | "小红"
这里 Greeting 是开始符号,Hello 和 Name 是非终结符,“你好”“嗨”“小明”“小红”是终结符。从 Greeting 出发,替换 Hello 为“你好”,替换 Name 为“小明”,就得到句子“你好小明”。如果替换成“嗨小红”,也是合法句子。就这么简单。
1.2 为什么叫“上下文无关”
理解“上下文无关”这个词,是理解整个CFG的关键。所谓“无关”,指的是产生式左边的非终结符在替换时,不关心它周围是什么。Expr -> Expr + Term 这条规则,无论 Expr 出现在 if 条件里、函数调用的参数里,还是赋值语句右边,替换规则都一样,不会因为“上下文”不同就产生不同行为。
与之相对的是“上下文有关文法”(Context-Sensitive Grammar,CSG)。在CSG里,产生式可以写成 A -> α 的形式,但允许带上下文条件。比如 aA b -> aX b,表示只有当 A 前面是 a、后面是 b 时,才能被替换成 X。这种文法表达能力更强,但解析复杂度也爆炸式上涨。
编译器领域选CFG而不是CSG,核心原因就一条:CFG能在表达能力和解析效率之间取得最佳平衡。绝大多数编程语言的语法结构——表达式、语句、声明、嵌套块——用CFG都够用。非要上CSG的话,解析时间复杂度会变得不可控,对编译器实现来说几乎不可接受。
1.3 从文法到语法树:语法分析的本质工作
语法分析器(Parser)的输入是词法分析器产出的Token流,输出就是一棵语法树(Syntax Tree)。这棵树的形状完全由文法决定。分析过程本质上就是:给定一个Token串,判断它是否属于这个文法定义的语言;如果属于,就构造出对应的推导过程,也就是树形结构。
我个人的理解是,文法就像一张“乐高图纸”,Token流是积木,语法树是搭好的成品。Parser要做的就是根据图纸把积木一块块拼起来,拼到一半发现形状不对,立刻报错。
这个地方有一个容易踩的误区:很多人以为语法分析就是“匹配括号”“检查分号”。走到这一步你会发现,匹配括号只是最最基础的工作,真正的难点在于处理嵌套、优先级、结合性、二义性,这些全部需要靠文法设计来解决。所以,写Parser前先写文法,不是流程要求,而是你省不了的一步。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. “随地大小变”:递归是CFG的灵魂
2.1 递归产生式:让语言可以无限嵌套
标题里说的“随地大小变”,我理解是对CFG递归能力的一种戏称。什么叫“随地大小变”?就是说文法的产生式可以引用自己,于是从一条规则出发,就能派生出任意深度、任意复杂度的结构。这种能力让CFG可以用有限的规则描述无限多种句子。
最典型的例子是括号匹配。一个合法的括号串可以是一个空串、一对括号、两对括号嵌套或并列。用CFG写出来就是:
code复制S -> ( S ) S | ε
看这条规则:S 可以被替换成 ( S ) S,里面的 S 又可以被替换成 ( S ) S......只要一直替换下去,就能产生((()()))这种任意深度的嵌套结构。这就是“大小变”的由来——文法规则是固定有限的,但派生出来的句子长度和深度没有上限。
编程语言里的表达式、代码块、函数调用,全都是靠递归产生式定义的。你写一个函数,函数体里可以有另一个函数定义,这个新函数体里又可以定义另一个……这种无限嵌套,不用递归文法根本画不出来。所以判断一门语言能不能用CFG描述,第一个考察点就是:它有没有递归嵌套的结构。有,CFG马上派上用场;没有,那可能是正则表达式的活儿。
2.2 左递归与右递归:从推导树看递归方向
递归也分方向。看下面两个文法:
code复制A -> A "a" | "a" // 左递归
B -> "a" B | "a" // 右递归
左递归的产生式左边先出现自己,右递归则是自己出现在右边。两者描述的语言可能是一样的,但推导出来的语法树形状完全不同。左递归产生的树向左下方生长,右递归产生的树向右下方生长。
这里有个非常实际的影响:如果你打算手写递归下降解析器(Recursive Descent),左递归会让你直接陷入死循环。原因很简单,A -> A "a" 这个规则会让解析函数在尝试匹配 A 时,进入“先调用 A 自身”的无限递归,根本读不到任何Token就栈溢出了。这也是为什么后面我要专门讲消除左递归。
右递归天然适合表示右结合的操作符,比如赋值 = 或指数运算 ^。左递归适合表示左结合操作,比如加减乘除。所以文法设计不是随便写的,它直接影响后面Parser的写法。
2.3 二义性文法:一个句子两棵树
有了递归,紧接着又冒出一个问题:同一个句子可能有两棵不同的语法树。文法如果允许这种“一树两形”的情况,就叫二义性文法(Ambiguous Grammar)。
经典例子是悬空else问题。看这一段文法:
code复制Stmt -> if Expr then Stmt | if Expr then Stmt else Stmt | Other
如果输入是 if A then if B then C else D,这个 else 到底匹配内层 if 还是外层 if?这个文法给出了两种可能的树形结构。大多数编程语言规定 else 匹配最近的 if,但CFG本身不会自动“遵守”这个规则,必须靠文法变体或者Parser的实现策略来强制。
另一个经典例子是表达式优先级。一个文法:
code复制E -> E + E | E * E | num
如果直接拿去写解析器,1 + 2 * 3 既可以解释成 (1+2)*3=9,也可以解释成 1+(2*3)=7。算术上的乘除优先根本表达不出来。解决方案是引入多层非终结符,把优先级“编码”进文法里。这个操作是每个写表达式解析器的人都躲不开的一步,后面实操部分我会具体讲。
二义性文法不是语法错误,但是对编译器来说非常危险,因为它会导致程序的含义不确定。所以文法设计阶段,尽量就要把二义性杀掉,杀不掉就用额外的规则(比如优先级表、结合性声明)去约束Parser。
3. 动手实操:设计一份可用的表达式文法
3.1 从优先级出发,逐层封装
我来讲一个我从零设计表达式文法的完整思路。假设要解析加减乘除和括号,正常的数学规则是:乘除优先于加减,括号最优先,加减从左往右结合,乘除从左往右结合。
要让文法体现优先级,常见做法是给每个优先级级别分配一个非终结符:
code复制Expr -> Term | Expr "+" Term | Expr "-" Term
Term -> Factor | Term "*" Factor | Term "/" Factor
Factor -> "(" Expr ")" | Number
这里 Expr 负责加减,Term 负责乘除,Factor 负责括号和原子。解析 1 + 2 * 3 时,2 * 3 只能由 Term 产生,而 Term 在 Expr 里又是一个独立的成分,所以 * 自然被 + 隔开,乘除先算的结构就出来了。
这种“从低优先级到高优先级逐层封装”的方法,是表达式文法设计的通用套路。不管你是写JSON解析器还是写SQL解析器,只要涉及运算符优先级,都得用这个招。
3.2 消除左递归:把递归改成循环
前面说不处理左递归,手写递归下降解析器就没法用。那怎么消除?标准算法是把左递归产生式改写成右递归加一个尾递归的额外非终结符。
看一个最简单的情况:
code复制E -> E "+" T | T
消除之后:
code复制E -> T E'
E' -> "+" T E' | ε
思路是:原先E可以一次接一个+T无限扩展,现在改成先用T开头,然后通过E'不断在后面追加+T,直到碰到不属于+的Token为止。这样递归的方向从“往里递归”变成了“往后递归”,递归下降解析器就能安全处理了。
注意一个细节:原来的左递归表达的是左结合(1+2+3等于(1+2)+3),改成右递归之后,语法树变成了右结合形状。表面上不影响识别合法性,但如果直接照做,语义上会有问题:1-2-3会被解析成1-(2-3)=2,而不是(1-2)-3=-4。所以实际写解析器时,遇到这种用右递归替代左递归的算子,一定在代码里额外处理结合性,以左结合的逻辑计算。这个坑我确实踩过,当时一个简单的减法表达式求值,愣是因为这问题算错了好多次。
3.3 提取左公因子:避免解析器“看一步”就懵
除了左递归,还有一个常见的文法改造叫提左公因子(Left Factoring)。看这段文法:
code复制Stmt -> if Expr then Stmt else Stmt | if Expr then Stmt
两个产生式都以 if Expr then Stmt 开头,在递归下降解析时,Parser读完 if、读完 Expr、读完 then、读完 Stmt 之后,才知道后面有没有 else。可它都读完了才开始后悔,晚了。
提左公因子的做法,是把公共前缀抽出来:
code复制Stmt -> if Expr then Stmt ElsePart
ElsePart -> else Stmt | ε
这样Parser在读完 then Stmt 之后,只需要看下一个Token是不是 else,就能决定走哪条分支。这背后其实是LL(1)的基本要求:每个非终结符的多个产生式,必须能通过“当前Token”唯一区分。如果区分不了,就得通过改写文法来区分。
3.4 递归下降解析器:把文法翻译成代码
文法设计好之后,直接照搬成代码就行。递归下降解析器的核心套路:每个非终结符对应一个函数,函数里写“匹配当前Token,然后按产生式调用其他非终结符对应的函数”。我用伪代码示范一下:
code复制parseExpr():
node = parseTerm()
while currentToken is "+" or "-":
op = advance()
right = parseTerm()
node = BinOp(node, op, right)
return node
parseTerm():
node = parseFactor()
while currentToken is "*" or "/":
op = advance()
right = parseFactor()
node = BinOp(node, op, right)
return node
parseFactor():
if currentToken is "(":
advance()
node = parseExpr()
expect(")")
return node
else:
return Number(advance())
看到没有,这个 while 循环就是消除左递归之后的“递归变迭代”。它每循环一次就消耗一个操作符和一个因子,然后立刻构建左结合的一个新节点。因为新节点会作为下一轮循环的左操作数,左结合语义天然就保留了。
写递归下降解析器最大的好处是代码可读性好,逻辑跟文法高度对应,调试也方便。最大的缺点是它只能处理LL文法(后面会讲),遇到需要更大表达能力的情况就得换工具。
4. LL(1)判定:你的文法能不能用递归下降
4.1 FIRST集合:一个非终结符“能拿得出手”的开头符号
写递归下降解析器时,每进入一个非终结符,都要根据当前Token决定走哪个产生式。怎么决定?靠的就是FIRST集合。
一个非终结符A的FIRST集合,就是从A出发能推出的所有符号串中,排在第一个位置的终结符集合。计算规则不复杂:
- 如果产生式是
A -> aβ,那么a加入FIRST(A)。 - 如果产生式是
A -> Bβ,那么FIRST(B)中的终结符全部加入FIRST(A)(要去掉ε)。 - 如果产生式是
A -> Bβ且B能推出空串ε,那么ε也要参与后续计算,即FIRST(β)的内容也要加入FIRST(A)。
举个具体例子,看这个简化文法:
code复制Expr -> Term Expr'
Expr' -> "+" Term Expr' | ε
Term -> "num"
FIRST(Term) = {"num"}。Expr' 的两个产生式,一个以 "+" 开头,所以 "+" 加入 FIRST(Expr');另一个是 ε,所以 ε 也加入。Expr 的第一个符号是 Term,所以 FIRST(Expr) = FIRST(Term) = {"num"}。
在Parser里,parseExpr 就是看当前Token是 "num" 才进入 parseTerm,然后再调用 parseExpr'。如果当前Token是 "+" 开头但后面没有合法的 Term,那说明语法错误。
4.2 FOLLOW集合:非终结符后面该跟什么
FOLLOW集合解决的问题是:当某个非终结符能推出ε时,Parser必须判断什么时候“什么都不匹配”。这时候它要看的不是第一个符号,而是当前Token是不是这个非终结符的合法后继。
非终结符A的FOLLOW集合,就是所有可能出现在A后面的终结符集合。计算规则:
- 开始符号的FOLLOW里加结束标记
$(表示输入结束)。 - 如果有产生式
A -> αBβ,那么FIRST(β)中除ε之外的所有终结符都加入FOLLOW(B)。 - 如果有产生式
A -> αB,或者A -> αBβ且β能推出ε,那么FOLLOW(A)全部加入FOLLOW(B)。
继续用上面的例子,Expr 是开始符号,所以 $ 加入 FOLLOW(Expr)。再看 Expr' -> "+" Term Expr',Term 后面跟着 Expr',所以 FIRST(Expr') 里的 "+" 和 ε 要处理。因为 Expr' 能推出ε,所以 FOLLOW(Term) 还要包含 FOLLOW(Expr'),而 FOLLOW(Expr') = FOLLOW(Expr) = {$}。最终 FOLLOW(Term) = {"+", "$"}。
有了FOLLOW集合,parseExpr' 在遇到当前Token不是 "+" 时,还要检查是不是 $ 或 FOLLOW(Expr') 中的符号,是的话就选择ε分支返回。
4.3 预测分析表与LL(1)冲突
FIRST和FOLLOW算完,可以构造一个预测分析表:行为非终结符,列为终结符,表项就是该采取哪个产生式。如果表里某个格子出现了两个产生式,就发生冲突,文法不是LL(1)文法。
经典冲突有两种。第一种是FIRST冲突,比如 Stmt -> if Stmt else Stmt | if Stmt,两个产生式的FIRST集合重叠了。解决方法是提左公因子。第二种是FIRST与FOLLOW冲突,比如 S -> Aa | ε 且 a 同时出现在 FOLLOW(S) 里,那么当Token是 a 时,Parser不知道该选 Aa 分支还是ε分支。这种冲突往往意味着文法设计有问题,需要回头改文法结构。
我实际写解析器时的经验是:不要过度追求自己的文法“先天就是LL(1)”,很多语言结构天然不是。真遇到冲突,与其死磕手写递归下降,不如直接换用LR工具,或者用ANTLR这种支持更多文法类型的生成器。工具是为目标服务的,不是目标本身。
5. 自底向上与LR家族:另一个大方向
5.1 为什么还需要LR解析
递归下降虽然好懂,但它只覆盖LL文法子集。很多编程语言的关键语法——比如左递归的表达式、需要前瞻多个Token的结构——用LL(尤其是LL(1))表达起来要么很别扭,要么需要大费周章地改写文法。
LR解析走的是完全不同的路线:自底向上归约。它不预测“该用哪个产生式”,而是读入Token往状态栈里压,当发现栈顶的符号串匹配某个产生式的右边时,就做一次归约(Reduce),把它替换成产生式左侧的非终结符。
LR解析器能识别的文法范围比LL大得多,几乎所有实际编程语言的文法都能用LR或其变体处理。经典工具Yacc、Bison就是基于LALR(1)的,ANTLR走的是ALL(*),本质上也扩展了LL的识别范围。
5.2 移进-归约:从Token到语法树的反向生长
LR解析的核心操作只有两个:移进(Shift)和归约(Reduce)。看一个简单例子,文法:
code复制E -> E + T | T
T -> num
输入是 1 + 2。解析过程大致是:
- 把
1移进栈,栈顶是num。 - 发现
num匹配T -> num的右边,归约成T。 - 继续发现
T匹配E -> T,归约成E。 - 读到
+,移进。 - 读到
2,移进。 - 栈顶
num归约为T。 - 栈变成
E + T,匹配E -> E + T,归约成E。 - 输入读完,栈里只剩起始符号
E,解析成功。
整个过程不像递归下降那样在函数调用栈里构建语法树,而是在状态栈里逐步拼装。实现上比递归下降复杂很多,但表驱动的方式使得性能非常稳定,所以很多生产级编译器(比如GCC早期版本、Go的早期版本)都选择Yacc/Bison这类LR工具。
5.3 怎么选:递归下降还是LR生成器
从我自己的接触来看,选型主要看项目条件。
如果你在写一个自定义小语言、内部DSL、配置文件解析器,或者学习目的为主,优先手写递归下降。它依赖少、报错可控、调试容易。
如果你在为现有编程语言写前端,或者要处理一门有完整语法规范的语言,直接上ANTLR或者Bison。自己手写LR自动机太痛苦了,完全不值得。工具能自动处理冲突检测、表生成、错误恢复,省下大量时间。
还有一个折中方案:用ANTLR写文法,让它生成解析器代码,但同时保留手写Visitor/Listener来遍历语法树。这个模式我做过多次,效率很高,既能利用ANTLR强大的文法表达能力,又不会丢失对语义处理的精细控制。
6. 常见问题与排查技巧实录
6.1 高频问题速查表
| 现象 | 可能原因 | 处理办法 |
|---|---|---|
| 解析器无限递归,栈溢出 | 文法存在左递归,但使用了递归下降 | 消除左递归,或改用LR工具 |
| 一个合法输入被错误拒绝 | 文法的FIRST集合和实际Token不匹配 | 重新计算FIRST/FOLLOW,检查产生式设计 |
| 输入解析成功但语义不对 | 左递归改右递归后丢失左结合性 | 在Parser代码中专门处理结合性 |
| 同一个句子出现两棵语法树 | 文法二义性(悬空else/表达式优先级) | 引入优先级分层、ElsePart等改写 |
| 预测分析表冲突 | 文法不是LL(1),FIRST或FOLLOW重叠 | 提左公因子,或选择LR路线 |
| 报错位置离真正错误很远 | 错误恢复机制过于激进或过于保守 | 实现Panic Mode错误恢复,跳过到同步Token |
6.2 排查思路:一步步定位文法问题
遇到解析错误,我的排查顺序是固定的。先复现最小用例,然后在Parser的入口处打印Token流,确认词法阶段输出没问题。接着在关键非终结符函数入口打日志,看解析走到了哪一步。如果发现某个非终结符没有按预期进入分支,就回头手算FIRST集合,把当前Token跟基础集合做对比,大部分时候问题就暴露了。
要是用了生成器(比如ANTLR),报错通常会告诉你几号规则几号位置冲突,直接定位到文法文件里的那一行就行。麻烦的是手写Parser,没有自动冲突报告,必须自己做好语法层面的“日志埋点”。我习惯是在每个非终结符函数里记录“进入时的Token”和“退出时的Token”,这套日志定位问题特别快。
6.3 几个实用的小技巧
最后分享几个我踩坑踩出来的经验。
第一,设计文法时千万别贪图“一步到位”,先写一个只覆盖核心语法的最小文法,跑通了再逐步扩展。一来文法出问题时好排查,二来不用在一开始就被一堆FIRST/FOLLOW集合的计算淹没。
第二,检查文法是否属于LL(1)时,不要只靠手工推演。你可以把文法录入到一个LL(1)分析器生成工具里,快速拿到FIRST/FOLLOW集合和冲突报告。哪怕你最终决定手写解析器,这个步骤也能帮你提前发现大量问题。
第三,递归下降解析器的错误提示一定要认真做。很多人Parser能跑但报错信息非常离谱,就是因为没有做“错误恢复”和上下文描述。我在每个非终结符里都会预设一个“expectedToken”集合,发现不匹配时,直接告诉用户“这里期望的是这些Token之一”,排查效率完全不一样。
文法这东西,初看是理论,细看全是工程。它能“随地大小变”,既是因为递归,也是因为你得按需求去调整、去改形、去补齐边界。把CFG吃透了,语法分析这关才算真正迈过去。
