Clojure mutation 例子: 遍历树并收集数据

在看Spector的代码, 碰到类似下面的代码

 
 
        used-locals-cell (i/mutable-cell [])
        _ (cljwalk/postwalk
           (fn [e]
             (if (local-syms e)
               (i/update-cell! used-locals-cell #(conj % e))
               e))
 
           path)
        used-locals (i/get-cell used-locals-cell)
 
 

这段代码也许只是完成一个简单的操作, 但是写法让我感觉不那么elegant.

这里面几乎违反了几个FP的最重要原则: 一个是mutation, 一个是副作用. 问题的本质是遍历某种数据结构, 并在每一个节点上收集必要的数据, 收集的点是一个函数, 可以称之为callback, 或者visitor, 在这个函数里面要把数据集中到某个store里面.

思考了一下之后发现, 不用mutation是无法做到的. 这个例子中为了实现mutable, 直接动用了底层的JAVA, mutable-cell是一个Java 类, 其唯一的作用就是实现一个mutable的store. 像Clojure这样提供了优秀的immutability工具, 但是出于实际的考虑, 我们有时候为了mutation不得不煞费苦心. 不过只要控制得当, 谨慎使用, 还是可以接受的. 原则就是不要mutate任何东西, 除非不得不如此.

 
public class MutableCell {
  private Object o;
 
  public MutableCell(Object o) {
    this.o = o;
  }
 
  public Object get() {
    return o;
  }
 
  public void set(Object o) {
    this.o = o;
  }
}
 

既然引入mutation不可避免, 那么我们也可以把事情做得得当一些. 其实Clojure本身提供很多工具来实现mutation. 例如atom, ref, 还有transient. transient在这个例子中并不适用, transient的主要作用是优化性能, 而这里是要产生副作用, 副作用本身就是对一个data store进行mutate.

如果不动用额外的Java类, 也可以实现同样的效果, 另外一点就是整个过程最好封装到一个函数当中, 涉及到mutation的部分最好应该对外不可见, 就像transient一样. 不应该把mutate的部分和其他代码混在一起. 另外在Clojure中写很长很大的函数几乎总是不太好的做法. 应该尽量写小而简洁的函数, 并始终注意尽量避免mutation, 除非万不得已.

下面的代码使用内置的mutation工具atom, 并且将mutate的部分封装在函数中, 接受的和返回的都是persistent data structure.

 
(defn extract-used-locals [local-syms path]
  (let [
         used-locals-accumulator (atom [])
       ]
    (clojure.walk/postwalk
      (fn [ele]
        (if (local-syms ele)
          (swap! used-locals-accumulator (fn [a] (conj a ele))) 
          ele
        )
      )
      path
    )
    @used-locals-accumulator
  )
)
 
(defmacro foo [& path]
  (pprint (extract-used-locals #{(symbol "ALL")} path))
)
 
(foo [ALL ALL #(= 0 (mod % 3))])