An open API service indexing awesome lists of open source software.

https://github.com/xianjianlf2/mini-compiler

手写 the-super-tiny-compiler:词法 / 语法 / 转换 / 代码生成(mini-* 源码学习系列,按 Git 提交历史一步步搭建)
https://github.com/xianjianlf2/mini-compiler

ast compiler learning parser tokenizer typescript

Last synced: about 8 hours ago
JSON representation

手写 the-super-tiny-compiler:词法 / 语法 / 转换 / 代码生成(mini-* 源码学习系列,按 Git 提交历史一步步搭建)

Awesome Lists containing this project

README

          

# mini-compiler

这个仓库会通过 Git 提交历史,一步步把 mini-compiler 搭出来。

目标不是一次写完一个复杂编译器,而是按最适合理解编译流程的顺序,让同一个小编译器慢慢长出来。

换句话说,这个仓库更在意“为什么下一步应该是它”,而不是“还能不能再多加一个功能”。

## 怎么看这条历史

每一条提交只做一件事,所以这条历史本身就是学习路线。

你可以先看完整代码,再按顺序回看提交,观察同一条编译链路是怎么一步步长出来的。

常用方式:

```bash
git log --oneline --reverse
```

如果你想看某一步的具体变化,可以继续用:

```bash
git show
```

如果你想按顺序体验每一步,可以从最早的提交开始,一步步切过去看。

## 为什么要按这个顺序

如果目标是学编译器的核心,而不是尽快做一个真正的语言,那顺序非常重要。

最容易走偏的方式,是太早去做这些东西:

- 先做变量作用域
- 先做类型检查
- 先做优化
- 先做复杂语法

这样虽然看起来“功能更多”,但会把编译器最核心的那条主线打散。

这个项目真正最值得先弄明白的,是这条链:

1. 源码字符串怎么拆开
2. 拆开的片段怎么变成树
3. 树怎么被稳定遍历
4. 源语言的树怎么变成目标语言的树
5. 目标语言的树怎么打印成代码
6. 这些步骤怎么串成一个 compiler

只有这条主线通了,后面的作用域、类型系统、优化、报错提示,才知道该挂在哪、为什么能挂上去。

## 推荐学习顺序

| 步骤 | 当前问题 | 新增能力 | 为什么重要 |
| --- | --- | --- | --- |
| 1 | 源码是一整段字符串 | 分词 | 先得到最小片段 |
| 2 | tokens 还是平铺的 | 解析成语法树 | 保存嵌套关系 |
| 3 | 树需要被稳定访问 | 遍历器 | 把“走树”和“处理节点”分开 |
| 4 | 源语言和目标语言结构不同 | 转换语法树 | 让结构靠近目标代码 |
| 5 | 树不能直接运行 | 生成代码 | 得到最终字符串 |
| 6 | 步骤太散 | compiler 总入口 | 串起完整流程 |

## 整体流程

```mermaid
flowchart LR
A["源码字符串"] --> B["tokenizer"]
B --> C["parser"]
C --> D["traverser"]
D --> E["transformer"]
E --> F["codegen"]
F --> G["目标代码"]
```

## 第一步

先把源码拆成一串 tokens。

这一步只回答一个问题:编译器拿到字符串以后,第一眼应该怎么把它切成更小、更容易理解的片段。

> **为什么下一步不是直接生成代码?**

反例是:如果连 `add`、`2`、`(`、`)` 这些最小片段都没分清楚,后面就没法判断谁是谁的参数。

## 第二步

把 tokens 组织成语法树。

这一版解决的是“括号嵌套关系怎么保存”的问题。

`(add 2 (subtract 4 2))` 不是一串平铺的词,而是 `add` 里面又包含了一个 `subtract`。语法树就是用来保存这种层级关系的。

> **为什么下一步不是直接改成目标代码?**

反例是:如果每次改代码都手写一堆针对某种节点的判断,逻辑会散在很多地方。先有统一遍历方式,后面转换会更清楚。

## 第三步

给语法树加一个统一遍历器。

这一版解决的是“怎么稳定走完整棵树”的问题。

遍历器会先进入外层节点,再进入里面的节点,最后再从里面退出来。这样后面想对某种节点做处理,只需要告诉遍历器“遇到它时做什么”。

> **为什么下一步才开始转换?**

反例是:如果没有遍历器,转换逻辑既要负责走树,又要负责改树,两件事混在一起会很难读。

## 第四步

把源语言语法树转换成目标语法树。

这一版解决的是“源语言结构和目标语言结构不一样”的问题。

源语言是 `(add 2 4)`,目标语言更像 `add(2, 4)`。它们表达的是同一件事,但树的形状不同,所以需要先转换结构。

> **为什么下一步还不能直接结束?**

反例是:转换后的树仍然只是对象结构,人和机器都不能直接把它当成最终代码运行。还需要把树重新打印成字符串。

## 第五步

把目标语法树生成 JavaScript 代码。

这一版解决的是“树怎么变回字符串”的问题。

转换后的树已经很接近 JavaScript,但还不是最终代码。代码生成器会按节点类型,把函数调用、数字、字符串这些节点打印出来。

> **为什么最后还要有 compiler?**

反例是:如果使用者每次都要手动调用分词、解析、转换、生成代码四个步骤,使用体验会很碎,也不利于看清完整流程。

## 第六步

把所有阶段串成一个 compiler。

这一版解决的是“怎么从源码一步到最终代码”的问题。

最终入口只做一件事:把源码依次交给分词、解析、转换和代码生成,最后返回目标代码。

> **为什么到这里先停住?**

反例是:如果刚跑通主线就加入变量、作用域、类型检查,学习者会分不清哪些是编译器主流程,哪些是更高级的语言能力。

## 最终能做什么

现在这个 mini-compiler 可以把这种 Lisp 风格的调用:

```text
(add 2 (subtract 4 2))
```

编译成 JavaScript 风格的调用:

```text
add(2, subtract(4, 2));
```

它也支持字符串参数:

```text
(print "hello")
```

会变成:

```text
print("hello");
```

## 怎么验证

运行:

```bash
npm test
```

它会检查每一个阶段,也会检查完整 compiler 的最终输出。

---

## `mini-*` 源码学习系列

手写主流框架 / 工具的最小可运行实现,每个仓库只追「核心主线」,不堆功能。

| 仓库 | 内容 |
| --- | --- |
| [mini-vue](https://github.com/xianjianlf2/mini-vue) | 手写 Vue3:响应式 / runtime / 编译器 |
| [mini-react](https://github.com/xianjianlf2/mini-react) | 手写 React:Fiber / reconciliation / Hooks |
| [mini-koa](https://github.com/xianjianlf2/mini-koa) | 手写 Koa:中间件洋葱模型 / context |
| [mini-webpack](https://github.com/xianjianlf2/mini-webpack) | 手写 webpack:依赖图 / loader / plugin |
| **mini-compiler**(本仓库) | 手写 the-super-tiny-compiler:词法 / 语法 / 转换 / 生成 |
| [ts-axios](https://github.com/xianjianlf2/ts-axios) | 手写 axios(TypeScript 版) |

> Talk is cheap. Read the code.