← 返回信息流
AI 资讯Hacker News·3 天前

不是我的错,是编译器的问题

原标题:It's not me, it's the compiler

速览

本文探讨了编程中的常见现象:开发者常背锅的Bug,实际原因出在编译器上。文章列举了编译器的奇怪行为、优化导致的问题以及内存管理等案例。它提醒程序员不要盲目怀疑自己,也要学会排查工具链。

AI 深度解读

背景

这篇文章来自 Hacker News,作者是一位正在开发自制 JavaScript 引擎(项目名为 joe)的开发者。他在一个周六晚上重构解析器(parser)代码时,遇到了一个出乎意料的 bug:一个看似等价的重构导致解析结果出错,而编译器生成的汇编代码也出现了诡异的变化。作者以此生动地展示了“不是我的问题,是编译器的问题”这句程序员口头禅在极端情况下确实会成真。

核心内容

作者原本的解析器中有两个函数:consumeconsume_test,后者是一个常见的模式——先通过 peek 检查当前 token 是否等于预期类型,如果相等则消耗掉该 token 并返回 true,否则返回 false。最初的实现使用了 if 分支:

impl LexerConsumer {
    #[inline]
    pub fn consume(&mut self) {
        self.0 += 1;
    }
    #[inline]
    pub fn consume_test(&mut self, store: &LexStore, expected: TokenKind) -> bool {
        if self.peek(store) == expected {
            self.consume();
            true
        } else {
            false
        }
    }
}

作者不满意这段代码的形状——每个分支都返回比较值,于是他把代码改成了直接利用 bool as u32 转换来自增索引,并直接返回比较结果:

#[inline]
pub fn consume_test(&mut self, store: &LexStore, expected: TokenKind) -> bool {
    let x = self.peek(store) == expected;
    self.0 += x as u32;
    x
}

作者单独检查了这段函数生成的汇编,发现它比原版短了几个字节,并且没有分支,感觉很满意。然而当作者用这条新版本解析一个简单的 for 循环语句时,却报了错:

> joe parse - 'for (var lol; false; false) {}'
Error was found
Diagnostic { kind: E079, flag: Flag(11529215046068469773), byte: 5, current_token: Var }
Parse error

作者怀疑是不是 bool as u32 的语义出了问题,于是把它改写成了显式的 if x { 1 } else { 0 }

#[inline]
pub fn consume_test(&mut self, store: &LexStore, expected: TokenKind) -> bool {
    let x = self.peek(store) == expected;
    self.0 += if x { 1 } else { 0 };
    x
}

这次解析竟然正常了,并且输出了正确的 AST:

Parsed 8 nodes in 14ns
...
POS=000 [7] Script
POS=000 [6] ForStatement
POS=009 [1] VariableDeclaration @A0
...

作者随即怀疑自己是否记错了布尔值转换的语义,但回忆确认自己写过数百次类似写法,因此断定是编译器的问题。他借助自己定制的构建系统(基于 TypeScript 和 Deno,完全自行管理 Rust 编译流程),直接反汇编了核心解析函数 ForOrInOfStatement。以下是作者反汇编出来的结果(部分,已做格式整理):

000000000021e290 <joe::fe::parser_handlers::ForOrInOfStatement>:
21e290: ff c6                         inc esi
21e292: 89 f0                         mov eax,esi
21e294: 83 e0 3f                      and eax,0x3f
21e297: 0f b6 04 07                   movzx eax,BYTE PTR [rdi+rax*1]
21e29b: 83 f8 04                      cmp eax,0x4          # cmp TokenKind::LParen
21e29e: ,----- 75 70                  jne 21e310 <joe::fe::parser_handlers::ForOrInOfStatement+0x80>
21e2a0: |    4d 85 c0                test r8,r8
...
21e310: '----> 48 89 ca               mov rdx,rcx
21e313: 83 f8 6e                      cmp eax,0x6e         # cmp TokenKind::Await
21e316: ,-- 75 15                     jne 21e32d
...

作者发现,原来的 match 语句竟然“消失”了!编译器在优化时,将 ForOrInOfStatement 中对 consume_test 的调用与后面的 match lex.peek(store) 进行了合并,移除了显式的 match 控制流,直接通过跳跃实现。由此产生的汇编代码非常紧凑,但也改变了行为——具体来说,consume_testtoken == LParen 时本该只前进一次,但优化后它可能跳过了某些状态,导致后续解析逻辑出错。而显式的 if x { 1 } else { 0 } 写法则给编译器提供了不同的优化线索,使其没有做相同的激进合并,从而保持了正确行为。

作者最后指出,他原本的代码中 consume_testpeek 构成了一类常见的编译器优化模式:当函数被内联后,编译器可能会识别出先比较再跳跃的模式,并将其与之后的其他控制流融合,但若融合方式不对(比如改变了 side-effect 的顺序或条件),便会引入难以察觉的 bug。

关键要点

  • Rust 中 bool as u32 的语义true 转换为 1false 转换为 0,这是明确定义且可移植的。作者最初的写法在语义上完全等价于 if x { 1 } else { 0 }
  • 编译器内联与优化合并#[inline] 提示编译器将函数内联,随后 LLVM(Rust 后端)会进行激进优化,例如将连续的比较-分支合并成新的控制流结构。本例中,编译器将 consume_test 中的比较与外围 match 合并,导致部分分支被重排,产生了错误的逻辑。
  • 汇编差异:用 bool as u32 版本生成的汇编更短、无分支,但编译器因此认为它可以安全地重排后续代码;用 if x { 1 } else { 0 } 版本则保留了显式分支,阻止了这种重排。
  • 调试手段:作者使用了自建的构建系统(非 Cargo)并配合 objdump 直接查看特定函数的反汇编,快速定位了问题根源。
  • 解析器架构:作者解析器使用 LexerConsumer 作为游标,通过 peekconsumeconsume_test 等接口进行 token 流控制,ForOrInOfStatement 等解析函数以递归下降方式工作。

意义与影响

  • 编译器不是万能的:现代优化编译器(如 LLVM)非常复杂,有时会以程序员意想不到的方式重组代码,产生语义上等价的假象,实际却改变了副作用顺序或条件跳转方向。
  • 低级调试能力的重要性:当高级语言行为与直觉不符时,查看生成的汇编代码是最终鉴别手段。作者拥有定制构建系统和反汇编能力,快速解决了问题;在更常见的开发环境中(如 Cargo),这类问题可能需要更长时间才能定位。
  • Rust 安全性并非绝对:Rust 保证了内存安全,但逻辑错误仍可能因编译器优化而引入。尤其是涉及 unsafe 或底层类型转换(如 bool as u32 配合指针运算)时,程序员需对优化行为保持警惕。
  • 对性能与可读性权衡的启示:作者最初为了美观和微小性能提升(少几个字节、无分支)而改写了代码,结果引入了 bug。这提醒开发者,在非关键路径上优先保证逻辑正确和可维护性,而非盲目追求微优化。
  • 工程实践:作者展示了一种反直觉的情况——两个语义等价的写法被编译器不同对待。社区中类似现象(如 if vs. match 的优化差异、bool as int vs. bool * 1 等)时有发生,这提醒我们在重构时应运行完整的测试套件,
查看原文 →parsa.wtf