最近看了很多clojure代码,第一次这么大规模地看clojure代码,学到很多有意思的东西。
全局变量是好事
在一般语言中,全局变量一般会被认为是坏代码的征兆,但是这几天发现在clojure中完全不是这么一回事,全局变量用得好可以实现一些很有意思的功能。比如很多clojure的库都会把可配置的选项当成全局变量暴露出来,需要配置这些选项时只需要使用binding绑定你所需要的值就行。再比如你需要实现一个类似于可嵌套作用域的功能,在作用域内的表达式都能访问特定资源,出了作用域就不能访问了,这在其他语言中都需要某种栈式的结构,而且还需要保证栈的弹入弹出正确,但是clojure中就用dynamic全局变量再加上binding函数即可,如下:
(def ^:dynamic *scope* {})
(binding [*scope* (merge *scope* {:a 1 :b 2})]
(println "in scope level 1:" *scope*)
(binding [*scope* (merge *scope* {:a 100 :c 2})]
(println "in scope level 2:" *scope*))
(println "returned from scope level 2, in scope level 1:" *scope*))
以上表达式输出
in scope level 1: {:b 2, :a 1}
in scope level 2: {:c 2, :b 2, :a 100}
returned from scope level 2, in scope level 1: {:b 2, :a 1}
clojure中处理这种功能实在是太简单了,根本不需要手动维护栈的平衡。所以全局变量是坏代码的征兆这点在clojure中根本不成立。
而且这种功能结合宏写成with-xx-scope看起来会很清晰。
当然,需要支持这功能也多亏了clojure的PersistentXX这些基础类。这些类即使在java中用也很爽,所谓的“虽然我写的是java,但是函数式的思想已经深深地嵌入我的脑海”。因为这些类把HashMap和链表这些传统上按引用传递的结构变换成了按值传递的结构,并且内部使用了共享的数据结构来降低内存使用,所以写多线程程序用这些会很安全。比如我在学校写的符号执行,一些基本的执行单元使用多线程跑,线程间有共用的数据,但是线程修改的数据只能自己可见,用上这种库就会很方便。
destructuring binding
最开始学destructuring binding时就奇怪这种语法形式很难看,而且貌似没什么用,在读代码时就发现错了。typed clojure把它用得很形象,比如为了取得...
,>
和<
的位置可以这么干:
(let [{pos1 '... pos2 '> pos3 '<} (zipmap '[a b c ... < > 2 3] (range))]
(println pos1 pos2 pos3))
还有在typed clojure可以指定一个类型的范围,用形如(x :< Float :> Long)
这种形式表示类型变量x
至少为Long
,至多为Float
,在parse这种形式时也能使用destructuring binding来很简单地做到。
(let [[v & {:keys [> <]}] '(x :< d :> a)] (println > <))
到处都是表达式
lisp中只有表达式,没有语句。好处就是什么样的程序结构都能写出来,由于clojure的let的绑定是顺序的,也就是说let的第一个绑定可以被第二个绑定使用,这样写程序时就能写成只有一个大大的let,例如
(let [x (calculate something)
y (calculate-another x)
_ (assert (pred? x y))
...]
(construct-result))
这样在let内部实现函数逻辑,在let体内构造返回值返回,看起来还是挺方便的。
辅助函数
读源码时自己写了一些辅助函数来帮助阅读源码。读源码的时候经常需要动态地看某个函数的输入和输出,然后看它的参数输入和输出是什么来理解函数。但是情况常常是你要观察的函数的参数是一个比较复杂的数据结构,在REPL中手动创建这个数据结构当参数会很麻烦,甚至你不知道会有什么约束条件,所以一般会编辑源码加上几行print再调用,但是修改再载入再调用会很浪费时间,最好能直接在REPL中就能添加print语句。这在clojure中可以用alter-root-var
来实现,虽然添加print的位置粗糙了点,但是在我看来够用了。
这几个辅助函数中最重要的就是trace,在REPL中调用类似(trace #'cgen/cs-gen pprint-all)
然后就能给程序一个输入让它跑。当调用到达cgen/cs-gen时就会先调用pprint-all输出cs-gen接收到的参数,然后再调用真正的cs-gen,最后输出cs-gen的返回值。如果对参数不关心只关心其返回值只需要在trace时把pprint-all换成ignore即可,想知道调用栈把pprint-all换成print-st即可。看完这个函数后再通过(untrace #'cgen/cs-gen)就能
关闭输出。
通过这种方法可以减少好多载入时间,特别是当情况特殊必须通过重启jvm来重新载入namespace时候特别爽。顺便吐槽一下lein repl启动时间实在是太慢了,要是有类似android的zygote那样预先加载所需类,启动一个lein repl实例只需简单fork就好了,这样就能减少很多启动时间了嘛。