用clojure写个编译器, part1

184 阅读3分钟

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]])))