CC 咖啡猫的工作空间 Coding Space

编译原理

这篇解决什么问题

当你按下「运行」按钮时,源代码并不会被 CPU 直接理解。CPU 只执行机器指令,编程语言需要经过编译器、解释器、虚拟机或 JIT 引擎,才能变成可执行的指令或运行时动作。

编译原理要回答的是:源代码如何从一段文本,变成结构化语法树、中间表示、优化后的代码,最后变成机器码或字节码。

理解编译原理,不是为了每个人都去写编译器,而是为了看懂这些日常问题:

  • 为什么有些报错发生在编译期,有些发生在运行时。
  • 为什么 TypeScript 能提前发现类型问题。
  • 为什么 Babel、SWC、ESLint、Prettier 都离不开 AST。
  • 为什么 Vite / Webpack 构建失败时要看转换链路。
  • 为什么 JavaScript 明明是动态语言,V8 仍然能跑得很快。

学完你会掌握

  • 编译器的六步流水线:词法、语法、语义、IR、优化、代码生成。
  • Token、AST、IR 分别是什么,解决什么问题。
  • 类型检查、作用域检查、常量折叠、死代码消除等机制如何工作。
  • 编译型、解释型、JIT 三种执行模型的差异。
  • 编译原理如何落到前端工程、后端构建、代码审查和 AI Coding 中。

总览:代码的翻译之旅

编译器可以理解成一个分阶段工作的翻译系统:

源代码字符流
  -> 词法分析 Lexical Analysis
  -> Token 流
  -> 语法分析 Syntax Analysis
  -> AST 抽象语法树
  -> 语义分析 Semantic Analysis
  -> 带类型和作用域信息的 AST
  -> 中间代码生成 IR Generation
  -> IR 中间表示
  -> 代码优化 Optimization
  -> 优化后的 IR
  -> 目标代码生成 Code Generation
  -> 机器码 / 字节码 / 目标平台代码

这条流水线的关键思想是:每一步都把信息变得更结构化、更接近执行。

阶段 输入 输出 解决的问题
词法分析 字符流 Token 流 识别单词
语法分析 Token 流 AST 识别结构和优先级
语义分析 AST 带语义信息的 AST 检查含义是否合法
IR 生成 AST 中间表示 脱离具体源码语法
优化 IR 优化后的 IR 减少无效计算和冗余代码
代码生成 IR 机器码或字节码 面向目标平台执行

词法分析:把字符流切成 Token

词法分析器从左到右扫描源码,把连续字符组合成有意义的词法单元,也就是 Token。

例如:

int age = 25 + 1;

会被拆成:

[int] [age] [=] [25] [+] [1] [;]
  |     |    |   |    |   |   |
关键字 标识符 运算符 数字 运算符 数字 分隔符

常见 Token 类型:

Token 类型 示例 含义
关键字 ifforintreturn 语言保留字
标识符 ageuserName 变量、函数、类等名字
字面量 123"hello"true 直接写在代码里的值
运算符 +==&&= 表达计算或赋值关系
分隔符 ;,(){} 分隔语句和结构
注释 / 空白 // comment、空格、换行 通常会被过滤或保留为调试信息

词法分析阶段能发现的错误通常是「词不合法」,例如字符串没有闭合、数字格式错误、出现语言不认识的字符。

语法分析:把 Token 组织成 AST

Token 只是孤立的单词。语法分析要根据语言文法,把 Token 组织成抽象语法树(AST, Abstract Syntax Tree)。

例如:

const result = 1 + 2 * 3;

不是简单从左到右算成 (1 + 2) * 3,而是要识别乘法优先级更高:

VariableDeclaration
  name: result
  value:
    BinaryExpression(+)
      left: NumericLiteral(1)
      right:
        BinaryExpression(*)
          left: NumericLiteral(2)
          right: NumericLiteral(3)

AST 省略了很多源码表面的细节,例如空格、换行、部分括号,但保留了代码结构。它是后续分析和转换的核心数据结构。

常见语法结构和 AST 节点:

代码结构 AST 节点思路
a + b 二元表达式 BinaryExpression
foo() 函数调用 CallExpression
if (...) {} 条件语句 IfStatement
const x = 1 变量声明 VariableDeclaration
import a from 'x' 模块导入 ImportDeclaration

语法分析阶段能发现的是「句子结构不合法」,例如括号不匹配、if 后没有条件、表达式缺少操作数。

AST 为什么是现代工具链的核心

你可能不写编译器,但每天都在使用基于 AST 的工具:

  • ESLint:解析 AST,检查潜在问题和团队规则。
  • Prettier:解析 AST,再按统一规则重新生成代码。
  • Babel / SWC:解析 AST,转换语法,再生成兼容代码。
  • TypeScript:在 AST 上做类型检查和声明分析。
  • IDE 重构:基于 AST 安全重命名变量、提取函数、查找引用。
  • Tree Shaking:分析 import / export,删除未使用代码。

这也是为什么复杂代码改造不要靠字符串替换。字符串替换不知道「这个 user 是变量名、字符串内容,还是注释里的单词」,AST 知道。

可以用 AST Explorer 观察真实代码的 AST。看几次之后你会发现:代码不是一行行文本,而是一棵有层级、有类型、有作用域的树。

语义分析:结构正确不等于含义正确

语法分析只保证「句子长得像合法代码」,语义分析要检查「这段代码在语言规则下是否有意义」。

常见语义检查:

检查 示例 问题
类型检查 let n: number = "hello" 字符串不能赋给数字
作用域检查 console.log(x),但 x 未声明 变量不存在
重复声明 同一作用域重复定义不允许的名字 名字冲突
可见性检查 访问 private 字段 权限不允许
返回值检查 函数声明返回 number,却返回 string 返回类型不匹配
控制流检查 某些分支没有返回值 路径不完整

很多你熟悉的错误,本质上来自语义分析:

  • TypeScript 的类型报错。
  • Java 的泛型、返回值、异常签名检查。
  • Rust 的所有权、借用、生命周期检查。
  • Go 的未使用变量检查。

不同语言的「硬核程度」差异,很大一部分体现在语义分析能检查多少东西。

中间表示 IR:为什么不直接生成机器码

大型编译器通常不会从 AST 直接生成机器码,而是先转成中间表示(IR, Intermediate Representation)。

IR 的价值是把「前端语言」和「后端平台」解耦:

C / C++ / Rust / Swift ...
          |
        前端
          |
         IR
          |
        后端
          |
x86 / ARM / WebAssembly ...

这样一来:

  • 新语言只要能生成同一套 IR,就能复用后端优化和代码生成能力。
  • 新 CPU 架构只要支持这套 IR 后端,就能运行多种语言。
  • 优化器可以在统一表示上工作,不必为每门语言重写一遍。

LLVM 就是典型的编译器基础设施。Clang、Rust、Swift 等都大量受益于 LLVM 的 IR、优化器和后端。

前端工程里也有类似思想:Babel 把 JS 解析成 AST,再做插件转换,最后生成目标版本 JavaScript。虽然它不一定生成机器码,但「统一中间结构 + 多阶段转换」的思想是一致的。

代码优化:让机器少做无意义工作

优化阶段通常作用在 IR 或 AST 上,目标是保持语义不变,但让代码更快、更小或更适合目标平台。

常见优化:

优化技术 优化前 优化后 原理
常量折叠 x = 10 + 5 x = 15 编译时能算的就不留到运行时
常量传播 x = 15; y = x * 2 y = 30 已知值沿使用链传播
死代码消除 if (false) {...} 删除分支 永远不会执行的代码没必要保留
函数内联 foo() 展开函数体 减少调用开销,暴露更多优化机会
循环不变量外提 循环内重复计算固定值 移到循环外 减少重复计算
公共子表达式消除 多次计算同一表达式 计算一次复用 减少冗余计算

例如:

const width = 10;
const height = 20;
const area = width * height;

如果编译器能确定 widthheight 都不会变,它就可能把 width * height 折叠为 200

理解优化有两个实际价值:

  1. 写出更容易被优化的代码,比如减少动态形状变化、避免 eval、让数据结构稳定。
  2. 看懂性能分析结果,知道「源码看起来短」不等于「机器执行更少」。

目标代码生成:落到具体机器

代码生成阶段把优化后的 IR 转成目标平台能执行的形式。

目标可能是:

  • x86 / ARM 机器码。
  • JVM 字节码。
  • .NET IL。
  • WebAssembly。
  • 旧版本 JavaScript。
  • 某个虚拟机或解释器的字节码。

这里会涉及更底层的问题:

  • 寄存器分配:哪些变量放寄存器,哪些溢出到栈。
  • 指令选择:用目标 CPU 的哪些指令完成操作。
  • 调用约定:函数参数、返回值、栈帧如何安排。
  • 链接:多个目标文件、库函数和符号如何合并。

所以「编译成功」不只是语法通过,还包括生成目标平台可接受的产物。

编译型、解释型与 JIT

语言执行模型常被粗略分成三类:

模型 过程 优点 代价 代表
编译型 源码先编译成机器码,再执行 运行快、部署产物直接 编译等待、跨平台需重新构建 C、C++、Rust、Go
解释型 源码由解释器边读边执行 开发快、灵活、跨平台 运行时开销较大 Python、Ruby、PHP
JIT 先解释或执行字节码,热点代码即时编译 热点性能高,兼顾灵活性 需要预热,运行时复杂 JavaScript V8、JVM、.NET

现实语言往往不是单一模型。例如 Java 通常先编译成字节码,再由 JVM 解释或 JIT;JavaScript 在 V8 中会先解析、生成字节码,再对热点函数做优化编译。

V8 的基本思路是:

JavaScript 源码
  -> Parser 生成 AST
  -> Ignition 生成字节码并执行
  -> 收集运行时反馈
  -> TurboFan 对热点代码优化编译成机器码
  -> 如果假设失效,反优化回退

这解释了为什么 JavaScript 性能有时很好,也解释了为什么某些动态写法会触发反优化。

前端工程里的编译原理

前端工具链几乎是编译原理的大型应用现场:

工具 编译原理对应环节
TypeScript 解析、类型检查、转译
Babel AST 转换、代码生成
SWC / esbuild 高性能解析、转换、压缩
Vite / Webpack 模块依赖分析、打包、Tree Shaking
Vue / Svelte 模板编译成渲染函数或更底层代码
JSX 语法扩展转成函数调用或运行时结构
Sourcemap 把生成代码映射回源代码

所以构建失败时,要沿着链路问:

  • 是源码语法错了?
  • 是 TypeScript 类型错了?
  • 是 Babel 插件顺序错了?
  • 是模块解析别名错了?
  • 是目标浏览器不支持生成代码?
  • 是压缩或 Tree Shaking 改变了副作用?

只看最后一行错误,经常会误判。

AI Coding 时代怎么用

AI 很擅长生成代码转换、配置和脚手架,但编译链路越复杂,越不能让它自由发挥。

让 AI 修改编译相关内容时,建议要求它说明:

  • 输入语言是什么,语法范围到哪里。
  • 输出目标是什么:浏览器、Node、字节码、机器码还是某个框架运行时。
  • 转换链路有哪些阶段,每个插件或 loader 的顺序为什么这样排。
  • 类型检查和运行时校验分别在哪一层完成。
  • sourcemap 是否保留,线上问题如何定位回源码。
  • 优化是否可能影响副作用、动态 import、tree shaking 或 polyfill。

更好的提问方式:

先根据项目配置画出 TypeScript -> Babel/SWC -> Bundler -> Minifier 的转换链路,
再说明这个报错可能发生在哪一层,最后给出最小改动修复方案。

常见误区

误区一:认为编译只属于 C、C++、Java。现代前端、移动端、模板框架、低代码平台、AI 工具链都大量依赖编译和转换。

误区二:把 AST 当成高级黑魔法。AST 本质上只是代码结构化后的树,真正难的是文法、作用域、类型和转换正确性。

误区三:以为语法正确就代表程序正确。语法只是结构,语义分析才处理类型、作用域、返回值和权限等问题。

误区四:认为编译器优化会自动解决所有性能问题。优化器只能在它能证明安全的范围内工作,糟糕的数据结构和 I/O 设计不会凭空消失。

误区五:认为 sourcemap 可有可无。没有 sourcemap,线上压缩代码的错误定位会非常痛苦。

误区六:用字符串替换做复杂代码改造。只要涉及语法结构、作用域或 import/export,优先使用 AST 工具。

小结

编译原理的核心不是「把代码变成机器码」这么一句话,而是一条完整流水线:字符变 Token,Token 变 AST,AST 经过语义分析变成更可靠的结构,再生成 IR、优化,最后落到目标平台。

掌握这条链路后,你会更容易理解报错信息、构建工具、类型系统、性能优化、JIT 行为和前端工程化。AI 可以帮你生成配置和转换代码,但你需要能判断它改的是哪一层,以及如何验证这层真的工作。