盖一栋楼,需要把建筑师的意图(设计图)翻译成施工图,再翻译成具体的砌砖指令。打一场官司,需要把当事人的诉求翻译成法律主张,再翻译成庭审陈述。编程也是一样——源码是人类意图的形式化表达,编译与执行是意图一步步固化为物理动作的全过程。

这个过程的核心问题不是”怎么翻译”,而是:意图如何在不同的抽象层次上保持一致,最终成为 CPU 可以执行的指令?

一、程序是什么?(本体论)

一个程序同时存在于多个层次:

程序员写的文本  →  语法结构(AST)  →  带类型的含义(IR)  →  机器指令  →  可观测行为

这不只是格式的变化,每一层都在回答一个不同的问题:哪个层次才是”真正的程序”?

不同语言社区给出了不同的答案:

  • C:程序是机器码,源码只是书写工具。C 标准定义了可观测行为,但允许编译器以任何方式实现——未定义行为(UB)是因为”你的意图”不够精确
  • JVM 语言(Java / Kotlin / Scala):程序是字节码,JVM 规范是合同。“一次编译,到处运行”的承诺绑定在字节码层,而非源码层
  • Python:程序是源码,其他都是实现细节。CPython、PyPy、Jython 可以有完全不同的内部机制,只要可观测行为一致
  • Haskell:程序是数学含义(指称语义),执行是附属品。GHC 可以彻底重写你的代码,只要输出相同

关键洞察:编译不是翻译,是判决。 翻译只改变形式,可以保留歧义。编译必须消除歧义——a + b 在源码里可以是整数加法、字符串拼接、自定义运算符重载,编译过程必须选定一个,不能保留含糊。每一次编译,编译器都在回答无数个源码没有明确回答的问题。

二、意义如何渐进固化(语义论)

编译管线的本质,不是把源码换成另一种格式,而是逐层收窄解释空间

源码     → 最大歧义(语法上允许多种解释)
  ↓ 词法/语法分析
AST      → 结构确定,语义未定
  ↓ 语义分析(类型检查、名字解析)
IR       → 意义固定,执行方式未定
  ↓ 优化(消除冗余抽象)
机器码    → 完全承诺,无歧义,无抽象
graph LR
    A["源码"] --> B["词法分析<br/>Lexer"]
    B --> C["Token 流"]
    C --> D["语法分析<br/>Parser"]
    D --> E["AST"]
    E --> F["语义分析<br/>类型检查 / 名字绑定"]
    F --> G["带类型的 IR"]
    G --> H["优化<br/>消除冗余抽象"]
    H --> I["代码生成"]
    I --> J["目标代码"]
    
    style A fill:#4dabf7,color:#fff
    style J fill:#69db7c,color:#fff
    style H fill:#ffa94d,color:#fff

词法与语法:识别结构

词法分析(Lexer)把字符流切分为 token,语法分析(Parser)把 token 组成 AST。这两步只处理形式——if (x > 0) { ... } 是什么结构,和它意味着什么无关。

关键算法:有限状态自动机(词法)、递归下降 / LR(k)(语法)。

语义分析:固化意义

语义分析是编译器真正”理解”程序的阶段:类型检查(这个操作合法吗?)、名字解析(这个 x 指的是哪个 x?)、作用域分析(这个变量在哪里有效?)。

这一步之后,歧义消失——每个名字有确定的绑定,每个操作有确定的类型含义。Hindley-Milner 类型推导算法在这里完成大部分工作(见 01 类型系统)。

中间表示:分离关注点

为什么需要 IR?它是前端(懂程序员语言)和后端(懂机器语言)之间的公共语言,分离了关注点。

SSA(Static Single Assignment):每个变量只赋值一次,用 φ 函数在控制流汇合点合并不同路径的值。这个约束极大简化了数据流分析和优化算法。几乎所有现代编译器优化(死代码消除、常量折叠、内联)都基于 SSA 形式。

LLVM IR:Rust、C/C++、Swift、Zig 共享的后端。LLVM 的设计哲学是前后端分离——各语言实现自己的前端,共享 LLVM 的优化和代码生成。Clang、rustc、swiftc 都是 LLVM 的前端。

字节码:虚拟机的指令集,比机器码高级,比源码低级。JVM 字节码跨平台的承诺绑定在这一层;Python .pyc 是编译后的 CPython 字节码——Python 是编译的,只是这个步骤对用户透明。

优化:消除冗余抽象

优化的哲学前提:在不改变可观测行为的前提下,编译器可以任意变换程序内部结构

常见优化技术:

  • 常量折叠(Constant Folding):编译时计算 2 * 3 * PI,运行时直接用结果
  • 内联(Inlining):把函数调用展开为函数体,消除调用开销,也让后续优化看到更多上下文
  • 死代码消除(DCE):删除不可达代码或结果未被使用的计算
  • 逃逸分析(Escape Analysis):判断对象是否”逃出”当前作用域,未逃逸则可以栈分配,省去 GC 压力
  • 循环向量化:把标量循环变成 SIMD 指令,单指令处理多个数据

Rust 能做激进优化的原因:类型系统保证了”无别名(no aliasing)“——编译器知道两个引用不会指向同一块内存,可以大胆重排指令。C/C++ 没有这个保证(除非用 restrict),因此相同的代码 Rust 编译出的性能有时优于 C。

三、何时固化意义?(时态论)

管线描述了”怎么固化”,时态论回答”什么时候固化”。这是 AOT / JIT / 解释的本质区别,代表三种截然不同的时间哲学:

graph TD
    A["源码"] --> B{"何时固化意义?"}
    B --> C["AOT<br/>执行前全部决定"]
    B --> D["JIT<br/>执行中动态决定"]
    B --> E["解释<br/>每步现场决定"]
    
    C --> C1["先知——静态分析预测未来<br/>Rust / C / Go / Swift"]
    D --> D1["实用主义——用证据优化<br/>V8(JS) / JVM(Java) / CLR(.NET)"]
    E --> E1["经验主义——观察现实再行动<br/>CPython / Ruby MRI"]
    
    style C fill:#69db7c,color:#fff
    style D fill:#ffa94d,color:#fff
    style E fill:#4dabf7,color:#fff

AOT:先知模型

执行前完成全部编译,生成原生机器码。 哲学立场:静态分析足以预测程序的所有行为,运行时不需要做决定。

代表:Rust、C、Go、Swift。

Go 有一个特殊设计选择:把编译速度本身作为一等目标。Go 刻意限制了泛型(早期)、放弃了模板元编程,换来秒级编译——这是 AOT 内部的取舍,不是 AOT 与其他策略的取舍。

AOT 的代价:无法利用运行时信息。一个函数在实际调用中 99% 都收到整数,但 AOT 编译器不知道,只能生成通用版本。

JIT:实用主义模型

先以基线速度运行,发现热点后动态编译优化。 哲学立场:运行时的真实行为比任何静态声明都可信,用证据优化比用推断优化更精确。

代表:V8(JavaScript)、JVM HotSpot(Java)、CLR(.NET)。

以 V8 为例的分层策略:

  1. Ignition(解释器):快速启动,生成字节码立即执行
  2. Sparkplug(基线 JIT):快速编译,生成未优化机器码
  3. Maglev / TurboFan(优化 JIT):对热点函数激进优化,内联、去虚化、类型特化
  4. 反优化(Deoptimization):优化假设失效时(如函数突然收到对象而非整数),回退到基线

JIT 的代价:预热成本(冷启动阶段性能低)、内存占用(需要同时保存字节码和机器码)、性能抖动(GC + 编译并发进行)。

解释:经验主义模型

不预先编译,逐条执行指令。 哲学立场:最大动态性,每步都保留改变语义的可能。

CPython 的 Python 是编译的(源码 → .pyc 字节码),但字节码由 PVM 逐条解释,不生成机器码。这个设计解释了为什么 Python 可以支持 eval、运行时 monkey-patching、动态导入——解释器每步执行前都能观察当前状态。

解释的代价:每一步都有解释开销(取指令、解码、分派),通常比 JIT 慢 10–100×。

特例:编译期执行

Zig 的 comptime 主动模糊了编译/运行时的界限:任何标记为 comptime 的代码在编译期执行,可以生成类型、展开循环、计算常量。这不是宏,是真正的编译期执行,完整的语言语义在编译期可用。

Zig 的哲学:与其在语言里加宏系统,不如让编译器直接执行代码。

四、谁来优化?(责任论)

连回我们的核心主线——优化的责任归属

责任方哲学立场代表代价
程序员个体主义——你写代码,你优化C 汇编内联、register 提示心智负担极高,错误致命
编译器合同主义——类型系统证明,编译器执行GHC(Haskell)、rustc + LLVM编译慢,优化不透明
运行时集体主义——运行时统一管理,按实际行为优化JVM JIT、V8 TurboFan预热成本,内存开销

Rust 编译慢的本质:rustc 在编译期做了其他语言留给运行时做的事——借用检查、泛型单态化(每种类型生成一份代码)、生命周期验证,再交给 LLVM 做机器相关优化。慢不是因为效率低,是因为工作量大、提前做了。

JVM JIT 的潜力:因为有运行时类型信息,JVM 可以做 AOT 无法做的优化——去虚化(devirtualization,把虚函数调用变成直接调用,前提是运行时观察到只有一种实现),再内联。高度优化的 Java 服务端代码,峰值性能可以超过手写 C++。

TypeScript 的两条管线

  • 开发期(tsc / Vite):优先速度,快速类型检查 + HMR,不做重优化
  • 生产期(esbuild / rollup):优先体积和性能,tree-shaking、压缩、代码分割

五、类型与编译的交汇

编译与类型系统不是独立的两件事——类型是承诺,编译是承诺的执行者(见 01 类型系统 契约层)。

三种类型在编译期的命运:

类型擦除(Type Erasure):TypeScript 和 Java 泛型。类型信息在编译后消失,运行时无类型。TypeScript 的类型只服务于编译器,不进入运行时——这是与 JavaScript 生态兼容的必要妥协,也是零运行时开销的设计智慧。代价:运行时无法反射类型,需要 Zod 等库补偿。

编译时解析(Compile-time Resolution):Rust 的单态化。泛型 Vec<i32>Vec<String> 编译出两份独立的机器码,类型信息用于优化后消失。零运行时开销,但二进制体积膨胀。

类型保留(Type Retention):Python 的 __annotations__、C# 的反射、Java 的 Class<?> 对象。类型信息保留到运行时,支持反射和动态分派。代价是运行时开销和二进制体积。

六、多语对照

设计问题代表语言核心哲学
AOT + 零运行时C, Rust编译时解决一切,部署产物最简单(单一二进制)
快速 AOTGo编译速度本身是设计目标,秒级编译 > 极致优化
JIT + 虚拟机Java (JVM), JavaScript (V8)跨平台 + 运行时自适应,峰值性能可超 AOT
纯解释CPython, Ruby MRI最大动态性,eval、monkey-patch 完全支持
编译期执行Zig (comptime)彻底模糊编译/运行时界限,编译器即解释器
两阶段类型TypeScript类型只服务编译期,运行时零开销
维度Python (CPython)TypeScript (V8)Rust (LLVM)
执行策略解释执行(字节码 + PVM)tsc 转译 → V8 JITAOT 编译到原生码
类型检查时机运行时(可选 mypy 静态)编译时(tsc 强制)编译时(rustc 强制)
类型信息命运运行时保留编译后擦除编译后擦除(单态化)
优化责任运行时(CPython + 可选 JIT)运行时(V8 TurboFan)编译器(LLVM 后端)
部署产物源码 + 解释器.js bundle + Node/浏览器单一二进制
启动速度快(V8 快照)极快
峰值性能极高
开发循环极快(写完即跑)快(Vite HMR)中(编译等待)

三语的编译策略差异,本质上是「何时做决定」与「谁来负责」两个哲学问题的不同答案——也是 01 类型系统 契约层在执行维度的工程落地。


延伸阅读

理论

  • Aho, Lam, Sethi, Ullman《Compilers: Principles, Techniques, and Tools》(Dragon Book)
  • Lattner & Adve《LLVM: A Compilation Framework for Lifelong Program Analysis & Transformation》(2004)
  • Cytron et al.《Efficiently Computing Static Single Assignment Form and the Control Dependence Graph》(1991)

实践


关联 meta 维度