优先级:为什么 1 + 2 * 3 不会算错
第 7 章我们发现 1 + 2 * 3 有歧义,能搭出两棵树。第 8 章我们照着分层文法写出了解析器。现在到了揭穿魔法的时刻:那叠「一层调用更紧一层」的函数,凭什么保证只搭出唯一正确的那棵树?答案漂亮得让人想笑 —— 优先级根本不是一张要背的表,它被编织进了函数的调用深度里。
魔法的核心:越紧的,在文法里越深
再看一眼那叠文法,这次只盯着它们的顺序:
expression → equality ← 优先级最低 equality → comparison comparison → term term → factor ← 加减 factor → unary ← 乘除(更紧) unary → primary ← 取负、取非 primary → 字面量 / 变量 / ( ) ← 最紧,叶子
每一条规则,都调用「比自己更紧一层」的规则:term(加减)调用 factor(乘除)。这个调用顺序,就是全部秘密。跟着走一遍 1 + 2 * 3:
- 解析从
term()开始(管加减)。它做的第一件事,是调用factor()去拿左操作数。 factor()(管乘除)拿到1,往后看是+—— 不是它管的乘除,它就不吃,把1原样返回。- 回到
term(),它看到+,是它管的,吃掉。然后调用factor()去拿右操作数。 - 关键在这一步:这次
factor()拿到2,往后看是*—— 是它管的!它贪心地吃下2 * 3,打包成一个乘法节点,再返回。 - 于是
term()手里的右操作数,是整个(2 * 3),加法节点的右孩子是它。树成了(1 + (2 * 3))。
看懂了吗?因为 factor(乘除)在调用链里更深,它总是先把自己那摊操作数抓走、打包好,才轮到外层的 term(加减)来处理剩下的。更紧的运算符,在树上离叶子更近;离叶子越近,求值时越先被算。「乘法优先」这件事,就这样被物理地编织进了树的形状 —— 没有任何一张优先级表被查过。
这是这一章、乃至整个卷 III 最值得带走的一句话。四个东西是同一件事的四张脸:
你把某个运算符在文法里放得越深 → 它在语法树里就离叶子越近 → 求值器遍历时就越先算到它 → 它的「优先级」就越高。你不需要在代码里写任何 if (优先级更高) 的判断。优先级不是一段逻辑,是文法结构的副产品。改优先级,只需要调整文法里规则的先后顺序。
亲手验证
下面这台是真的解析器。你输入表达式,它把你写的东西加满括号给你看它到底怎么理解的,再画出对应的树,最后用真求值器算出结果:
试试 1 + 2 * 3(看它加成 (1 + (2 * 3)))和 1 * 2 + 3(看它加成 ((1 * 2) + 3))—— 无论加号乘号谁在前,乘法总是被括进更内层。这就是「乘法优先」在树上的样子,它不是规定,是文法层次算出来的结果。
结合性:1 - 2 - 3 是从左往右
优先级管「不同运算符谁先」,还有个问题:同一个运算符连用,从哪边开始?1 - 2 - 3 是 (1 - 2) - 3 = -4,还是 1 - (2 - 3) = 2?答案是前者 —— 减法是左结合的。这个「从左往右」,也被编码进了代码,就藏在第 8 章那个 while 循环里:
function term() {
let expr = factor(); // 先拿到 1
while (match("MINUS", "PLUS")) {
const op = previous();
const right = factor();
expr = { k: "binary", op, left: expr, right }; // 把已有的塞进 left
}
return expr;
}
循环第一轮:expr = (1 - 2)。第二轮:expr = ((1 - 2) - 3) —— 每一轮都把「已经攒好的一大坨」放进新节点的左孩子。于是树往左长,这就是左结合。在上面的 Demo 里输入 1 - 2 - 3,你会看到它加成 ((1 - 2) - 3)。
那右结合(比如赋值 a = b = c,是 a = (b = c))怎么写?不用 while,用递归 —— 让规则调用自己而不是循环:
function assignment() {
const expr = or();
if (match("EQ")) {
const value = assignment(); // 递归调用自己 → 往右靠
return { k: "assign", name: expr.name, value };
}
return expr;
}
左结合用循环(往左攒),右结合用递归(往右伸)。就这一个区别。结合性和优先级一样,全是写法的产物,不是一张要背的表。
回流
你在别的语言里查过、背过、被坑过的「运算符优先级表」,其实就是别人替你写好的这叠分层文法的一个人类可读摘要。它不是玄学,是文法结构的一张速查卡。
C 语言有个著名的优先级 bug:a & b == c 你以为是 (a & b) == c,实际是 a & (b == c) —— 因为 C 把 == 的优先级设得比 & 高。Dennis Ritchie 本人后来承认这是个设计错误,但改不动了(太多代码依赖它)。用这一章的话说:他们在文法里把 == 的规则放得比 & 更深了一层,仅此而已 —— 一个层次放错,坑了几十年。
而 Lisp 干脆没有优先级表(如果你读过《求值》那本书):(* 2 3) 结构完全显式,压根没有「谁先谁后」这个问题 —— 它把消除歧义的活,从解析器甩给了你多打的那几个括号。两种语言,两种权衡:要么解析器背下这套层次,要么让你把结构写明白。而 CI 的 clox(以及大多数手写解析器)会用一种更紧凑的写法叫 Pratt 解析来实现同样的优先级 —— 那是同一个思想的另一种打包方式,你在这本书打好的底子,到那里会一点就通。
这一章的一句话
优先级不是一张要背的表,是文法的层次:越紧的运算符在文法里越深、在树上离叶子越近、求值时越先算 —— 左结合用循环,右结合用递归,全是写法。
解析器的「快乐路径」齐活了。下一章收尾:当代码有语法错误,解析器怎么不被一个错带崩、还能把话说清楚。