5

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

 3 years ago
source link: https://blog.oyanglul.us/javascript/clojure-essence-in-javascript-macro
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
JavaScript玩转Clojure大法之 - Macro (1)

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

Table of Contents

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.


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK