编译原理入门
前言
当你按下"运行"按钮,代码是怎么变成屏幕上的结果的? 你写的每一行代码,计算机其实都"看不懂"——它只认识 0 和 1。编译器就是那个把人类语言翻译成机器语言的"翻译官"。理解编译原理,你就能理解报错信息从哪来、为什么有些语言快有些慢、以及代码优化的底层逻辑。
这篇文章会带你学什么?
学完这章后,你将获得:
- 全局视野:掌握从源代码到可执行程序的完整编译流水线
- 词法分析:理解编译器如何把代码拆成一个个 Token
- 语法分析:理解 AST(抽象语法树)的构建过程
- AST 可视化:直观看到代码的树形结构
- 语义分析与优化:理解类型检查和代码优化的原理
- 优化技术实战:掌握常量折叠、死代码消除等核心优化手段
- 执行模型:区分编译型、解释型和 JIT 三种执行方式
| 章节 | 内容 | 核心概念 |
|---|---|---|
| 第 1 章 | 编译器是什么 | 翻译官类比、编译流水线 |
| 第 2 章 | 词法分析 | Token、词法规则 |
| 第 3 章 | 语法分析 | AST、语法树、优先级 |
| 第 4 章 | AST 可视化 | 交互式语法树、节点类型 |
| 第 5 章 | 语义分析与优化 | 类型检查、常量折叠、死代码消除 |
| 第 6 章 | 优化技术实战 | 函数内联、循环外提、常量传播 |
| 第 7 章 | 编译型 vs 解释型 vs JIT | 三种执行模型对比 |
0. 全景图:代码的"翻译之旅"
想象你是一个翻译官,要把一本中文小说翻译成英文。你不会一个字一个字地直译,而是:
- 识别词语 — 把句子拆成一个个词(词法分析)
- 理解句法 — 判断句子结构是否正确(语法分析)
- 理解语义 — 确保意思通顺、没有矛盾(语义分析)
- 润色优化 — 让译文更地道流畅(代码优化)
- 输出译文 — 写出最终的英文版本(代码生成)
编译器做的事情完全一样,只不过它翻译的是编程语言。
int age = 25;1. 编译器的六步流水线
编译器的工作可以分为六个阶段,像工厂流水线一样,每个阶段处理完交给下一个阶段。
int x = 10 + 5;
→ [int] [x] [=] [10] [+] [5] [;]
关键字 标识符 运算符 数字 运算符 数字 分隔符编译流水线
- 词法分析(Lexical Analysis):把源代码拆成一个个 Token(单词)
- 语法分析(Syntax Analysis):把 Token 组织成语法树(AST)
- 语义分析(Semantic Analysis):检查类型是否正确、变量是否声明
- 中间代码生成(IR Generation):生成与平台无关的中间表示
- 代码优化(Optimization):让中间代码更高效
- 代码生成(Code Generation):生成目标平台的机器码
| 阶段 | 输入 | 输出 | 类比 |
|---|---|---|---|
| 词法分析 | 源代码字符流 | Token 流 | 把句子拆成单词 |
| 语法分析 | Token 流 | AST(语法树) | 分析句子结构 |
| 语义分析 | AST | 带类型的 AST | 检查意思是否通顺 |
| 中间代码 | 带类型的 AST | IR | 写出初稿 |
| 代码优化 | IR | 优化后的 IR | 润色删减 |
| 代码生成 | 优化后的 IR | 机器码 | 输出终稿 |
2. 词法分析:把代码拆成"单词"
词法分析是编译的第一步。编译器从左到右扫描源代码的每个字符,把它们组合成有意义的Token(词法单元)。
🔤 词法分析器:把代码拆成 Token
输入一行代码,实时看到词法分析的结果
就像读英文句子时,你的大脑会自动把字母组合成单词一样,词法分析器把字符组合成 Token:
源代码: let x = 10 + 5;
Token 流:
[let] → 关键字(语言保留字)
[x] → 标识符(变量名)
[=] → 运算符(赋值)
[10] → 数字字面量
[+] → 运算符(加法)
[5] → 数字字面量
[;] → 分隔符(语句结束)Token 的五大类型
- 关键字:语言保留的特殊单词,如
let、if、return、function - 标识符:程序员定义的名字,如变量名、函数名
- 字面量:直接写在代码里的值,如数字
42、字符串"hello" - 运算符:执行运算的符号,如
+、-、=、=== - 分隔符:分隔代码结构的符号,如
;、,、(、)
3. 语法分析:构建语法树(AST)
词法分析把代码拆成了 Token,但 Token 只是一个个孤立的"单词"。语法分析的任务是把这些 Token 按照语法规则组织成一棵抽象语法树(Abstract Syntax Tree, AST)——它反映了代码的结构和运算优先级。
表达式: 1 + 2 * 3
语法树: 为什么这样?
+ 因为 * 的优先级
/ \ 高于 +,所以
1 * 2 * 3 先结合
/ \ 成为一个子树
2 3AST 的重要性
AST 是编译器的"核心数据结构",后续的语义分析、优化、代码生成都基于它进行。现代开发工具也大量使用 AST:
- ESLint:解析代码为 AST,检查是否违反规则
- Prettier:解析为 AST 后重新格式化输出
- Babel:解析 AST → 转换 → 生成兼容代码
- IDE 重构:基于 AST 进行安全的变量重命名、函数提取
| 语法结构 | Token 序列 | AST 节点 |
|---|---|---|
| 变量声明 | let x = 10 | VariableDeclaration → Identifier + Literal |
| 函数调用 | add ( 1 , 2 ) | CallExpression → Identifier + Arguments |
| 条件语句 | if ( a > b ) | IfStatement → BinaryExpression + Block |
4. AST 可视化:看见代码的"骨架"
上面我们用文字描述了 AST 的结构,但"看到"比"读到"更直观。下面的交互组件让你选择不同的表达式,实时观察它们的语法树长什么样。
🌳 AST 可视化:看见代码的"骨架"
选择一个表达式,观察它的抽象语法树结构
通过可视化你会发现,AST 的核心规律其实很简单:
| 代码结构 | AST 根节点 | 子节点 |
|---|---|---|
1 + 2 * 3 | BinaryExpression (+) | 左: NumericLiteral(1),右: BinaryExpression(*) |
let x = 10 | VariableDeclaration | VariableDeclarator → Identifier(x) + NumericLiteral(10) |
add(a, b) | CallExpression | Identifier(add) + Arguments(a, b) |
AST 在日常开发中的应用
你可能没直接写过编译器,但你每天都在用基于 AST 的工具:
- ESLint / Prettier:解析代码为 AST,检查规则或重新格式化
- Babel / SWC:解析 AST → 转换语法 → 生成兼容代码
- IDE 重构:基于 AST 做安全的重命名、提取函数
- Tree-shaking:分析 AST 中的 import/export,删除未使用的代码
5. 语义分析与代码优化
语法分析确保代码"结构正确",但结构正确不代表"意思正确"。语义分析负责检查代码的含义是否合法,代码优化则让程序跑得更快。
4.1 语义分析:检查"意思"对不对
| 检查内容 | 示例 | 结果 |
|---|---|---|
| 类型检查 | int x = "hello" | ❌ 类型不匹配 |
| 作用域检查 | 使用未声明的变量 y | ❌ 变量不存在 |
| 类型推断 | 1 + 2.0 | ✅ 推断结果为 float |
| 参数检查 | add(1, 2, 3) 但函数只接受 2 个参数 | ❌ 参数数量不匹配 |
你见过的报错,大多来自语义分析
TypeError: Cannot read properties of undefined— 类型检查ReferenceError: x is not defined— 作用域检查Expected 2 arguments, but got 3— 参数检查
4.2 代码优化:让程序更快
编译器在生成最终代码前,会对中间代码做各种优化。这些优化对程序员透明,但能显著提升性能。
| 优化技术 | 优化前 | 优化后 | 原理 |
|---|---|---|---|
| 常量折叠 | x = 10 + 5 | x = 15 | 编译时直接算出结果 |
| 死代码消除 | if (false) { ... } | 直接删除 | 永远不会执行的代码 |
| 常量传播 | x = 15; y = x * 2 | y = 30 | 已知值直接替换 |
| 循环不变量外提 | 循环内重复计算 len = arr.length | 提到循环外 | 避免重复计算 |
6. 优化技术实战:编译器如何让代码更快
上面我们提到了几种优化技术的名字,现在来深入看看编译器具体是怎么做的。下面的交互组件展示了 5 种最常见的编译器优化,你可以直观对比优化前后的代码差异。
⚡ 编译器优化:让代码自动变快
选择一种优化技术,观察编译器如何自动改进你的代码
const width = 10 const height = 20 const area = width * height // 运行时计算 console.log(area)
const area = 200 // 编译时直接算出结果 console.log(200)
现代编译器和 JIT 引擎(如 V8、GCC、LLVM)会自动应用数十种优化。作为开发者,你不需要手动做这些优化,但理解它们能帮你:
- 写出更容易被优化的代码:比如用
const而不是let,编译器更容易做常量折叠 - 理解性能差异:为什么小函数比大函数快?因为编译器能内联它们
- 避免"反优化":某些写法会阻止编译器优化,比如
eval()和with
| 优化技术 | 触发条件 | 性能影响 | 开发者能做什么 |
|---|---|---|---|
| 常量折叠 | 表达式中全是常量 | 消除运行时计算 | 多用 const 声明 |
| 死代码消除 | 代码不可达或结果未使用 | 减小代码体积 | 及时清理无用代码 |
| 循环不变量外提 | 循环内有不变的计算 | 减少重复计算 | 手动提取也是好习惯 |
| 函数内联 | 小函数被频繁调用 | 消除调用开销 | 保持函数小而专注 |
| 常量传播 | 变量值在编译时可确定 | 整条计算链被消除 | 用常量代替魔法数字 |
7. 编译型 vs 解释型 vs JIT
代码写完后,有三种"翻译方式"让它运行起来。这三种方式各有优劣,直接决定了语言的性能特征和使用场景。
🔄 编译型 vs 解释型 vs JIT
点击不同执行模式,观察代码从源码到运行的过程
| 维度 | 编译型 | 解释型 | JIT 即时编译 |
|---|---|---|---|
| 过程 | 先全量编译成机器码,再执行 | 边读边执行,逐行翻译 | 先解释执行,热点代码再编译 |
| 运行速度 | 最快 | 最慢 | 中等(热点接近编译型) |
| 启动速度 | 慢(需要编译) | 快(直接运行) | 中等(需要预热) |
| 跨平台 | 需要重新编译 | 天然跨平台 | 跨平台 |
| 代表语言 | C, Rust, Go | Python, Ruby | JavaScript (V8), Java |
为什么 JavaScript 这么快?
V8 引擎的 JIT 编译器会监测哪些代码被频繁执行(热点代码),然后把它们编译成高度优化的机器码。所以虽然 JavaScript 是"解释型语言",但在 V8 中它的性能可以接近编译型语言。这也是 Node.js 能做服务端的底气。
总结
编译原理不是只有编译器开发者才需要了解的知识。理解编译流程,能帮你更好地理解报错信息、选择合适的语言、写出更高效的代码。
回顾本章的关键要点:
- 编译器是翻译官:把人类可读的代码翻译成机器可执行的指令
- 六步流水线:词法分析 → 语法分析 → 语义分析 → 中间代码 → 优化 → 代码生成
- 词法分析拆 Token:把字符流拆成关键字、标识符、运算符等有意义的单元
- 语法分析建 AST:按语法规则把 Token 组织成树形结构,反映运算优先级
- 语义分析保正确:类型检查、作用域检查,你见过的大多数报错都来自这里
- 编译器自动优化:常量折叠、死代码消除、函数内联等技术让代码自动变快
- 三种执行模型:编译型最快、解释型最灵活、JIT 兼顾两者
延伸阅读
- AST Explorer - 在线查看代码的 AST 结构
- Crafting Interpreters - 从零实现一门编程语言(免费在线书)
- The Super Tiny Compiler - 用 JavaScript 实现的超小编译器
- V8 Blog - V8 引擎的 JIT 编译技术博客
- LLVM 官网 - 最流行的编译器基础设施
