1. 先把这个梗破了:为什么会用“随地大小变”来形容CFG
标题里“随地大小变”不是纯粹为了整活。如果你写过递归下降parser,大概率对下面这种体验不陌生:某个非终结符出现在推导串的中间,你的代码一遇到它,就直接调用它对应的子函数去展开解析,完全不需要关心它前后还有什么符号。这正是上下文无关文法(Context-Free Grammar,CFG)最让人“上头”的地方——规则里但凡出现形如 A → α 的产生式,你就能把句子中任何位置的 A 直接替换成 α,不需要看左边是谁、右边是谁、更不需要看嵌套深度。所谓上下文无关,本质就是“替换动作不受邻居约束”,想要变,随时就能变。
我把这个梗讲给不少刚学编译原理的朋友听,他们反而一下就记住了四个字:CFG 的规则在使用时真的就是“随地大小变”。甚至还能再延伸一层——不光推导时自由,连写文法时你也很自由:产生式右边可以是终结符、非终结符、空串,也可以是它们的任意组合,几乎没有格式上的限制。所以这个标题表面是个玩笑,实际上把形式语言理论里一个最核心的规则特点给说透了:任何非终结符都能在其出现的任何位置独立进行展开。
不过,编程序的人都知道,自由通常伴随着代价。上下文无关文法虽然写起来自由,真正落地成parser的时候,你会碰到左递归、公共前缀、二义性、shift/reduce冲突这些非常具体的问题。这篇文章我会从CFG最严格的定义讲起,一步一步手推推导过程,用它跟正则文法、上下文相关文法做对比,再落到真实parser里那些“一写就炸”的经典坑。无论你是刚开编译原理这门课,还是正在啃某个开源编译器的手写parser,都建议把这篇完整看完。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 四元组、终结符与产生式:CFG的“语法零件”长什么样
2.1 形式化定义:G = (VN, VT, P, S)
大多数人第一次接触CFG,是被那一堆术语劝退的。什么非终结符、终结符、产生式、开始符号……先别慌。文法归根到底干的事只有一件:用一条条“替换规则”,把一个初始符号逐步展开成一句由终结符组成的句子。为了把这个过程描述精确,数学家给文法设计了一套四元组定义:
| 组成 | 符号 | 通俗解释 | 例子 |
|---|---|---|---|
| 非终结符集合 | VN | 推导过程中“待展开”的中间概念,又被称为语法变量 | E、T、F |
| 终结符集合 | VT | 最终句子里的字符或词法单元,不能再被替换 | +、*、(、)、数字 |
| 产生式集合 | P | 形如 A → α 的替换规则,A必须是非终结符 |
E → E + T |
| 开始符号 | S | 整个推导过程第一步要展开的非终结符 | E |
要特别注意,VN 和 VT 必须不相交。因为一个符号不能既是“待展开变量”又是“最终字符”,否则推导过程就没法确定何时停止。产生式集合 P 是有限的,但推导出来的句子可以是无穷多个,这正好对应一种语言可以用有限条规则描述无穷句子的核心思想。S 必须是 VN 里的元素,它是一棵语法树的“根”,从它开始,语言才有一个确定的生长起点。
这里我第一次学的时候有过一个误解:以为非终结符一定对应某种“抽象语法概念”,终结符一定对应一个“真实单词”。实际上这个划分是人为选择的。你完全可以把数字、变量名、运算符全部设计为非终结符,也可以只把它们当作终结符。真正影响划分的是:你希望文法在哪一层“停止展开”。词法分析器输出的token流,在语法分析阶段通常就被处理成终结符,因为它们已经是不可再分的“原子”;而语句、表达式、声明这类结构,需要再一次次展开,所以它们通常是非终结符。
2.2 一条文法的完整示例
只看四元组概念还是太抽象。下面给一个非常经典的算术表达式文法:
text复制E → E + T | T
T → T * F | F
F → ( E ) | n
这里的竖线 | 是“或者”的缩写,表示同一个非终结符可以有多条候选产生式。展开写成多条就是:
text复制E → E + T
E → T
T → T * F
T → F
F → ( E )
F → n
这个文法里,终结符集合是 { +, *, (, ), n },其中 n 可以理解成“一个数字token”的占位符号;非终结符集合是 { E, T, F },开始符号是 E。你可能会问,为什么不能干脆写成 E → E + E | E * E | n?表面上确实更简洁,但后面讲二义性的时候你会看到,这样写会对 n + n * n 这种句子生成两种完全不同的语法树。而借助 E → E + T、T → T * F 这种层次化分层,文法不仅能够描述四则运算表达式,还能把加减和乘除的优先级差异“写进结构里”。这是设计文法最常见也最重要的手法:优先级不同,就拆成不同层级的非终结符。
2.3 BNF、EBNF与日常写法
很多教材上CFG的规则不长成箭头形式,而写成 :is 或 :=,比如:
text复制expr := expr "+" term
| term
这就是 BNF(巴科斯-诺尔范式)。它把 → 写成 :=,把非终结符用 < > 或斜体包裹起来,以便和终结符区分。现代工程里更常用的是 EBNF(扩展巴科斯-诺尔范式),它额外引入 *、+、?、括号分组这些“语法糖”,让你不必把重复结构用递归一步步写出来。比如下面这段 EBNF:
text复制expr := term (("+" | "-") term)*
term := factor (("+" | "-") factor)*
factor := NUMBER | "(" expr ")"
表达式 expr 可以读成“一个 term,后面跟着零到多个由 + 或 - 连接的新 term”。这种写法比原始CFG贴近人脑得多,但它并没有增加文法的表达能力——任何EBNF能描述的语言,总能用标准CFG描述出来。很多语言官方规范(比如Go语言规范、Python语法定义)用的就是类似形式,你把它看作一种“方便人类阅读的CFG包装”即可。
3. 手推一遍推导:看一条句子是怎么被文法“长”出来的
3.1 最左推导与最右推导
有了文法和产生式,接下来的核心动作叫“推导”。推导的每一步,就是找一个句型里的非终结符,用某条产生式把它替换成产生式的右部。从一个句子推导出另一个句子的记号是 ⇒,读作“推导出”。
回到上面的算术表达式文法。现在给一串输入:n + n * n。怎么从 E 出发得到它?推导顺序可以有很多种,最经典的是“每次替换当前句型最左边的非终结符”,这叫最左推导。整个过程如下:
| 步骤 | 当前句型 | 本次使用的产生式 |
|---|---|---|
| 0 | E | 初始 |
| 1 | E + T | E → E + T |
| 2 | T + T | E → T |
| 3 | F + T | T → F |
| 4 | n + T | F → n |
| 5 | n + T * F | T → T * F |
| 6 | n + F * F | T → F |
| 7 | n + n * F | F → n |
| 8 | n + n * n | F → n |
反过来,“每次都替换最右边的非终结符”叫最右推导。同样是 n + n * n,最右推导的过程是:
| 步骤 | 当前句型 | 本次使用的产生式 |
|---|---|---|
| 0 | E | 初始 |
| 1 | E + T | E → E + T |
| 2 | E + T * F | T → T * F |
| 3 | E + T * n | F → n |
| 4 | E + F * n | T → F |
| 5 | E + n * n | F → n |
| 6 | T + n * n | E → T |
| 7 | F + n * n | T → F |
| 8 | n + n * n | F → n |
同一个句子用同一条文法,可以有这么多条“路径”得到它,但最终都停在同一个句子 n + n * n 上。这背后有个很关键的观察:最左和最右推导只是语法树分支的生长顺序不同,树的结构不会变。先长左边还是先长右边,都由同一个确定的产生式集决定。在上下文无关文法里,每一个非终结符的替换都不受周围符号影响,所以不同分支的展开可以互相独立、顺序任意调换。这也是“自由替换”在推导层面的直接体现。
3.2 语法树
把上面的推导过程画成树形结构,会更直观。每个内部节点是一个非终结符,每个叶子是一个终结符。对应 n + n * n 的语法树长这样:
text复制 E
/ | \
E + T
| /|\
T T * F
| | |
F F n
| |
n n
你看,* 在树里比 + 埋得更深,意味着它在计算时必须先完成,这正是乘法优先级高于加法的结构表达。所以语法树不是“画着玩的”,它就是parser最终想要从token流里恢复出的骨架。很多教材把语法树和抽象语法树(AST)区分开:语法分析阶段构建的完整语法树里含有 ( )、逗号这类辅助终结符;后面做语义分析和代码生成时,大家会再去掉这些不携带信息的节点,得到更精简的AST。但不管是哪种树,起点都是同一个:“这个句子的结构是什么”。推导过程就是结构构建的过程。
4. 自由背后的边界:比正则强在哪、和上下文相关又差在哪
4.1 乔姆斯基层级
形式语言理论把文法分成四个层级,通常用乔姆斯基层级(Chomsky hierarchy)表示。
| 文法类型 | 产生式限制 | 对应自动机 | 典型应用 |
|---|---|---|---|
| 0型:短语结构文法 | 无限制 | 图灵机 | 理论通用计算模型 |
| 1型:上下文相关文法 | αAβ → αγβ,仅当A出现在α和β之间才能替换 |
线性有界自动机 | 少数自然语言现象 |
| 2型:上下文无关文法 | A → γ,替换不依赖环境 |
下推自动机 | 编程语言语法定义 |
| 3型:正则文法 | A → aB 或 A → a |
有限自动机 | 词法分析 |
单看表格,CFG只是第二层的“普通成员”,但它偏偏是程序设计语言最常用的那个。原因不复杂:它在“表达能力”和“可解析性”之间找到了一个极其舒服的平衡点。正则文法简单到可以一眼写出词法规则,但它没有递归能力;上下文相关文法能力强悍,但它的解析问题复杂度立刻暴涨,没人愿意用这种文法来描述一门编程语言的语法结构。
4.2 CFG到底比正则强在哪
先举个经典例子。设语言 L = { (^n )^n | n ≥ 1 },也就是“先来n个左括号,再来n个右括号”的符号串,比如 (), (()), ((()))。很自然的一个结构,但正则文法没法描述它。原因很简单:有限自动机只有有限个状态,而这类语言要求“数清楚左括号的数量,并保证右括号数量完全相等”。当n可以无限取下去时,任何有限状态都不够存下这个计数。
上下文无关文法处理起来却毫不费力:
text复制S → ( S )
S → ε
或者描述“任意合法配对的括号串”(比如 ()(()) 也能匹配)可以用:
text复制S → ( S ) S
S → ε
后者读起来像是“S要么为空,要么由一个左括号、一个内部S、一个右括号,再跟一个S组成”。它天然对应递归嵌套结构。程序设计语言里的 if 嵌套、函数调用嵌套、表达式任意深度括号,本质上都是这种“递归套递归”的结构。这就是为什么词法分析器可以用正则表达式搞定,而语法分析器必须上CFG——一个不认嵌套,一个天生为嵌套而生。
4.3 “上下文无关”与“上下文相关”的真正差别
既然叫“上下文无关”,那还不如干脆直接看“上下文相关”的最原始形态。如果允许产生式长这样:
text复制A B → A C
那就意味着:B被替换成C这件事,只有在它紧跟在A后面时才合法。这种规则把“替换动作”挂靠在上下文环境上,因此名字叫上下文相关。正是这个差别让它的推导过程变得极其复杂:你不能随便挑一个非终结符就展开,你得先检查它的前驱后继是否满足条件。一个句子能否从起始符号推导出来,有时候得全局统筹规划才能判断。
于是这个梗真正拧清楚的地方来了:CFG里的“自由变”不是说你可以无视规则乱来,而是说“非终结符上任何位置发生替换时,规则都一视同仁”。A出现的地方,永远可以换成产生式右部,不多看环境一眼。这种“撒手不管”的风格换来的是极高的解析效率。真正现实世界里的编译器也清楚,纯CFG并不足以处理一门语言的全部细节——比如C语言里 A * B; 到底是“把A声明为指针类型B”还是“A乘以B的表达式”,语法分析阶段光靠CFG根本分不清,编译器必须借助符号表这类“上下文”去判断。但人们依然把语法骨架架在CFG之上,额外再引入少量上下文信息做补充,而不是干脆把整门语言的语法定义成上下文相关文法。因为那样做不仅文书写起来要命,解析器的工程复杂度也根本无法收场。
5. 文法写得爽、解析就炸了:二义性和几个经典现场
5.1 一个“平铺直叙”的文法如何翻车
在讲二义性之前,先做一个实验。假设把算术表达式文法简化成下面这样:
text复制E → E + E | E * E | ( E ) | n
这条规则看起来很干净,但它立刻会让 n + n * n 对应两棵语法树。第一棵是“先算乘法”:
text复制 +
/ \
n *
/ \
n n
第二棵是“先算加法”:
text复制 *
/ \
+ n
/ \
n n
同一句话有两棵结构不同的树,结果含义完全不同。文法本身没有给 + 和 * 定义优先级,于是它把“7”和“9”两种结果都合法化了。这在语法层面等于没把关。我们平时说某个CFG有歧义,指的就是:存在至少一个句子,能用同一条文法得到两棵不同的语法树。
要修掉这个问题,最自然的办法就是上一节提过的“分层”。把表达式按优先级从低到高拆成:加减层 E、乘除层 T、原子层 F。于是:
text复制E → E + T | T
T → T * F | F
F → ( E ) | n
这样一来,n + n * n 里 +两边只能接T,而T内部一定先处理 *。语法树就只剩一棵了,而且 * 必然嵌在 + 的下一层。你还顺便发现:E → E + T 这种左递归形式,天然让 + 操作符满足左结合,即 n - n - n 会解析成 (n - n) - n,而不是 n - (n - n)。文法的设计决定了运算符的结合性和优先级,这就是最核心的干货。
5.2 悬垂 else:最经典的那道坎
除了表达式二义性,编译原理课程里最著名的二义性例子是“悬垂 else”。考虑下面这个控制流文法片段:
text复制stmt → if expr then stmt else stmt
| if expr then stmt
| other
现在给它输入:
text复制if c1 then if c2 then s1 else s2
问题来了:这个 else s2 到底是和第一个 if 配对,还是和第二个 if 配对?两种理解分别对应两棵不同的语法树。真实语言里的规则通常是“else与最近的尚未配对的 if 匹配”,所以它应该属于内层那个 if。但问题在于,如果你把上面那份文法直接扔给一个无优先级的通用工具,它根本不知道应该选哪棵。
处理方式有三条路。第一条,修改文法,让它能体现“中间状态”,但这会让文法变得繁琐,尤其当 if 和 else 之间还夹着许多语句类型时,工程上很难维护。第二条,像 yacc/bison 这类工具那样,用优先级声明告诉parser:遇到悬垂else这种 shift/reduce 冲突时,选择 shift,意即“让 else 跟内部更小的 if 走”。第三条,手写递归下降parser的时候,直接让 if 的解析逻辑在读到 else 时优先与最近压栈的 if 配对。真正成熟编译器的实现里,第二条和第三条都很常见。
5.3 parser生成器怎么报告二义性
如果你用过 yacc、bison、CUP、PLY 之类的解析器生成器,一定遇到过这类报错:shift/reduce conflict。它的意思是在某个状态下,parser既可以“移进”下一个token,也可以“归约”当前已读到的内容,两者都能构成合法推导。很多时候这就是文法二义性的报警信号。除非你明确处理,否则一个充满冲突的语法规则,往往会解析出意料之外的结果。
更隐蔽的还有 reduce/reduce conflict,指parser在同一状态下有两条归约路径可选。出现这种冲突,通常说明文法本身有冗余——两个非终结符展开出来的内容可以被同一串输入触发。调试这种问题确实让人头大,但根子通常不是工具不聪明,而是你的规则没有给“到底该归约成谁”一个唯一的决策依据。所以很多有经验的工程师反倒会刻意把文法写得“结构化”一些:宁可多一些非终结符层级,也不要在优先级声明上堆太多的补丁。补丁堆得越多,文法离“可读、可验证、可维护”就越远。
6. 真正写Parser时绕不开的文法设计问题
6.1 左递归和自顶向下parser的“死循环”
从CFG到parser,常见路线有手写递归下降、parser生成器、parser combinator三大类。但不管哪一类,只要语法是自顶向下扫描的,就立刻撞上一个理论问题:左递归不能直接用。
看这段文法:
text复制E → E + T | T
如果把它翻译成代码,非终结符 E 对应一个函数 parseE()。函数执行第一步就要“尝试匹配 E + T”,于是它首先又去调用 parseE()。这个调用同样要先匹配 E + T,然后又调 parseE()……这样无限递归下去,栈直接溢出。所以自顶向下的parser都需要先把左递归消掉。消去方法有标准公式。对形式 A → Aα | β,可以改写成:
text复制A → β A'
A' → α A' | ε
套到上面的表达式文法上:
text复制E → T E'
E' → + T E' | ε
T → F T'
T' → * F T' | ε
注意,这里的 E' 是一个新引入的非终结符。它用右递归表达“零个或多个 + T 的序列”。右递归带来的树形结构是向右倾斜的,对加法这种左结合运算符来说,语义上需要再加工一下,但在语法层它能被线性地解析完,不炸栈。
6.2 从文法到代码:递归下降是最直观的翻译
带着文法去写递归下降parser,本质上就是把每条产生式翻译成“一个函数里的一系列调用和分支判断”。比如下面的EBNF:
text复制expr := term (("+" | "-") term)*
term := factor (("+" | "-") factor)*
factor := NUMBER | "(" expr ")"
用Python写一个极简版本,大概是这个感觉:
python复制def parse_expr():
left = parse_term()
while current.type in ("PLUS", "MINUS"):
op = current
advance()
right = parse_term()
left = BinOp(op, left, right)
return left
def parse_term():
left = parse_factor()
while current.type in ("STAR", "SLASH"):
op = current
advance()
right = parse_factor()
left = BinOp(op, left, right)
return left
def parse_factor():
if current.type == "NUMBER":
val = current
advance()
return Number(val)
if current.type == "LPAREN":
advance()
node = parse_expr()
expect("RPAREN")
advance()
return node
raise ParseError(f"unexpected token: {current.type}")
你会发现,函数名和非终结符一一对应,while 循环正好对应 EBNF 里的 * 重复,递归调用对应非终结符之间的互相引用。这里有个容易被忽略的点:EBNF把“重复”直接写成了循环,因此你不再需要像标准CFG那样加一个辅助非终结符 E',写代码时反而更自然。这也就是为什么现代手写parser的参考规范几乎都用EBNF,而不是标准CFG。
6.3 该选手写、生成器还是combinator
不同项目对CFG的落地方式有不同的取舍。简单整理一下我能想到的对比:
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 手写递归下降 | 错误信息友好、调试直观、可穿插语义代码 | 文法变更时维护成本高 | Rust、Go、Clang这类大型编译器前端 |
| parser生成器(yacc/bison/ANTLR) | 直接从文法生成代码,适合快速迭代 | 冲突调试困难、错误恢复比较僵 | DSL、计算器、简单脚本语言 |
| parser combinator | 用宿主语言函数组合,写法优雅 | 性能一般、堆栈深、报错难读 | 解释器、小型内部语言、教学项目 |
选型没有绝对对错。重要的是你得先有一个干净、无二义、左递归处理好的文法,不管是人写代码还是工具生成,CFG才是蓝图。蓝图错了,后面再炫目的工程手段也只是在打补丁。
我自己这些年写paser养成一个习惯:先搭一个能跑通最小用例的文法,再逐步增加优先级和语法糖。比如做一门小语言,我会先从表达式和语句的结构开始,跑通 1 + 2 * 3,再往里加 if/while、函数调用、变量声明。每次加完语法,我都会立刻用一组歧义用例去测,比如把所有二元运算符塞在同一层级里,故意验证是不是产生了错误树。等主结构稳了,再考虑在文法里加入错误产生式或者给错误恢复逻辑留位置。这个顺序能让你在文法还简单的时候抓住大多数设计失误,而不是等到语法写了几百行之后,再面对一屏的parser冲突日志欲哭无泪。
最后再说一个容易被教材忽略但工程里很值钱的细节:token边界也是设计文法时该留意的隐式上下文。空白、换行、注释这些由词法层处理的内容,通常不会出现在CFG的终结符集合里;但如果你的语言里换行真的有语义(比如Python的隐式续行),那它就必须作为一个显式终结符进入文法的考虑范围。很多人在词法阶段把换行吞掉,到了语法阶段才发现语句边界没法识别,只能回头改词法。这种跨层沟通的坑,往往比文法二义性还要隐蔽,需要格外小心。
