刚把词法分析写到自己满意,紧接着就会被语法分析浇一盆冷水:上一篇文章里我们用正则表达式把源码切成了 token 流,但 token 流本身只是一堆零件。真正让 2 + 3 * 5 变成一个能被计算的表达式,靠的是语法分析阶段。而语法分析的地基,就是上下文无关文法(CFG,Context-Free Grammar)。
这个标题里的“随地大小变”看着像玩笑,实际上精准抓住了 CFG 的核心行为——非终结符在句子里的任何位置,都可以被直接替换成它对应的产生式右侧序列,完全不看左右邻居。换句话说,变是自由的,变的方式不受上下文约束。这篇文章是语法分析系列的第(2)篇,目标读者是正在啃编译原理教材的学生、准备面试的开发者,以及想动手写一个解释器却总觉得无从下手的工程师。读完你能理解 CFG 的来龙去脉,能亲手设计一个无二义性的表达式文法,能看懂递归下降分析器的代码,还能在生产环境常见的语法分析坑里快速定位问题。
1. 先从“变”说起——为什么叫“上下文无关”
1.1 用“替换规则”理解文法
编译原理里的文法,本质上就是一组替换规则,或者叫重写规则。每条规则长这样:
text复制左部 -> 右部
含义很简单:当我们在一个符号序列里看到“左部”这个符号时,可以把它替换成“右部”的符号序列。比如:
text复制E -> E + T
这一条规则的意思是,不管 E 出现在哪个位置,都可以把 E 替换成 E + T。注意这里的关键:规则只关心被替换的符号是谁,完全不关心它前面是什么、后面跟着什么。这两点一成立,就叫“上下文无关”。
这个特性乍一看有点像做菜时的固定操作步骤——不管你这锅菜前面放了土豆还是白菜,“临出锅前加水淀粉勾芡”这个步骤对所有菜都适用,不会因为锅里有其他食材就改变操作。上下文无关文法也是一样:替换规则的触发只取决于被替换符号自身,和周围出现的符号没有任何关系。
但如果你仔细想想,会发现“无关”恰恰是 CFG 力量的来源。正因为规则这么简单、这么机械,分析器才能用非常高效的算法去处理它。如果规则里加上了“前后文条件”(变成上下文有关文法),比如“只有当前面是 A 后面是 B 时才能把中间的 C 换成 D”,表达力确实更强了,但分析的复杂度立刻爆炸,根本无法作为编译器的日常语法工具。
1.2 为什么编译器需要上下文无关文法
上一篇文章里我们处理词法,识别关键字、标识符、数字字面量,用的武器是正则表达式。正则对“扁平结构”非常顺手:识别一个标识符、识别一个数字、识别一段注释,全都是线性扫描。但一旦遇到嵌套结构,正则就麻了。
举个最经典的例子:括号的嵌套匹配。(((...))) 这种结构,正则表达式没法表达,因为正则的“星号”只能做水平方向的重复,不能做垂直方向的递归。而递归恰好是编程语言语法结构的基本特征:表达式里面有子表达式,语句块里面有子语句块,if 里面还能再套 if。没有递归能力,就没有办法描述这些结构。
CFG 的替换规则天然支持递归,因为 E 的产生式右边可以再次出现 E。比如:
text复制E -> E + T
右边的 E + T 里又出现了一次 E,所以 E 可以无限展开下去,形成嵌套。这个“递归展开”的能力,正是正则表达式和 CFG 最重要的分水岭。CFG 的表达力刚好卡在“足够描述编程语言结构,又足够简单到能自动分析”的位置。这也是为什么几乎所有编程语言的规范都用 CFG 或它的扩展(EBNF)来写语法部分。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 文法四要素与一个完整表达式文法
2.1 文法由哪四部分组成
正式定义一个上下文无关文法需要四样东西,也就是教科书上写的那个 G = (V, T, P, S)。这四样东西分别对应:
- V(非终结符集合):可以继续替换成其他符号的“中间符号”。起名“非终结”是因为它不是终点,还能继续展开。比如表达式文法里的 E、T、F。
- T(终结符集合):不能再替换的符号,是最终句子里实际出现的“原子符号”。比如数字、加号、乘号、括号。
- P(产生式集合):从非终结符展开成一组符号的规则,是整个文法的核心。
- S(开始符号):整个推导的起点,必须是一个非终结符。所有合法的句子,都是从 S 出发,按照产生式逐步替换得到的。
用程序员的思路类比一下:终结符就是词法分析器吐出来的 token(数字、加号、左括号),非终结符就是语法上的“概念构件”(表达式、项、因子、语句),产生式就是语法规则本身,开始符号就是“程序”或“语句列表”这个顶层入口。
2.2 从朴素文法到层级文法:设计一个无二义性的表达式
先展示一个最“朴素”的算术表达式文法:
text复制E -> E + E | E - E | E * E | E / E | (E) | num
这个文法一眼看上去很自然,但它有两个致命问题。第一个问题是优先级不明确:对于 1 + 2 * 3,按照这个文法,既可以推导成先算加法再算乘法,也可以推导成先算乘法再算加法。前者得到 (1 + 2) * 3 = 9,后者得到 1 + (2 * 3) = 7,语义完全不同。
第二个问题是结合方向不明确:对于 1 - 2 - 3,如果左结合,应该算成 (1 - 2) - 3 = -4;如果右结合,算成 1 - (2 - 3) = 2。数学和绝大多数编程语言都要求减法左结合,但这个朴素文法提供不了这种约束。
解决办法是引入“层级”设计。把运算符按优先级分组,优先级最低的放最外层,优先级最高的放最底层:
text复制E -> E + T | E - T | T
T -> T * F | T / F | F
F -> (E) | num
这个文法的设计原则很清晰:
- E 层处理加减法,优先级最低;
- T 层处理乘除法,优先级在中层;
- F 层处理括号和数字,优先级最高,最内层;
- 每一层内部用左递归实现左结合。
你可能会问:为什么不保留朴素的 E -> E + E,然后用工具里的优先级声明来消除冲突?这个问题很重要。如果在手写递归下降分析器里用了 E -> E + E,函数调用会直接陷入无限递归,根本跑不出来;如果在 Yacc/Bison 这类自动生成工具里写这种文法,会报一屏的 shift/reduce 冲突。与其依赖工具的“魔法”兜底,不如一开始就写一个良定义的无歧义文法。这也是所有严肃文法设计的共识。
3. 推导实操:用文法生成一个句子
3.1 最左推导:一步一步换出目标串
文法设计好了,现在要用手动推导的方式,看看一个具体表达式怎么被“生成”出来。我们拿 2 + 3 * 5 做例子,用上面的层级文法做一次最左推导:
text复制E
→ E + T (E -> E + T)
→ T + T (E -> T)
→ F + T (T -> F)
→ 2 + T (F -> num)
→ 2 + T * F (T -> T * F)
→ 2 + F * F (T -> F)
→ 2 + 3 * F (
