卷 III · 树CH 09深度 09/20

优先级:为什么 1 + 2 * 3 不会算错

第 7 章我们发现 1 + 2 * 3 有歧义,能搭出两棵树。第 8 章我们照着分层文法写出了解析器。现在到了揭穿魔法的时刻:那叠「一层调用更紧一层」的函数,凭什么保证只搭出唯一正确的那棵树?答案漂亮得让人想笑 —— 优先级根本不是一张要背的表,它被编织进了函数的调用深度里。

优先级结合性分层文法Pratt

魔法的核心:越紧的,在文法里越深

再看一眼那叠文法,这次只盯着它们的顺序

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;
}

左结合用循环(往左攒),右结合用递归(往右伸)。就这一个区别。结合性和优先级一样,全是写法的产物,不是一张要背的表。

回流

⟲ 回流 · 那张你背过的优先级表,和它的一个著名 bug

你在别的语言里查过、背过、被坑过的「运算符优先级表」,其实就是别人替你写好的这叠分层文法的一个人类可读摘要。它不是玄学,是文法结构的一张速查卡。

C 语言有个著名的优先级 bug:a & b == c 你以为是 (a & b) == c,实际是 a & (b == c) —— 因为 C 把 == 的优先级设得比 & 高。Dennis Ritchie 本人后来承认这是个设计错误,但改不动了(太多代码依赖它)。用这一章的话说:他们在文法里把 == 的规则放得比 & 更深了一层,仅此而已 —— 一个层次放错,坑了几十年。

而 Lisp 干脆没有优先级表(如果你读过《求值》那本书):(* 2 3) 结构完全显式,压根没有「谁先谁后」这个问题 —— 它把消除歧义的活,从解析器甩给了你多打的那几个括号。两种语言,两种权衡:要么解析器背下这套层次,要么让你把结构写明白。而 CI 的 clox(以及大多数手写解析器)会用一种更紧凑的写法叫 Pratt 解析来实现同样的优先级 —— 那是同一个思想的另一种打包方式,你在这本书打好的底子,到那里会一点就通。

这一章的一句话

优先级不是一张要背的表,是文法的层次:越紧的运算符在文法里越深、在树上离叶子越近、求值时越先算 —— 左结合用循环,右结合用递归,全是写法。

解析器的「快乐路径」齐活了。下一章收尾:当代码有语法错误,解析器怎么不被一个错带崩、还能把话说清楚。