Coffee Break Clojure, Vol.2

上一篇 我们配置好了 REPL,可以随时开始编写 Clojure 代码并执行。 第零篇 提到,被求值(Eval)的列表叫作形式(form),形式的第一个元素是操作符,剩余的是参数。根据操作符的不同,形式被分为函数形式、Lambda 形式、宏形式和特殊形式。今天的文章就来介绍这几种形式。

函数形式

不难理解,函数形式就是操作符为函数的形式,比如这个 println 形式:

(println "Hello World")

我们知道 "Hello World" 是字符串,那 println 是什么类型?在其他语言里我们可能很少考虑函数名本身的类型,但别忘了 Lisp 的「代码即数据」本质。要把待求值的形式当作数据而非形式处理,只需要把它 quote 起来。

这里要引入 Clojure 的 type 函数,它返回输入值的类型。我们不妨把刚才的形式 quote 起来,然后获取他的第一个元素 println,看看它是什么类型。

(type (first '(println "Hello World")))
;; => clojure.lang.Symbol

类型是 Symbol(符号)!这是 Lisp 中的一种特殊数据类型,和其他的原子不同,符号不是自求值(self-evaluating)的。意思是说,对 "string" 求值得到的是 "string" 本身,但对符号求值得到的是符号指向的其他值。

要定义一个符号很简单,只需要用到 def。

(def my-symbol 123)
;; => #'user/my-symbol

123
;; => 123

my-symbol 
;; => 123 

可见符号和它指向的数据本身是等价的,对 123 求值和对 my-symbol 求值得到的结果都是 123。

接下来试试让符号指向函数:

(def my-fn 
 (fn [a b] (+ a b)))
;; => #'user/my-fn

(fn [a b] (+ a b))
;; => #object[user$eval170$fn__171 0x6f8e0cee "user$eval170$fn__171@6f8e0cee"]

my-fn
;; => #object[user$my_fn 0x3df978b9 "user$my_fn@3df978b9"]

(my-fn 1 2)
;; => 3

fn 是个宏(macro),稍后我会讲到它为什么必须是个宏而不能是函数。你可以把它理解为其他语言里的匿名函数,或者 Lambda 表达式。实际上 fn 就是 Common Lisp 和 Scheme 里的 lambda,不过换了个名字。

直接对 fn 形式求值得到的是形如 #object 的字符串,这个字符串是 JVM 上某个数据的文字表示。像函数这种数据没办法像字符串和数字那样直接表示出来,于是就用这种标记表示。

输出这种标记对程序而言没有意义,使用函数的正确方式当然是调用它。上述例子中的 (my-fn 1 2) 就是个函数形式,第一个元素是指向函数的符号,剩下的数据作为参数传递给符号指向的函数。

既然符号本身也是数据,那么如果形式的第一个元素不是符号,而是返回符号的函数调用,能否正常调用函数呢?还记得 eval 本身也是个函数吗?

((eval 'my-fn) 1 2)
;; => 3

之前我们提到对 (eval (quote x)) 求值和对 x 本身求值是等价的,这里也成立。对 my-fn 求值返回的是个符号,对 (eval 'my-fn) 求值得到的也是个符号。只要形式的第一个元素是指向函数的符号,就能完成函数调用。

根据条件返回不同类型的符号也是可以的:

(def a 1)
;; => #'user/a
((if (> a 1) + -) 2 1)
;; => 1

好了好了,没人会这么写程序的。你现在知道,函数形式就是操作符为指向函数的符号的形式,对函数形式求值就是调用函数。还记得刚才让一个符号指向一个值的 def 吗?如果要用来声明函数,还有更简便的写法:

(def my-fn 
 (fn [a b] (+ a b)))

;; 等价于 
(defn my-fn [a b]
 (+ a b))

写 Clojure 函数的时候你多半只会用到 defn,但不要忘了函数名和变量名一样,都只是符号而已。

记住这个原理你能做到很多有趣的事情。比方说,在其他语言里我们经常对值做一些操作,然后赋值给变量,就像这样:

(def a (reduce + '(1 2 3)))
;; => #'user/a 

a 
;; => 6

我们对列表 (1 2 3) 做了累加操作,变成了 6,然后赋值给 a。函数也可以这么干,比如 partial 就是对函数的操作,可以让我们提前填充函数的某些参数,然后返回新的函数:

(def plus-one (partial + 1))
;; => #'user/plus-one 

(plus-one 3)
;; => 4

((partial + 1) 3)
;; => 4

我们对函数 + 做了 partial 操作,给它提前填上了一个参数 1。调用得到的新函数就相当于调用 (+ 1 ...)。在其他语言里,要做到类似的事情,你可能要这么写:

(defn plus-one [num]
 (+ 1 num))

你可能注意到了 Clojure 函数和非 Lisp 语言的另一个区别:没有 return 语句。函数如果有多个并列的形式,就会以最后一个形式的值作为函数的返回值。

做个习题,下面这段函数调用为什么返回了 nil?

(defn chatty-add [a b]
 (let [result (+ a b)]
 result 
 (println result)))

(chatty-add 1 2) ;; => nil

Lambda 形式

Lambda 形式类似 JavaScript 里的立即执行函数,也就是定义一个匿名函数,然后立马执行它,这样就可以限定变量的作用域。

(function (a, b) {
 let c = a + b 
 return c
})(1, 2)
// => 3

如果是 Clojure,就要这么写:

((fn [a b] (+ a b)) 1 2)
;; => 3

Lambda 形式的操作符不是指向函数的符号,就是函数本身。

在 Scheme(另一门 Lisp 方言)里要这么写:

((lambda (a b) (+ a b)) 1 2)
;; => 3 

在 Scheme 里,这种写法有个语法糖,叫 let:

(let ((a 1) (b 2)) 
 (+ a b))

没错,这和 JavaScript 和 Rust 等语言用于声明变量的 let 很类似,但 Scheme 中 let 仅仅是立即执行函数的语法糖。不过,在 Clojure 中,let 是个特殊形式,有专门的 解析器 ,我会在后文展开。

宏形式

前面我一直在用加减法做例子,现在不妨来看看逻辑运算。Clojure 中表示与、或、非运算的操作符分别是 and、or 和 not。其中 not 是个非常简单的函数:

(defn not [x] 
 (if x false true))
;; 如果 x 为真,返回 false
;; 如果 x 为假,返回 true

但 and 和 or 不可以是函数,必须是宏。为什么?我们不妨假设他们是函数,看看会发生什么。

(let [a 1 b 2]
 (or (> b 1)
 (> a 1)))

首先要对 or 函数的所有参数求值,得到值之后才能调用函数。(> b 1) 为 true,(> a 1) 为 false,然后得到 (or true false),最后算出值为 true。

你发现了吗?我们做了一步完全多余的运算。根本不需要计算 (> a 1) 为 false,只要算出 (> b 1) 为 true,就可以算出整个 or 形式的值为 true,因为逻辑运算或只要求其中一个值为真。

如果 or 是函数,就必须做那一步多余的运算,否而得不到值,无法调用函数。然而,有些运算是有副作用的,比如从消息队列中取出一条消息,会导致其他人无法再次从队列中取到它,若 or 运算丢弃了这条消息,数据就丢失了。

那怎么办呢?有什么办法可以让参数不被预先求值吗?

答案就是宏(macro)。宏这个机制被不少语言借鉴,比如 Rust 和 Haskell(在 Haskell 里叫 Template,模板),其实本质就是对还没求值的列表做预处理。

定义宏就是定义一系列对列表做变换的规则,宏作为形式的操作符时,会先进行宏展开(macroexpand),然后对展开的结果求值。宏展开之前,所有的列表都不会被求值。

我们来看看 or 这个宏的定义:

(defmacro or
 ([] nil)
 ([x] x)
 ([x & next]
 `(let [or# ~x]
 (if or# or# (or ~@next)))))

没有参数时n展开为 nil,只有一个参数时展开为这个参数本身,有多个参数时会被展开为嵌套在 let 里的 if,我们看到宏还能像函数一样递归,因为 or 的定义包含了 or 本身。

这看起来有点难以理解,我们不妨手动展开一下:

(macroexpand '(or (> b 1) (> a 1)))
;; => (let* [or__5600__auto__ (> b 1)] (if or__5600__auto__ or__5600__auto__ (clojure.core/or (> a 1))))

;; 整理得到:
(let* [or__5600__auto__ (> b 1)] 
 (if or__5600__auto__ 
 or__5600__auto__ 
 (clojure.core/or (> a 1))))

;; clojure.core/or 就是包含命名空间的 or,指向同一个宏 
;; 由于单参数的 or 展开为参数本身,所以可以得到: 
(let* [or__5600__auto__ (> b 1)] 
 (if or__5600__auto__ 
 or__5600__auto__ 
 (> a 1)))

;; 为了方便理解,我们再把 let 删掉: 
(if (> b 1) (> b 1) (> a 1))

if 是个特殊形式,和 let 一样有自己的解析器。当 if 计算 (> b 1) 得到 true 时,就会选择第二个参数求值,第二参数是 (> b 1),得到 true,于是返回 true;第三个参数 (> a 1) 没有被求值,我们不知道他是 true 还是 false。

还记得一开始的 fn 吗?如果 fn 是个函数,下面这段表达式会变成什么样子?

(fn [a b c]
 (println a b c)
 (+ a b c))

如果 fn 是函数的解释

首先对 [a b c] 求值,解释器会发现 a 这个符号不存在,然后报错。

关于怎么自定义宏,就放在之后的文章再讲了。

特殊形式

前面提到 if 和 let 都是特殊形式,可以理解为其他语言里的保留字。这些符号有特殊的解释规则,或者编译规则。特殊形式就是由编译器和解释器实现的形式。函数和宏都是用户定义的,只不过有的定义在标准库里。

有趣的是,Clojure 的 let 最外层其实是个宏,而 let* 才是特殊形式,只不过 let 最终会被展开为 let*。

(macroexpand '(let [a 1 b 1] (+ a b)))
;; => (let* [a 1 b 1] (+ a b))

看起来 let 被展开之后仅仅是把 let 替换成了 let*,为什么要套一层宏呢?

这次我们故意把 let 写错,看看会发生什么。

(macroexpand '(let [a 1 b] (+ a b)))
;; Syntax error macroexpanding clojure.core/let at (REPL:1:1).
;; [a 1 b] - failed: even-number-of-forms? at: [:bindings] 
;; spec: :clojure.core.specs.alpha/bindings

报错了,错误在 [a 1 b],失败原因是 let 里写的符号绑定应该是偶数个。这个语法检查并不是编译器实现的,而是宏定义的:

(defmacro let
 [bindings & body]
 (assert-args
 (vector? bindings) "a vector for its binding"
 (even? (count bindings)) "an even number of forms in binding vector")
 `(let* ~(destructure bindings) ~@body))

assert-args 是标准库内部的宏,用来断言(assert)参数是否符合规范(spec)。在 let 的宏定义中,它要求 bindings(符号绑定)是向量(也就是包裹在 [] 里的数据结构),并且是偶数个。断言失败就不会继续展开宏,而是会报错。

如果没有问题,let 就会被展开为 let*,展开之前还对 bindings 执行了 destructure 操作。这是解构,解构是 Clojure 的语法特性,Python 和 JavaScript 也有解构:

let anObject = { a: 1, b: 2, c: 3 }
const {a, b, c} = anObject 
console.log(a, b, c) // 1 2 3

上述代码用 {a, b, c} 代替了变量名,用来从 anObject 这个对象里提取和变量名同名的属性。这就是解构操作。

Clojure 没有用编译器实现解构,而是用 destructure 函数实现的,这个解构函数在 let 宏展开时调用。以下是用 let 解构的例子:

(def a-map {:a 1 :b 2 :c 3})
(let [{:keys [a b c]} a-map]
 (println a b c))
;; (out) 1 2 3 
;; => nil

如果对上面的 let 形式做宏展开,就会得到:

(let* [map__147 a-map 
 map__147 (if (seq? map__147) (if (next map__147) (clojure.lang.PersistentArrayMap/createAsIfByAssoc (to-array map__147)) (if (seq map__147) (first map__147) clojure.lang.PersistentArrayMap/EMPTY)) map__147) 
 a (get map__147 :a) 
 b (get map__147 :b) 
 c (get map__147 :c)] 
 (println a b c))

正因为 let 本身是宏,而非单独的编译器实现,Clojure 的 let 和其他 Lisp 的 let 比起来才好用不少。在 Common Lisp 和 Scheme 中,let 和 let* 的作用不同,比方说下面这段代码是有问题的:

(let ((a 1) (b 2) (c (+ a b))) 
 (+ a b c))
; in: LET ((A 1) (B 2) (C (+ A B)))
; (+ A B)
;
; caught WARNING:
; undefined variable: COMMON-LISP-USER::A
;
; caught WARNING:
; undefined variable: COMMON-LISP-USER::B
; ...

求值 (+ a b) 的时候,Common Lisp 解释器会认为 a 和 b 没有被绑定,因为 (c (+ a b)) 和 a、b 的绑定在同一个 let 里。如果要使用在同一个 let 定义的其他符号,就要用 let*:

(let* ((a 1) (b 2) (c (+ a b))) 
 (+ a b c))
; 6

《 A Scheme Primer 》里写到,如果 Scheme 的规范要从头开始指定,最好要把 let、let* 还有 letrec(允许递归引用符号自己的 let)都合成同一个。Clojure 作为较新的一门 Lisp 方言,少了很多历史遗留问题。


最后

现在你应该知道求值 Clojure 形式时,解释器底下大概都发生了什么,你应该也熟悉了一些基本的函数、宏和特殊形式,比如 let if 和 fn,可以写一些简单的 Clojure 程序了。

其实除了本文提到的四种形式,Clojure 还可以用特定类型的数据作为操作符(比如用一个集合做操作符,用于查看参数是否存在于集合内),也可以当作一种类型的形式。这要留到下篇讲了。

下一篇我们要了解 Clojure 里的集合数据类型,分别是我们已经见过的列表(list)和向量(vector),此外还有集合(set)和映射(map)。我们还会讲到如何操作这些集合,包括遍历和筛选等等。我们还会了解到惰性序列(Lazy sequence),然后你就可以写程序处理相对复杂的数据,并且能够到同时做到语法简洁和高性能。这些都是后话了。

回见!

添加评论
点赞收藏
点踩分享查看原文
评论
?
参与讨论