Skip to content

Latest commit

 

History

History
197 lines (121 loc) · 6.36 KB

File metadata and controls

197 lines (121 loc) · 6.36 KB

mini-compiler

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

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

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

怎么看这条历史

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

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

常用方式:

git log --oneline --reverse

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

git show <commit-id>

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

为什么要按这个顺序

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

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

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

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

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

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

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

推荐学习顺序

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

整体流程

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

第一步

先把源码拆成一串 tokens。

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

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

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

第二步

把 tokens 组织成语法树。

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

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

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

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

第三步

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

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

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

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

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

第四步

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

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

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

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

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

第五步

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

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

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

为什么最后还要有 compiler?

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

第六步

把所有阶段串成一个 compiler。

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

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

为什么到这里先停住?

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

最终能做什么

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

(add 2 (subtract 4 2))

编译成 JavaScript 风格的调用:

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

它也支持字符串参数:

(print "hello")

会变成:

print("hello");

怎么验证

运行:

npm test

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


mini-* 源码学习系列

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

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

Talk is cheap. Read the code.