HOME | EDIT | RSS | INDEX | ABOUT | GITHUB

JavaScript玩转Clojure大法之 - Macro (1)

macro可以说是lisp语言的独门绝技, lisp语言数据即代码,以及s-expression的特点使得可以轻松自定义macro. 虽然js原生不能这么玩, 但是依然不能阻止我们通过sweet.js在预编译的过程中使用macro.

Macro

我非常不喜欢中文字面翻译–'宏', 中文宏的意思是大, 广大, 实在想不通这跟macro有毛关系. 而macro值的是某条指令可以扩展 成一堆其它指令. 反而用个图我觉得更贴切

bender-make-bender.gif
(macroexpand '(when-not (= 1 3) (print "damn")))
; => (if (= 1 3) nil (do (print "damn")))

看看Clojure的macro when-not 能扩展成什么? 神奇的变成了if.

靠, 这不是就是语法糖?

嗯, 就是语法糖, 但是是强大的可以自定义的语法糖. 这代表着你可以用Clojure编写自己独特版本的Clojure.

lisp语言因为本身的原因能轻松这么办到

  • 语法简单,只有s-expression
  • 数据即代码, s-expression本身就是树形数据结构
  • lexer(词法分析器) → Reader → expander

如果英文好, 可以看看这篇解释clojure macro的文章

但是就算可以自定义语法糖, 到底有什么好处呢, 真的只是使语法更好看吗?

当然不是, macro可以说是元编程的终极形态, 当Clojure推出core.async这么牛逼的库之后, 立即就被port到 ClojureScript, 也就是说, ClojureScript写的 go block 可以编译成能在浏览器上抛的单线程JavaScript.

如果回忆不起来可以翻看下如何用JavaScript实现 core.async 的 go block. 你会发现 generator 是实现的关键, 而ClojureScript却只用macro展成不同的纯状态机实现.

怎么做到的呢, 就是macro, 如果你翻看ClojureScript 的 core.async源码, 会看见一堆一堆的macro. 根据go block 中不同的语法扩展成不一样的状态机.

Sweet.js

因此对于像其它有更多语法的语言要实现macro可就没那么简单了(虽然一些新的语言还是很努力的实现了macro, 比如rust和julia). 比较直白的实现方式是定义的macro接收一个 AST, 然后改吧改吧, 做macro该干得事情. 但是操作语法树实在是太复杂了, 跟自己写类似coffeescript编译器有毛区别.

sweet.js 给我提供了一个自制js macro的工具, sweet.js来自mozilla mozilla还有论文呢: https://github.com/mozilla/sweet.js/blob/master/doc/paper/sweetjs.pdf , 嗯嗯, 就是rust的那个mozilla, 就是那个 如果没有chrome, 应该能占浏览器半壁江山的 汪峰 firefox 的mozilla 公司 基金会.

Rule macro

因此sweet.js和mozilla自己的语言rust支持的macro语法上非常接近, 也绝逼不是巧合.

来看看rust的 rule macro

macro_rules! foo {
    (x => $e:expr) => (println!("got a ", $e));
}
foo!(x => 3); // => println!("got a ", 3)

来看看 Sweet.js的 rule macro

macro foo {
  {
    (x=>$e:expr)
  } => {
    console.log('got a ', $e)
  }
}
foo(x=>3) // => console.log("got a ", 3)

简直是一模一样, 好吧, 我承认我的标题应该改成 javascript玩转rust大法更为贴切, 但是我们先来关心下这里面到底发生了什么?

实际上sweet.js做了之前clojure的Reader 和 Expander的工作

  1. lexer 把源码token化, 得到token序列
  2. Reader把token编程token树, 这是一个类似 sexpr 的token树
  3. 变term树
  4. Expander按照定义的macro匹配扩展token树, 再parse成AST

说人话!

拿 macro foo 作为例子

  1. 变token: foo • ( • x • = • > • 3 • )
  2. 变token树: 括号里面是一棵树, 第一个是根, 后面的元素有括号的是子树, 没有的就是叶子了.
(foo ('()' x = > 3))
  1. 变term树:

    (call:foo x = > 3)
    
  2. expand:
(call:foo (call:console.log 'got a' , 3))

这个, 这个这个……怎么说好变成树怎么就变成lisp了

没错, lisp 简单的 s-expr 界限非常清晰而且本身就是完美的树型结构, 实现macro最方便的方式

case macro

Allright, 当然这个例子好简单, 但是像 rule macro 只能做一些非常简单的形式上的一一变化, 那么说好的元编程呢? 说好的可以像clojure那样用clojure编写clojure代码呢. 这时候case macro就是解决这个问题了. clojure由于 数据即代码, 代码只要quote起来就跟list一样好操作, 那么JavaScript麻烦的语法要怎么变数据好让我们用JavaScript操作呢?

答案是太复杂不能变数据, 但是只能变AST, 只能操作复杂的语法树了, 真是忧伤, 但是总比没有好吧.

让我们先来一 个例子

  macro m {
    case {ctx (x=>$x)} => {
      console.log('haha iam javascript')
      return #{
       console.log($x) 
      }
    }
  }
  m(100) 
//=> haha iam javascript (to console)
//=> console.log(100)

如果你已经忘了, 请 电梯 返回去对比一下到底有什么区别

  1. 多了一个参数 ctx, 匹配用到m时的那个m
  2. 接下来都一样, 直到… #{} 这个是什么? 这里面的语法变成语法树, 当然语法树结构是数组, 每个元素还是一个token树.比如console.log(3)大概是这种结构
[
    {token: {value: 'console'}}
    {token: {value: '.'}},
    {token: {value: 'log'}},
    {token: {value: '()'},
     inner:[
         {token: {value: 3}}
     ]}
]
  1. 最重要的, 现在里面可以写正常js了, 意味着你可以用js编程各种语法,然后拼到token树中

我感觉语言以及不能解释了, 请深吸一口气, 来一个骚味复杂一点的栗子

比如我要在js里弄一个想clojure的arity function一样骚的函数

arity function指根据不同个数的参数, 有不同的函数body. 比如

(defn add
  ([x] (+ 0 x))
  ([x y] (+ x y)))
(add 1);=>2
(add 1 2);=>3

所以类似的我期望的能在js里这样定义函数

defn add {
  (a){a}
  (a, b) {a+b}
}
add(1) //=> 1
add(1, 2) //=> 3

先把macro摆出来

//var macro from http://jlongster.com/Sweet.js-Tutorial--2--Recursive-Macros-and-Custom-Pattern-Classes
macro caseFunc {
    case {_ ($args...) {$body... $last:expr}} =>
    {
      letstx $len = [makeValue(#{$args...}.length , null)];
      return #{
      case $len:
        return (function($args...){$body... return $last}).apply(this, arguments)
      }
    }
}

macro defn{
  rule { $name { $(($args (,) ...){$body ...})...} } => {
    function $name (){
      switch(arguments.length){
        $(caseFunc ($args...) {$body...};
         )...
      }
    }
  }
}
defn arity_function{
  (a){a}
  (a, b) {a + b}
}
// =>
/*
function arity_function() {
    switch (arguments.length) {
    case 1:
        return function (a) {
            return a;
        }.apply(this, arguments);
    case 2:
        return function (a, b) {
            return a + b;
        }.apply(this, arguments);
    }
}
*/

WTF shen me gui

叫我一点一点解释, 重要的是第二个macro(第一个应该都能看懂吧), 这里面有几个新东西

  • 2行的 $last:expr: 匹配最后一个表达式
  • 4行: 里面的 #{$args} 把match到的javascript语法变成token树的列表.
  • 这个token列表就是javascript的数组, 里面是token对象.
    • makeValue 把这个javascript再变成token树
    • letstx $len 来装这个token树, 就可以在后面的 #{}
  • 最后返回token树
 1: macro caseFunc {
 2:   case {_ ($args...) {$body... $last:expr}} =>
 3:     {
 4:       letstx $len = [makeValue(#{$args...}.length , null)];
 5:       return #{
 6:         case $len:
 7:         return (function($args...){$body... return $last}).apply(this, arguments)
 8:       }
 9:     }
10: }

Recap

总之, macro给我们带无线的 wifi 可能, 对于语法复杂的语言确实不能像lisp一样简单实现macro, 但是通过 lexer和reader转换成类似lisp token树, 虽然坎坷了一些, 但是还是能达到相同的效果的. 当然 sweet.js 提供 的macro的功能还不只这些, 下篇将介绍 operator 和 infix macro, 当然如果你等不急自己看sweet.js文档 也是极好的.

另外感兴趣的话可以看看我最近正WIP的项目 ru-lang 的一些macro.

Footnotes: