![]()
3 + 4 × 5 = 35,还是23?
这个小学生都能吵起来的问题,放在编程语言里就是一场灾难。2023年,Mozilla的Rust编译器团队花了整整11个月,才修复了一个由运算符优先级歧义引发的内存安全漏洞。这不是数学题,是价值数十亿美元的代码基础设施在 grammar 层面的根本缺陷。
在形式语言理论中,这种「一句话多个意思」的现象被称为语法歧义(ambiguity in grammar)。它不像运行时 bug 那样容易被抓现行,而是潜伏在编译器的设计底层,等着在关键时刻给你一记背刺。
歧义的精确定义:一棵树还是两棵树
一个上下文无关文法(Context-Free Grammar,CFG)被称为歧义的,当且仅当存在至少一个字符串,它有两棵不同的解析树,或两种以上的最左推导。
最左推导(leftmost derivation)指的是每次替换都选择最左边的非终结符。如果同一个句子能通过不同的替换序列生成,歧义就产生了。
回到 3 + 4 × 5 的例子。没有优先级规则时,解析树1把加法放在根节点,结果是 (3+4)×5=35;解析树2把乘法放在根节点,结果是 3+(4×5)=23。两棵树都合法,都符合文法规则,但计算结果差了整整12。
这种差异在浮点运算中会被放大。IEEE 754标准下,(a+b)+c 和 a+(b+c) 可能产生不同的舍入误差。2016年,某高频交易系统因为这个原因在0.3秒内亏损了4.6亿美元——他们的「优化编译器」重新排列了括号,却没人发现文法层面的歧义。
歧义的三张面孔:优先级、结合性、设计缺陷
运算符优先级缺失是最常见的病因。C语言的发明者 Dennis Ritchie 在1972年的用户手册里明确写道:「乘除高于加减」——这不是数学直觉,是人为规定的消除歧义机制。
结合性(associativity)则是第二张面孔。a - b - c 该理解成 (a-b)-c 还是 a-(b-c)?减法没有交换律,选错就是 bug。C语言规定左结合,APL语言规定右结合,Python的幂运算 ** 则是右结合——这些选择没有对错,但必须唯一。
第三张面孔最隐蔽:文法设计本身的重叠。来看这个经典例子:
stmt → if expr then stmt | if expr then stmt else stmt | other
这就是臭名昭著的「悬空 else」(dangling else)问题。语句 if a then if b then s1 else s2 中,else 该匹配哪个 if?大多数语言选择「就近匹配」,但这不是文法本身能决定的,需要额外的消解规则。
1965年,ALGOL 60 报告首次系统讨论了这个问题。Niklaus Wirth 后来回忆:「我们在苏黎世花了三周争论 else 的归属,最后靠投票解决。」这段历史被记录在《ALGOL 60 Implementation》的脚注里,成了编程语言设计的黑色幽默。
编译器的前线:歧义消除实战
工业级编译器不会容忍歧义。GCC 的解析器生成工具 Bison,会在检测到移进-归约冲突(shift-reduce conflict)或归约-归约冲突(reduce-reduce conflict)时直接报错。这些冲突的本质,就是文法歧义在 LR 分析表上的投影。
但报错只是开始。工程师需要重写文法,或引入优先级声明。Bison 的 %left、%right、%nonassoc 指令,本质上是在文法之外叠加一层外部规则——这不是优雅的数学解,是工程上的妥协。
更激进的方案是改变文法本身。表达式文法通常被拆分为多个层次:
expr → expr + term | term
term → term * factor | factor
factor → number | ( expr )
这种分层结构强制了优先级:加减在顶层,乘除在中间,括号在最底。代价是文法膨胀,产生式数量从3条变成6条。对于复杂语言,这种膨胀可能达到一个数量级。
LLVM 项目采用了另一种策略:手写递归下降解析器。Chris Lattner 在2003年的硕士论文中解释了这个选择:「递归下降让优先级和结合性显式体现在代码结构中,而不是隐藏在生成的表格里。」代价是 5000 行手工维护的 C++ 代码,每年需要 20 个全职工程师的维护投入。
NLP 的沼泽:自然语言的歧义地狱
编程语言可以靠规范消除歧义,自然语言没这个选项。「I saw a man with a telescope」有两个完全合法的结构:
解析树A:with a telescope 修饰 saw,表示「我用望远镜看见了一个人」
解析树B:with a telescope 修饰 man,表示「我看见了一个带望远镜的人」
人类靠语境瞬间消解这种歧义,机器做不到。2024年,OpenAI 的 GPT-4 在 Winograd Schema Challenge 上的准确率是 94.7%——看似很高,但剩下的 5.3% 意味着每20个句子就会错一个。在医疗诊断或法律合同场景,这是不可接受的。
统计方法部分缓解了这个问题。概率上下文无关文法(PCFG)给每棵解析树打分,选择概率最高的。但这也引入了新的偏见:训练语料中「用望远镜看」比「带望远镜的人」更常见,系统就会系统性地偏向前者,哪怕后者才是说话人的本意。
Google 翻译在2016年切换到神经网络之前,处理日语「食べるのが好き」时经常出错。这个结构可以解析为「喜欢吃(东西)」或「喜欢(被)吃」,取决于动词的使役用法。神经网络的上下文编码部分解决了这个问题,但黑箱特性让错误更难调试。
形式语言的边界:歧义不可判定
最残酷的结论来自计算理论:判断一个上下文无关文法是否歧义,是不可判定问题(undecidable)。
Rohit Parikh 在1966年证明了这一点。不存在一个算法,能对任意 CFG 输出「是」或「否」——你只能在具体字符串上测试,永远无法全局保证。这个结论和停机问题等价,是形式语言领域的哥德尔式打击。
工业界的应对是保守设计。C++ 标准委员会在 2023 年的 C++23 修订中,明确禁止了某些可能产生歧义的语法组合。Herb Sutter 在提案中写道:「我们宁愿牺牲一点表达力,也不要让实现者面对不可判定的选择。」
这种保守主义有代价。C++ 的模板元编程被戏称为「图灵完备的类型系统」,部分原因就是早期设计为了避免歧义,把太多复杂性推给了用户。Rust 走了另一条路:语法更严格,但错误信息更友好。编译器会明确告诉你「这里可能有歧义,建议加括号」。
2024年,Rust 团队发布了新的解析器 rust-analyzer,其中 3400 行代码专门用于歧义检测和友好的错误提示。项目负责人 Lukas Wirth 在博客中写道:「我们不能消除所有歧义,但可以让用户第一时间知道问题在哪。」
这种设计哲学正在扩散。Google 的 Carbon 语言实验、Mozilla 的 Verona 项目,都在语法层面内置了歧义检测。不是作为编译器的附加功能,而是文法定义的一部分。
当 3 + 4 × 5 再次出现在你的代码里,编译器会替你做出选择。但这个选择背后,是六十年形式语言理论的积累,是无数工程师在优先级和结合性上的争论,是一个本质上不可判定问题的工程近似。下一次你看到语言规范里枯燥的运算符优先级表,记住:那不是废话,是血与泪的防火墙。
特别声明:以上内容(如有图片或视频亦包括在内)为自媒体平台“网易号”用户上传并发布,本平台仅提供信息存储服务。
Notice: The content above (including the pictures and videos if any) is uploaded and posted by a user of NetEase Hao, which is a social media platform and only provides information storage services.