看到“BNF、巴科斯-诺尔范式”这个标题,很多刚接触编译原理的人第一反应是:又一个高大上的数学符号体系。但说句实在话,BNF 是我在编译原理里见过的最接地气的工具之一。它本质上就干了一件事——用一套严格、无歧义的规则,告诉计算机“什么样的句子是合法的”。之所以你感觉它难,是因为教材往往一上来就甩出“上下文无关文法”“产生式”“终结符”这类术语,让人瞬间劝退。
这篇文章我带你把 BNF 彻底剥开看。不仅讲清楚它是什么,更会拆解它怎么用、怎么自己写、怎么在真正的解析器(Parser)里落地,还会聊很多课本上不会写、但工作里一定会踩的坑。无论你是正在啃编译原理的学生,还是想自己写一个模板引擎、配置文件解析器乃至一门玩具语言的开发者,这篇文章都值得你读完。
1. BNF 为什么而生:计算机需要一本“语法法典”
1.1 自然语言的歧义,计算机无法承受
先想一个问题:人和人交流的时候,句子不通顺,我们能靠常识脑补。比如“苹果我吃”,哪怕语序不合常规,对方也能意会。但计算机不行,它是一台没有“脑补”能力的笨机器。你给它一段程序,它只能在 0 和 1 的层面上机械地判断“这串字符符不符合我事先定义好的规则”。
问题在于:你怎么把规则告诉它?直接用文字描述吗?比如“一个 if 语句要先写 if,然后写括号,括号里放条件……”,这种描述是模糊的,而且面对嵌套、递归、复杂组合时,必然产生歧义。
所以,我们需要一种“语法法典”,一套机器可理解、人可书写的形式化规则,把“合法句子”的定义精确到不可辩解的地步。这就是 BNF 的历史使命。
1.2 从乔姆斯基体系说起:上下文无关文法
如果要给 BNF 找一个理论靠山,那就是乔姆斯基(语言学家,也是形式语言理论的奠基人)提出的文法分层体系。BNF 描述的是其中非常重要的一类——上下文无关文法(Context-Free Grammar,CFG)。
“上下文无关”的直觉理解是:一个语法成分能否被替换成别的东西,只取决于它自己,不取决于它在句子里的周围环境。举个例子,在程序语言里,一个“表达式”不管出现在赋值号右边、函数参数里还是 return 后面,它的语法展开规则完全一样,不需要看“上下文”脸色。这就大大简化了语法分析的难度,让解析器可以自上而下、机械地进行匹配。
1.3 一个 BNF 最简单的样子
先别把它想复杂。看看这个经典例子——描述一个“小数或整数”:
<number> ::= <digit> | <digit><number> <digit> ::= 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9这段规则的中文意思是:
- 一个“数字串”要么是一个“数字”,要么是一个“数字”后面再跟一个“数字串”。(这是一个递归定义)
- 一个“数字”可以是 0 到 9 中的任意一个。
注意到没有,你已经能感受到 BNF 的两个核心元素了:尖括号括起来的叫“非终结符”(它还需要继续展开),直接写出来的字面量叫“终结符”(它是最终句子里的真实字符)。::=表示“定义为”。
这套东西读起来一点不难,难的是把它和组织起成千上万行的语法规则,以及连成一条链的解析算法。
2. 拆开 BNF 的骨架:终结符、非终结符与产生式
2.1 三大核心要素
很多人一看到“终结符”“非终结符”就发怵,其实完全可以做个类比。想象你在拼乐高:
- 终结符(Terminal):最小的、不可再拆的积木颗粒,比如单个字母
a、数字1、符号+。在语法层面,它们是句子的最终外观。 - 非终结符(Nonterminal):一个“拼装蓝图”的名字,它并不直接出现在最终句子里,但它能展开成多个积木。比如“表达式”“语句”“函数定义”。
- 产生式(Production):一条“拼装说明”。它告诉系统:名字为 X 的蓝图,可以用哪些零件组合方式替换。
X ::= A B就是一条产生式。
用更贴近编译原理的话说:终结符构成语言的“词”(Token),非终结符构成语言的“句子结构”,产生式则定义了从结构到词、从词到句子的全部映射关系。
2.2 产生式的四类基本连接方式
写 BNF 的时候,一个产生式的右侧说白了就是下面几套组合手法:
| 写法 | 含义 | 例子 |
|---|---|---|
| 并列 | 必须按顺序一个接一个出现 | stmt ::= if ( expr ) stmt |
| 选择(竖线) | 满足其中之一即可 | `bool ::= true |
| 递归 | 自己包含自己,表达“任意多个” | `list ::= item |
| 空串 | 允许什么都不出现 | `opt_else ::= else part |
特别要点名的是“递归”和“空串”。递归是 BNF 表达“重复”的唯一手段。想想看,我们没有“重复一次或多次”这种现成运算符,所以要表达“一个数字串”,只能通过<digit> <number>这种自己引用自己的方式。空串通常用希腊字母 ε 表示,用来表达“可以有也可以没有”,它极其有用,但也极其容易引起歧义和解析冲突,后面会在实战部分展开。
2.3 为什么尖括号和::=不可随意更换
有些人觉得 BNF 记号不统一:有的书用::=,有的用=,有的用→。这只是历史习惯差异。关键是你定义“非终结符”的方式不能含糊。比如用尖括号括起来的<expr>一定是非终结符,而裸写的if、1、+一定是终结符。这套约定保证了规则的无歧义性。
如果你在写一个 Pratt Parser(一种处理表达式优先级的技巧),你可能会在代码里自定义一个 Parser 类,里面用方法调用模拟 BNF 的展开,这是后话。但无论什么工具,你的底层结构里必须有一张“终结符 → Token 类型”的对应表,以及一张“非终结符 → 产生式集合”的表。
3. 从 BNF 到 EBNF:上班之后真正在用的形态
3.1 BNF 的“反人类”之处
你可能会说:纯 BNF 递归表达“零个或多个”,写起来也太麻烦了。比如一门语言的标识符规则——以字母开头,后续可以是字母或数字。用纯 BNF 写是这样:
<identifier> ::= <letter> | <identifier> <letter> | <identifier> <digit>读起来啰嗦,写多了眼睛都花。这个问题在 20 世纪 70 年代被注意到了,于是出现了 EBNF(扩展巴科斯-诺尔范式)。EBNF 本身没有改变 BNF 的表达能力,但增加了一批“语法糖运算符”,大幅提升了可读性。
3.2 EBNF 的三大运算符
EBNF 最核心的增强是这三个表示法:
- 方括号
[ ]:表示“可选”。例如if_stmt ::= 'if' '(' condition ')' statement ['else' statement],意思是 else 分支可有可无。 - 花括号
{ }:表示“重复零次或多次”。例如block ::= '{' {statement} '}',表示花括号里可以放任意多条语句(包括零条)。 - 括号
( ):表示“分组”。例如term ::= factor {('*' | '/') factor},把乘除看成一个整体。
有了这三个运算符,前面的标识符规则直接写成:
<identifier> ::= <letter> { <letter> | <digit> }清爽多了。
3.3 练习:用 EBNF 描述一个小型 JSON 子集
我当年第一次上手练习 EBNF,是尝试用这套记号去描述 JSON 的一个子集。这里给你看我当时写的一条:
<json> ::= <object> | <array> <object> ::= '{' [<member> {',' <member>}] '}' <member> ::= <string> ':' <value> <array> ::= '[' [<value> {',' <value>}] ']' <value> ::= <string> | <number> | <object> | <array> | 'true' | 'false' | 'null' <string> ::= '"' {<char>} '"'写完之后你会立刻发现 EBNF 和纯 BNF 的差别:用 BNF 表达“逗号分隔的数组元素”需要额外引入两个非终结符来转接,而 EBNF 直接写成了[<value> {',' <value>}],一眼就能看出“第一个元素可选,后续元素必须以逗号开头”。
这套定义现在依然是我给学生和同事讲解析器时用的开场案例,原因是它覆盖了嵌套结构、分隔符、可选元素三种最常见的语法模式。
4. 从定义到推导:语法树和最左/最右推导
4.1 什么是推导
有了 BNF 定义之后,“如何判断一个句子属于这门语言”就成了一个机械的动作,叫推导(Derivation)。简单说就是:从一个起始非终结符出发,不断地用产生式右侧替换左侧的非终结符,直到整个序列只剩终结符。
比如用一组极其简单的规则:
<expr> ::= <expr> + <term> | <term> <term> ::= <number> <number> ::= 1 | 2 | 3要想推导出句子1 + 2,可以这样做:
<expr> → <expr> + <term> (用第一条产生式展开最外层 expr) → <term> + <term> (展开左边的 expr) → <number> + <term> (展开左边的 term) → 1 + <term> (展开左边的 number) → 1 + <number> (展开右边的 term) → 1 + 2 (展开右边的 number)推导过程本质上是在做“归约”的逆过程,也是一个解析器在内部干的事情。理解推导,你才算真正拿到了读懂 BNF 的钥匙。
4.2 最左推导与最右推导:为什么说它们重要
在上述每一步中,我每次都选择最左边的非终结符进行展开,这叫最左推导。如果每次选最右边的,就叫最右推导。
这两者有实际意义吗?有,而且不小。自顶向下的递归下降解析器(Recursive Descent Parser)天然对应最左推导,而自底向上的 LR 解析器则对应最右推导。换句话说:
你选择什么解析算法,你就“隐含地”选择了什么样的推导顺序。
掌握这一点,排查 parse 栈溢出或抱死循环(死循环)问题时,思路会清晰很多。
4.3 语法树:BNF 的动态表达
推导过程中如果把每一步替换关系画出来,会得到一个树形结构——语法树(Parse Tree),也叫具体语法树(CST)。树根是起始非终结符,树叶是终结符,内部节点是非终结符。
这也揭示了一个非常本质的对应:一棵语法树对应一个或多个推导序列(当文法存在歧义时),一个推导序列对应一棵语法树。所以,判断一个文法是否有歧义,最直接的方法就是看:是否存在某个句子,能画出两棵不同的语法树。这个概念在后面的运算符优先级处理中会再次用到。
5. 实战:用 BNF 设计表达式的优先级和结合性
5.1 学 BNF 不能总停留在“读”
很多人读文法读得溜,一到自己写就抓瞎。原因很简单:读只需要理解,写需要设计能力。而 BNF 设计中最经典、最受考验的能力,就是用分层定义处理运算符的优先级和结合性。
优先级的本质是:谁离根更远,谁就先算。比如1 + 2 * 3,乘法项2 * 3要先算,所以“乘法项”这个非终结符应该离终结符更近。
经典的四则运算文法(没有括号版本)是这样写的:
<expr> ::= <term> {('+' | '-') <term>} <term> ::= <factor> {('*' | '/') <factor>} <factor> ::= <number> | '(' <expr> ')'对照一下:expr位于最外层,它里面嵌套term,term里面嵌套factor。当解析1 + 2 * 3时:
expr → term + term → factor + term → 1 + term → 1 + factor * factor → 1 + 2 * 3注意,2 * 3在语法树中成了term节点的孩子,而加法在更外层。这正是乘法优先于加法的结构体现。
5.2 左结合与右结合:递归位置定生死
再来看结合性。1 - 2 - 3,我们当然希望是(1 - 2) - 3,这叫左结合。如何用 BNF 表达左结合?关键技巧是让递归出现在产生式右侧的左边:
<expr> ::= <expr> '-' <term> | <term>用这个规则推导1 - 2 - 3,得到的是:
expr → expr - term → (expr - term) - term → ((term) - term) - term这棵树的形状是向左下方倾斜的,天然对应左结合。
反过来,如果要表达右结合,比如幂运算的2^3^2等于2^(3^2),那就要让递归出现在产生式右侧的右边:
<expr> ::= <term> '^' <expr> | <term>这个“递归出现位置决定结合性”的技巧,可以说是 BNF 设计里性价比最高的一个知识点。不少面试题就喜欢拿它考人,比如:“如何用 BNF 定义右结合的赋值运算符?”答:把目标非终结符放在产生式右侧最右端。
5.3 为什么不能直接写<expr> ::= <number> <op> <expr> <op> <number>
很多新手会这样试图一步到位定义表达式:
<expr> ::= <number> { <op> <expr> }问题大了。这既模糊了优先级,也难以控制结合性。解析1 + 2 * 3时,可能出现多个不同的展开,形成多棵语法树——这在编译原理里叫二义性文法(Ambiguous Grammar)。绝大多数情况我们必须避免它,因为多棵语法树意味着多种程序含义,这会让编译器不知道该怎么生成代码。
正确的做法永远是那套分层法:每引入一级优先级,就新增一个非终结符层次。优先级有 n 档,就写 n+1 层(最后一层是原子项)。
6. 每个写解析器的人都会撞上的“左递归”和“回溯地狱”
6.1 自顶向下解析器为什么怕左递归
如果你真去实现一个递归下降解析器,你大概率会遇到一个“爆栈”问题:解析器无限递归,直接撑爆调用栈。
原因就在形如expr ::= expr '+' term的产生式上。递归下降解析器在解析非终结符expr时,会调用parse_expr(),而parse_expr()一开始就调用parse_expr(),形成自我无限递归。这就是著名的“左递归灾难”。
如果文法是你自己在设计,最直接的解决方案是“改写文法”。把左递归文法改写成等价的右递归或循环形式。一组通用做法是:
- 把
A ::= A α | β改写为A ::= β { α }(EBNF 写法)。
举个例子:
<expr> ::= <term> { ('+' | '-') <term> }这不就是我前面写的版本吗?对,当时我为了可读性已经用 EBNF 把左递归“隐形”地消掉了。如果你必须坚持用纯 BNF 写,那就写成:
<expr> ::= <term> <expr_tail> <expr_tail> ::= '+' <term> <expr_tail> | '-' <term> <expr_tail> | ε注意,ε 表示空串。这种引入“尾巴”非终结符的手法,标准叫法是“左递归消除(Left Recursion Removal)”。
6.2 还有一个大坑:公共前缀和回溯爆炸
即便你成功消除了左递归,如果两个产生式有公共前缀,递归下降解析器仍然可能陷入盲目回溯。例如:
<if_stmt> ::= 'if' '(' expr ')' stmt | 'if' '(' expr ')' stmt 'else' stmt解析器遇到if后,先按第一条规则展开,实际可能遇到else,然后发现不匹配,只好回溯重新选择第二条。这在小语法里还好,在几千条规则的编译器里,回溯代价可能大到无法接受。
解决办法之一就是提取左公因子(Left Factoring),例如改写成:
<if_stmt> ::= 'if' '(' expr ')' stmt ['else' stmt]实际上,大多数现代编程语言的设计者,会刻意让文法满足 LL(1) 条件(一眼即可决定选择的特性),尽量避免回溯。你在设计自己的语法时,也应遵循这个思路:让解析器在每一步只看一个 Token 就知道该选哪条产生式。
6.3 左递归并不总是坏事:左结合性的另一种实现思路
到这里你可能有些迷惑:我前面说,想表达左结合需要左递归;现在又说自顶向下解析器怕左递归。那到底怎么取舍?
实际上,在真正的解析器实现里,很多递归下降解析器会采用循环 + 优先级绑定算法来处理左结合运算符,也就是经典的 Pratt Parsing(表达式解析算法)或运算符优先级解析法。在这些算法中,文法本身可以是扁平的,但计算结合性是通过代码里的优先级表格实现的。也就是说:
BNF 是语法的“规范描述”,而解析算法是语法的“工程实现”。两者可以不完全一一对应。
这在面试或工程里非常常见。所以你一定要分清“概念层面的文法”与“工程层面的解析器”两件事。
7. 动手写一个玩具语言:从 BNF 到可运行的解析器
7.1 目标设定
理论聊得不少,接下来我们用 BNF 手写一个实用的小玩意:一个支持整数、加减乘除、括号和单行注释的表达式解析器。语言本身很小,但足够演示 BNF 到代码的迁移过程。
我选 Python 写,不是因为 Python 多高级,而是因为它表达力强,大家可以无痛阅读。
7.2 定义文法(EBNF)
先把规则写出来:
<expr> ::= <term> { ('+' | '-') <term> } <term> ::= <factor> { ('*' | '/') <factor> } <factor> ::= <number> | '(' <expr> ')' <number> ::= <digit> { <digit> } <digit> ::= '0' | ... | '9'注意,最外层expr处理加减,中间层term处理乘除,内层factor处理括号和原子值。这已经覆盖了我前面说到的优先级分层法。
7.3 代码骨架:Token 化
解析的第一步是分词(Tokenize),把原始字符串拆成带类型的 Token:
import re TOKEN_SPEC = [ ('NUMBER', r'\d+'), ('PLUS', r'\+'), ('MINUS', r'-'), ('STAR', r'\*'), ('SLASH', r'/'), ('LPAREN', r'\('), ('RPAREN', r'\)'), ('SKIP', r'\s+'), ] token_re = re.compile('|'.join(f'(?P<{name}>{pattern})' for name, pattern in TOKEN_SPEC)) def tokenize(text): tokens = [] pos = 0 while pos < len(text): m = token_re.match(text, pos) if not m: raise SyntaxError(f'无法识别的字符: {text[pos]!r}') kind = m.lastgroup if kind != 'SKIP': tokens.append((kind, m.group())) pos = m.end() tokens.append(('EOF', '')) return tokens这里的正则表达式其实就是对“终结符定义”的一种机械化翻译。每个 Token 类型对应 BNF 中的终结符。
7.4 递归下降解析器:直接把 BNF 翻译成函数
接下来是最有爽感的部分:每一条产生式,就是一个函数;每一次非终结符的展开,就是一次函数调用。直接对照着写:
class Parser: def __init__(self, tokens): self.tokens = tokens self.pos = 0 def peek(self): return self.tokens[self.pos] def consume(self, kind): token = self.peek() if token[0] != kind: raise SyntaxError(f'期望 {kind}, 但遇到 {token[0]}') self.pos += 1 return token def parse_expr(self): node = self.parse_term() while self.peek()[0] in ('PLUS', 'MINUS'): op = self.consume(self.peek()[0])[0] right = self.parse_term() node = ('BinOp', op, node, right) return node def parse_term(self): node = self.parse_factor() while self.peek()[0] in ('STAR', 'SLASH'): op = self.consume(self.peek()[0])[0] right = self.parse_factor() node = ('BinOp', op, node, right) return node def parse_factor(self): token = self.peek() if token[0] == 'NUMBER': self.consume('NUMBER') return ('Num', int(token[1])) if token[0] == 'LPAREN': self.consume('LPAREN') node = self.parse_expr() self.consume('RPAREN') return node raise SyntaxError(f'意外的 Token: {token}')看一眼parse_expr和 BNF 中的<expr> ::= <term> { ('+'|'-') <term> },对应关系几乎是一比一转译。parse_expr一开始调用parse_term,对应产生式右侧的第一个非终结符term;随后进入 while 循环,对应 EBNF 的花括号{ ... }。
7.5 求值器:走一遍语法树
解析完成后,我们对得到的语法树做一个简单的递归求值:
def evaluate(node): if node[0] == 'Num': return node[1] _, op, left, right = node lval, rval = evaluate(left), evaluate(right) if op == '+': return lval + rval if op == '-': return lval - rval if op == '*': return lval * rval if op == '/': if rval == 0: raise ZeroDivisionError('除数不能为零') return lval / rval到这一步,一个支持优先级和括号的迷你计算器已经完成了。测试一下:
text = "2 + 3 * (4 - 1)" tokens = tokenize(text) parser = Parser(tokens) ast = parser.parse_expr() print(evaluate(ast)) # 输出 11.0在代码里对应一下:3 * (4 - 1)被构造在term层,2 +被构造在expr层,所以计算顺序完全正确。这就是 BNF 分层定义的功劳。
7.6 代码里的隐藏细节
上面代码有个地方特别值得注意:parse_expr里循环while self.peek()[0] in ('PLUS', 'MINUS')。这对应 EBNF 的{ ... },实现的是“零个或多个”的重复。但为什么不是if?因为expr后面可能没有加减运算符,这时候应该直接结束返回。用循环是正确处理“零次”的唯一方式。
还有一个工程细节:由于我们处理的是左结合运算符,每次循环里都立刻把新节点作为左子树,再读入右侧项。也就是node = ('BinOp', op, node, right)。如果把左右反过来写,就变成右结合了。这个位置上的选择,正好呼应了第 5 节讲的“递归位置决定结合性”的工程落地。
8. 常见问题速查与现实工程里的坑
8.1 一张表记住高频问题
结合我带过的人和项目里的真实体验,把 BNF 相关最常见的求助问题整理如下:
| 现象 | 原因 | 解决办法 |
|---|---|---|
| 解析器爆栈(RecursionError) | 文法存在直接左递归 | 改写文法,或采用循环 + 递归下降写法 |
| 解析正确性时好时坏 | 文法二义性,多棵语法树 | 消除二义性:分层、提取左公因子、调整结合性 |
| 表达式优先级不对 | 分层不够,优先级高层被放到了内层 | 检查是否“优先级越高的运算符,对应非终结符越接近原子的终结符层” |
| 回溯爆炸,性能极差 | 同一非终结符多条产生式有公共前缀 | 提取左公因子,确保 LL(1) 性 |
| 空串导致死循环 | ε 产生式使用不当 | 检查产生式是否出现“能推导出 ε 的非终结符无限展开” |
8.2 现实工程:你其实不一定要手写 BNF 解析器
如果你在做的是公司项目,而不是练习玩具,你有更省力的选择:直接用现成的解析器生成器,比如 ANTLR、flex/bison、JavaCC、PLY、Lark 等。它们的共同逻辑是:你写一套接近 EBNF 的语法文件,工具自动生成解析器代码。
用 ANTLR 写表达式的一个片段大概是:
expr : term (( '+' | '-' ) term)* ; term : factor (( '*' | '/' ) factor)* ; factor : NUMBER | '(' expr ')' ;这几乎就是把第 5 节那道 EBNF 直接搬进工程。所以,把 BNF/EBNF 学扎实,绝不只是在啃理论,它直接影响到你能否顺畅使用这些工业级工具。
我个人的建议是:作为学习,一定要手写一次递归下降解析器。因为解析器生成器会把你和底层的机制隔离,导致你永远没有机会真正体会“非终结符=函数调用”“终结符=Token 匹配”这个和谐对应关系。一旦手写过,再看任何语法文件,都会有“原来代码里就是这么跑的”这种通透感。
8.3 一些你必须亲测的边界用例
这里给出几个我在测试自己写的解析器时必测的边界输入,大家可以对照跑一跑:
"3":单数字,能不能正常解析?"(1)":多层括号嵌套,能不能退栈干净?"1+2*3":优先级是否正确?"1*2+3":优先级分层反了过来,是否正确?"8/4/2":左结合性如何?会不会算成8/(4/2)?" 1 + 2 ":前后空格能否跳过?""(空串):是报错还是静默通过?"1+":结尾缺操作数,错误信息是否清晰?
我见过不少人,解析器写出来测简单的1+2没问题,一跑8/4/2就露馅(算成了4),就是因为结合性写反了。这些测试用例,几乎可以在不跑代码的情况下提前帮你发现八成问题。
9. 延伸:从 BNF 到形式语言,再到真正的编译器前端
9.1 BNF 只是一种描述工具,不是算法
很多人容易把 BNF 和“解析算法”混为一谈。这里想帮你厘清一个重要的层次:
- BNF/EBNF 是问题定义层,它负责说明“什么句子是合法的”。
- 递归下降、LL、LR、Pratt Parsing 等是问题求解层,它负责“如何高效判断并构造语法树”。
- 二者不能互相替代,但必须配套使用。你给 BNF 却没有算法,机器还是一头雾水;你有算法却没有 BNF,代码会变成无人能维护的黑魔法。
9.2 BNF 在学习路径中的位置
如果你刚踏入编译原理,一个合理的路径是:
- 先熟练阅读和手写 BNF/EBNF,掌握推导、语法树、结合性、递归。
- 再学词法分析,理解终结符如何由正则表达式产生 Token。
- 然后学 LL(1)、LR(1) 等解析算法,明白机器如何从输入字符串一步步归约或推导。
- 紧接着走上语法树之后的语义分析:类型检查、作用域解析、中间代码生成。
- 最后再回到代码生成和理解运行时机制。
可以看到,BNF 是整个技术栈的地基,但它不是全部。地基打牢后,上层建筑才有得谈。
9.3 关于“要不要背文法”
有读者可能问:“我需要把每种文法的写法背下来吗?”我的答案是不需要,也不太可能背得完。真正值得花时间的是掌握“文法设计通用原则”:
- 永远让优先级高的运算符出现在内层;
- 左结合用左侧递归或循环,右结合用右侧递归;
- 不要给同一个非终结符写下多个公共前缀的产生式;
- 不要让同一个句子产生两棵语法树;
- 尽可能让解析器只看一个 Token 就能决定走向。
这几条揣在脑子里,比记住任何 N 行语法模板都管用。实际面对任何新的编程语言或 DSL 时,你可以在不了解完整文档的情况下,仅凭一小段样例和这些原则,快速反推出大部分文法结构——我把这个方法叫“与语言作者对弈”。很多开源项目的语法文件,读起来就是这种感觉:通过 BNF 去理解设计者的意图和权衡,比直接看实现代码还要高效。
10. 最后想跟你分享的实操体会
BNF 这个东西,初看是符号游戏,但它是连接“人如何表达语法”与“机器如何解析语法”的桥梁。我个人的体会是:与其把 BNF 当成一门需要背的学问,不如把它当成一门需要写的技艺。每当你需要在项目里引入一个小的配置文件格式、一套规则引擎或者一门迷你查询语言,试着先用铅笔在纸上画一遍 EBNF,你会发现后续写解析器、写文档、和同事沟通都会顺畅很多。
踩过几次坑之后,我现在写任何语法文件都会带上三样东西:一是从一开始就注意避免左递归和公共前缀;二是永远保留一份“语法速查表”;三是设计好报错信息,让它能指出“我在解析哪个非终结符时发现哪个 Token 不匹配”。后一点的重要性,在你调试复杂嵌套表达式时会被放大十倍。
这个内容后续还可以这样扩展:把上面那个玩具计算器加上变量支持、加赋值语句、加函数调用,你就会一步步碰到“符号表”“作用域”这些真正编译器设计问题。到那时候再回头看 BNF,你会发现当初学的所有东西都在后台帮你兜底。