lisp 语言的 s-expression 非常适合用来处理 AST. 并且 clojurescript 写前端很方便, 有很好的库 re-frame, 还可以利用 npm 生态里面的其他库, 例如 d3 来做可视化.
这个系列会用 clojurescript 写一个编译器, 可以根据输入的源代码, 即时的 parse 成 AST, 并可视化. 再将 AST 进行多次编译到不同的 IR, 每层 IR 也有可视化. 最后会编译到栈式虚拟机的一串指令, 再加上栈式虚拟机逐步执行指令的可视化过程. 后续可能还会加上编译优化, 语法高亮, 自动补全等.
简单算术表达式
先从简单的开始, 只支持正整数, 加法, 乘法表达式. 例如 20 * 2 + 2 这样的表达式.
AST 定义
clojure 代码习惯使用基础数据结构来表达所有的 data, 而不是使用 ADT(algebra data type), 因此在这里使用 vector 来表达所有的 AST 节点.
- 常数用
[:const n]表示, 例如[:const 4] - 加法用
[:add e1 e2]表示, 例如[:add [:const 3] [:const 1]] - 乘法和加法类似, 用
[:mul e1 e2]表示
定义了 AST 之后, 再加一点辅助函数, 类似 java 的各种 getter.
(defn get-type
"获取一个表达式的类型, 返回 :const | :add | :mul ..."
[expr]
(first expr))
(defn get-const [const-expr]
(second const-expr))
(defn get-operand-1 [add-or-mul-expr]
(second add-or-mul-expr))
(defn get-operand-2 [add-or-mul-expr]
(third add-or-mul-expr))
现在可以手写一个 AST, 例如 20 * 2 + 2
[:add [:mul [:const 20]
[:const 2]]
[:const 2]]
利用宿主语言 eval
只要宿主语言能够递归的处理树状结构, 那么就可以对 AST 做一次递归求值, 得到表达式的计算结果.
(defn eval-expr [expr]
(cond
(= (get-type expr) :const) (second expr)
(= (get-type expr) :add) (+ (eval (second expr))
(eval (third expr)))
(= (get-type expr) :mul) (* (eval (second expr))
(eval (third expr)))))
(eval [:add [:mul [:const 20]
[:const 2]]
[:const 2]]) ;; => 42
编译到栈式虚拟机
栈式虚拟机的指令集目前只有
[:const n], 将常数 n 推入栈[:add], 将栈顶的前两个元素取出, 相加后再入栈[:mul], 将栈顶的前两个元素取出, 相乘后再入栈
以下是 20 * 2 + 2 的指令
[[:const 20] [:const 2] [:mul] [:const 2] [:add]]
在写编译 AST 到指令集之前, 先看一下怎么执行指令集.
在 eval 的时候, 需要有一个栈, 即 eval-instr 的第二个参数, 这里使用 clojure 的 list 作为 stack 使用
(defn eval-instr [instrs stack]
(if (empty? instrs) (first stack)
(let [instr (first instrs)]
(cond
(= (get-type instr) :const) (eval-instr (rest instrs)
(conj stack (get-const instr)))
(= (get-type instr) :add) (eval-instr (rest instrs)
(conj (drop 2 stack) (+ (first stack)
(second stack))))
(= (get-type instr) :mul) (eval-instr (rest instrs)
(conj (drop 2 stack) (* (first stack)
(second stack)))))))
测试一下
(eval-instr [[:const 20] [:const 2] [:mul] [:const 2] [:add]] '()) ;; => 42
观察可以发现, 编译 AST 到指令集时,
- 如果是 const 节点, 直接往栈上推
- 如果是 add 节点, 先编译 operand1, 这些指令执行完时会在栈顶留下 operand1 的计算结果. 再编译 operand2, 栈上就会留下两个计算结果. 最后加上 add 指令
- mul 节点同上, 最后修改成 mul 指令
(defn arith-expr-to-instr [expr]
(cond
(= (get-type expr) :const) [:const (get-const expr)]
(= (get-type expr) :add) (concat (arith-expr-to-instr (get-operand-1 expr))
(arith-expr-to-instr (get-operand-2 expr))
[[:add]])
(= (get-type expr) :mul) (concat (arith-expr-to-instr (get-operand-1 expr))
(arith-expr-to-instr (get-operand-2 expr))
[[:mul]])))