Lua 的表达式解析器为什么不用 LR?
编译原理课上教的 LR 分析器,当然是经典。但打开 Lua 的源码,你会看到
思路很直白:给每个 token 绑定两个优先级,一个「左绑定力」(left binding power),一个优先级比它高时才继续往下吃「右绑定力」(right binding power)。解析器从左到右扫描 token,遇到操作符时,比较当前操作符的左绑定力和栈顶操作符的右绑定力——左边强就先归约,右边强就先递归。二元运算、一元运算、函数调用、下标访问,全都能塞进同一套框架,不需要额外的规则表或状态机。
Lua 的实现尤其精简。
为什么 Pratt Parser 在实践里这么受欢迎?V8 的 parser、Rust 的 parser、甚至 GCC 后来也逐步转向手写递归下降。原因之一是错误信息——手写递归下降可以精确控制在报错位置给出有意义的提示,而 LR 生成器的报错出了名的难读。原因之二是扩展性——加一个新运算符?插入一行
但它也有代价。
下次再看到编译课上那张 LR 状态转换表的时候,可以顺手翻一下
#CS
编译原理课上教的 LR 分析器,当然是经典。但打开 Lua 的源码,你会看到
lparser.c 里处理表达式的是一套完全不同的打法——Pratt Parsing,也叫自顶向下算符优先解析。思路很直白:给每个 token 绑定两个优先级,一个「左绑定力」(left binding power),一个优先级比它高时才继续往下吃「右绑定力」(right binding power)。解析器从左到右扫描 token,遇到操作符时,比较当前操作符的左绑定力和栈顶操作符的右绑定力——左边强就先归约,右边强就先递归。二元运算、一元运算、函数调用、下标访问,全都能塞进同一套框架,不需要额外的规则表或状态机。
Lua 的实现尤其精简。
subexpr 函数收一个 limit 参数,表示"当前能接受的最低优先级",递归时把操作符的右绑定力作为新的 limit 传下去。binop 和 unop 的处理逻辑加在一起不到 80 行,整个表达式解析就搞定了。没有冲突、没有归约/移进的抉择,更没有用生成器吐出几千行 C 代码。为什么 Pratt Parser 在实践里这么受欢迎?V8 的 parser、Rust 的 parser、甚至 GCC 后来也逐步转向手写递归下降。原因之一是错误信息——手写递归下降可以精确控制在报错位置给出有意义的提示,而 LR 生成器的报错出了名的难读。原因之二是扩展性——加一个新运算符?插入一行
case 就行,不用重新生成再排查 shift/reduce conflict。但它也有代价。
E → E + E | ida < b < c。而且这类手写 parser 的正确性没法像 LR 那样由形式化工具自动验证,全靠人肉 review 和测试。下次再看到编译课上那张 LR 状态转换表的时候,可以顺手翻一下
lparser.c,看看真实世界的 parser 是怎么写的。#CS