Coffee Break Clojure, Vol.0

如果有人问我最喜欢的编程语言是什么,我可能会在 Clojure 和 Go 之间纠结一番,但无疑我更享受手写 Clojure 的体验。可就像大部分 Lisp 一样,Clojure 的讨论度并不高,中文内容更是少得可怜。所以我为什么不能贡献一些内容呢?

我前段时间听过一档名为《Coffee Break French》的播客,内容是可以在午休时喝着咖啡听完的法语对话。学习一门编程语言怎么不可以这样轻松呢?我最近也有在帮忙翻译《 A Scheme Primer 》,这篇教程的语言相当通俗。受其启发,再加上我一直想要练习节制地写作,而不是滔滔不绝地写上好几千字,所以我决定开启新的博客系列:《Coffee Break Clojure》。

希望我不会事无巨细、毫无保留地把某个概念讲得十分彻底,最后写成令人望而生畏的长文。我也会更多关注「为什么」而非只是「是什么」,尽力让读者理解某个东西为什么要存在于这门语言里,为什么要这么写。此外,这篇教程是写给有编程基础的人读的,读者至少要熟悉一门编程语言才能继续。


Show Me The Code!

我在了解一门编程语言之前,总想要先有些视觉印象,所以会找些代码片段来看。我说的不是 Hello World,而是真实的代码。

下面是一段从我的项目里摘取出来的 代码片段

(defn trigger
 "Send requests to a list of webhooks.
 Given a vector/list of URLs, it sends plain POST request to each URL. 
 Given a keyword and a map of URL vectors/list, it sends request to each URL in (:keyword map)."
 ([urls]
 (trigger urls nil))
 ([keyw-or-urls webhooks]
 (when-let [hooks (if (nil? webhooks) 
 keyw-or-urls
 (->> (get webhooks keyw-or-urls)
 (filter string?)
 (filter valid-http-url?)))]
 (when (keyword? keyw-or-urls) 
 (timbre/info "Webhook" (name keyw-or-urls) "triggered."))
 (->> hooks (map send-webhook) doall))))

如果你第一次接触 Lisp,那么你可能注意到这段代码不仅有奇怪的缩进,还有很多、很多的括号

不过,初学者也能发现一些熟悉的东西,比如字符串 "Webhook" "triggeerd.",还有条件语句 if。看样子,defn 和 Python 的 def、Go 的 func 一样,用来定义函数。

其他的也不难理解,比如第 10 行的 (get webhooks keyw-or-urls),如果换个写法,读者可能就能看懂一些了:

get(webhooks, keyw-or-urls)

这段代码中的 webhooks 是映射(map),里面存的是键值对;keyw-or-urls 在这里是 keyw(ord)(关键词),也就是键值对的「键」;get 函数从 webhooks 中取出指定键的值。(之所以有 -or-urls,是因为这是门动态类型语言,这个值也可能本身就是我们要的 URL,不必从 webhooks 数组中取出)

没看懂也没关系,上面的代码不重要。

Clojure(以及所有的 Lisp)中的函数调用都写作 (函数名 参数1 参数2 ...) 的形式,符号之间一般用空格分隔。

这看起来可能不像函数调用,倒像个数组,或者说列表(list)。不仅是函数调用,函数定义、宏、条件语句、循环结构,以及包括列表本身在内的数据,都是列表。Lisp 的意思是 LISt Processing(列表处理)。

函数定义是这么写的:

(defn function-name [arg1 arg2]
 (println "I'm a function!")
 (str arg1 arg2))

这也是个列表,列表的元素分别是 defnfunction-name[arg1 arg2] 以及后面的函数体。

如果要表示列表数据呢?可以这么写:

(list "a" "b" "c")
;; => ("a" "b" "c")

函数调用和函数定义的写法一样,代码和数据的写法一样,除了个别语法糖,Clojure 几乎没有别的语法了,一切都可以用列表表示,Clojure 程序就是一个个相互嵌套的列表。

To eval, or not to eval

回看列表:

(list "a" "b" "c")
;; => ("a" "b" "c")

它返回的值是 ("a" "b" "c")。为什么 "a" 作为列表的第一个元素,没有被当作操作的名字?为什么 "b""c" 没有被当作参数?

带着这个问题,先来理解 Eval 这个概念。你可能在 Python 和 JavaScript 里见过 eval() 函数,这个函数把字符串(也就是数据)当作代码来执行,像这样:

// 你现在就可以按下 Ctrl/Command + Shift + I 打开 Console 
// 在你的浏览器里输入这段 JavaScript 代码: 

eval("1 + 1")
// => 2

JavaScript 也可以用函数构造数组:

eval('Array("a", "b", "c")')
// => ['a', 'b', 'c']

上面这段代码和下面这段是等价的:

Array("a", "b", "c")
// => ['a', 'b', 'c']

让我们回到 Clojure,下面这两段代码也是等价的:

(list "a" "b" "c")
;; => ("a" "b" "c")
(eval '(list "a" "b" "c"))
;; => ("a" "b" "c")

等等,单独出现的那个 ' 是什么?为什么不写成字符串?

不妨试试求值字符串会发生什么:

(eval "(list \"a\" \"b\" \"c\")")
;; => "(list \"a\" \"b\" \"c\")"

得到的是字符串本身!为什么会这样?

在 Clojure(以及所有的 Lisp)里,数据可以大致理解为两类:原子(atom)和形式(form)。括号里不能再分的东西就是原子,比如 list"a" 都是原子,而 (list "a" "b" "c") 是形式。

对原子执行 eval 操作,或者说求值(evaluate),得到的是原子本身,比如 (eval 1) 得到的就是 1;对形式求值,就要把形式的第一个原子当作操作,剩下的原子当作参数——这就是我们在前一节建立的对 Clojure 的理解。

其实我们一直预设我们写的所有 Clojure 代码最终都会被求值。如果把一整个 Clojure 源代码文件当作形式(form)的集合,我们执行源代码的过程其实就是在对其中的形式求值(eval)。

理解 Eval 对理解 Lisp 非常重要,如果没理解,可以多读几遍。

回看这段代码:

(eval "(list \"a\" \"b\" \"c\")")
;; => "(list \"a\" \"b\" \"c\")"

eval 求值的是一个原子,一个字符串值,不是形式。

(eval '(list "a" "b" "c"))
;; => ("a" "b" "c")

这段代码才是在对形式求值。是时候解释 ' 是什么了。这个符号其实是个语法糖,上面的代码等价于:

(eval (quote (list "a" "b" "c")))
;; => ("a" "b" "c")

我们不妨试试,没有这个 quote,最终会得到什么结果:

(eval (list "a" "b" "c"))
;; Execution error (ClassCastException) at user/eval19 (REPL:1).
;; class java.lang.String cannot be cast to class clojure.lang.IFn (java.lang.String is in module java.base of loader 'bootstrap'; clojure.lang.IFn is in unnamed module of loader 'app')

报错了!捋一下发生了什么:一般来说,执行函数之前,要先对函数的参数求值。eval 函数的参数求值后得到 ("a" "b" "c"),于是上面的代码就变成了:

(eval ("a" "b" "c"))
;; Execution error (ClassCastException) at user/eval19 (REPL:1).
;; class java.lang.String cannot be cast to class clojure.lang.IFn (java.lang.String is in module java.base of loader 'bootstrap'; clojure.lang.IFn is in unnamed module of loader 'app')

Clojure 没办法把字符串 "a" 转换为函数类型的数据,所以报错了。

要保证 Clojure 不把这个列表误以为是函数调用,就要用到 quote。给形式打上引号,让 Clojure 把 quote 里面的东西当作数据而不是代码来处理。

写在源代码文件里的形式最终都会在运行时被求值(eval),如果想要一部分形式不被求值、被当作数据处理,就要打引号,用 quote 或者 ' 标记。quote 看起来像函数,实际上是个特殊形式(special form),里面的参数不会被求值。

Clojure 代码在运行时默认被求值一次,若是多次求值,含义就会不同。

(eval (eval '(list "a" "b" "c")))
;; Execution error (ClassCastException) at user/eval27 (REPL:1).
;; class java.lang.String cannot be cast to class clojure.lang.IFn (java.lang.String is in module java.base of loader 'bootstrap'; clojure.lang.IFn is in unnamed module of loader 'app')

不妨练习一下,搞清楚上面这段代码被求值时发生了什么。

还有一些别的小练习:

;; 1. 为什么下面这两段代码是等价的?
(eval (quote (println "Hello World")))
(println "Hello World")
;; 2. list 和 quote 的区别是什么? 
(list "a" "b" "c")
;; => ("a" "b" "c")
'("a" "b" "c")
;; => ("a" "b" "c")

(list 1 (+ 1 1) 3)
;; => (1 2 3)
'(1 (+ 1 1) 3)
;; 猜猜这段代码会得到什么结果?

代码即数据

不难发现,在 Clojure 中:代码即数据。代码不过是被 Eval 的数据,而数据不过是没有被 Eval 的代码,归根结底都是被括号包起来的形式,区别仅仅在于有没有被 Eval。

这么看来,我们可以对代码做非常有趣的事情:

(cons '+ (list 1 2 3))
;; => (+ 1 2 3)

如你所见,我们用 cons+ 放在了数字列表的最前面。这个形式被 Eval 了一次得到 (+ 1 2 3),不难想象再 Eval 一次会发生什么。

(eval (cons '+ (list 1 2 3)))
;; => 6

上述代码有个简便写法:

(apply + (list 1 2 3))
;; => 6

往列表里添加元素不过是冰山一角,我们能对数据做什么样的变换,就能对代码做什么样的变换。我们还能借助宏(macro)在求值前变换代码,来构造某些很方便的写法,按自己的想法拓展这门语言。具体的内容会在之后谈到,今天的内容已经够多了。

回见!


  1. 虽然大体一致,底层的逻辑其实是不同的,apply 并不是 (eval (cons func args)) 的语法糖。不过,evalapply 都调用了 IFn 接口的 applyTo 方法,详见: clojure.lang.AFnclojure.lang.Compilerclojure.core/apply 。Clojure 虽然是 Java 写的,但代码很易读。
添加评论
点赞收藏
点踩分享查看原文
评论
?
参与讨论