02.快速上手

快速上手

这一节让我们来了解一下那些“使用 Clojure 编程必须要掌握的东西”。

其实 Clojure(以及其他 lisp 系语言)的学习曲线还是比较陡峭的。学习 Clojure 之前必须要掌握一些基础的数据结构和算法知识。

同像性

其实 Lisp 系语言的语法并没有什么可说的,因为 Lisp 系语言的代码实际上是由Lisp 数据构成的。换句话说,当我们编写 Lisp 代码时,我们事实上是在描述一个数据结构。这种特性被称作同像性。例如,当我们在 Lisp 中调用函数时,实际上我们是写下了一个列表结构:

(+ 1 1) ; 函数调用
;=> 2
(class (+ 1 1)) ; 查看(+ 1 1)的类型
;=> clojure.lang.PersistentList

可以看到,(+ 1 1)的类型实际上是一个 PersistentList。

因此,学习 Lisp 的语法其实就是在学习 Lisp 的数据表示。

同向性带来的好处是我们可以像操作数据结构一样操作代码,这为 lisp 系语言带来了其他语言不可比拟的元编程能力。

形式(Form)

lisp 的语法单元被称为形式。形式指那些能够被读取器读入的代码片段。诸如数值、字符串、列表以及其他复合结构都是形式。相对的,一个没有闭合的列表不是一个形式。

在 Clojure 中,有一类特殊形式,比如用于定义变量的def,它们是语言的基础组成部分。

数值类型

和绝大多数语言一样,Clojure 中自然也有数值类型,对数值类型求值,当然也会返回这个数值本身(废话!)

除了常见的整型和浮点数外,Clojure 还支持任意精度实数和任意精度整数:

(class 0.0000000000001M) ; 任意精度实数
;=> java.math.BigDecimal ; 它的实际类型
(class 999999999999999999N) ; 任意精度整数
;=> java.math.Bigint

除此之外,Clojure 还支持“有理数”类型:

(/ 1 3)
;=> 1/3 ; 除法的结果以分数形式表示

相较于浮点数,有理数类型不会损失精度,但会带来一定的性能损失。

数值计算

Clojure 的数值计算与普通的函数调用并无不同:

(+ 1 2) ;=> 3
(- 2 1) ;=> 1
(* 2 2) ;=> 4
(/ 4 2) ;=> 2
(* (+ 1 2) 3) ;=> 9

有趣的是,这些函数都是可以接受任意数量的参数的:

(+ 1 2 3 4) ;=> 10
(apply * [1 2 3 4]) ;=> 24
(- 1) ;=> -1
(+) ;=> 0
(*) ;=> 1

数值比较

数值比较在 Clojure 中也是函数:

(> 2 1) ; => true
(= 1 1) ;=> true

这些函数也可以接受任意参数,因此,我们可以用>=函数判断一个数列是否为降序:

(defn desc? [nums] (apply >= nums)) ; 定义函数desc?
;=> #'user/desc?
(desc? [4 3 2 1])
;=> true

注:在 Clojure 中有一个命名约定:返回值为布尔类型的函数应该以问号结尾。

布尔值与 nil

布尔值只包含 true 和 false 两个值。在 Clojure 中,nil 相当于 java 中的 null。Clojure 的真值判断遵循一个简单的规则:false、nil 为假,其余值都为真。

!注意:在 Clojure 中,空列表不为假

条件判断

Clojure 中自然也有分支跳转,需要用到一个叫 if 的特殊形式:

(if test then else?)

if 十分简单,当 test 为真时返回 then,为假时返回 else(如果有的话)。注意,当 test 为假时,else 是不会被求值的。

(if (= (+ 1 1) 2)
  "Math still works today!"
  (println "Never happens"))
;=> "Math still works today!"

与 Java、C 语言的 if 不同,Clojure 的 if 是具有返回值的。因此其实它相当于其他语言的三元运算符。

if 中的 then 块只能包含一个形式,如果需要执行多条语句可以使用 when:

(when (= (+ 1 1) 2)
  (println "print something")
  "Math still works today!")
;; print something
;=> "Math still works today!"

注意:when 没有 else 块,如果判断条件不满足,when 会返回 nil。

许多语言都有 if-else if-else 的语言结构,在 Clojure 中可以使用cond实现同样的效果:

(defn speak [x]
  (cond
    (= x :dog) "Woof!Woof!Woof!"
    (= x :cat) "Mew~"
    (= x :repeater) *1  ; *1是REPL的特殊变量,表示上一个在REPL中求得的值
    :else nil)) ; :else会被求值为真,因此当上述条件都不满足时就会返回nil
(speak :dog) ; => "Woof!Woof!Woof!"
(speak :repeater) ; => "Woof!Woof!Woof!"
(speak :cat) ; => “Mew~”
(speak :repeater) ; => “Mew~”
(speak :monkey) ; => nil

Clojure 中也有类似 switch 的结构case,上述例子可以用 case 进行改写:

(defn speak2 [x]
  (case x
    :dog "Woof! Woof! Woof!"
    :cat "Mew~"
    :repeater *1
    nil)) ; 最后一行表示默认情况

符号与变量

Clojure 中的符号类似其他语言中的标识符。除了能够使用字母、数字、下划线**(注:符号不能以数字开头)**以外,Clojure 符号还能够使用一些特殊符号,如+、-、*、/、<、>、.等等。注意,除号(/)和句点(.)常被用于命名空间。

Clojure 中可以使用特殊形式 def 声明一个变量:

(def myname "Khellendros")
;=> #'user/myname
myname
;=> "Khellendros"

变量的初始值不是必须的:

(def no-value)
no-value
#object[clojure.lang.Var$Unbound 0xcc0a548 "Unbound: #'user/no-value"]

使用 def 定义的变量都是全局变量,使用特殊形式 let 可以定义局部量,这些局部量只能在 let 范围内使用:

(let [myage 22, mygender :male]
  (do (println "Name: " myname)
  (println "Age: " myage)
  (println "Gender: " mygender)))
;; Name:  Khellendros
;; Age: 22
;; Gender: :male
;=> nil
myage
;=> CompilerException java.lang.RuntimeException: Unable to resolve symbol: myage in this context, compiling:(null:0:0)

关键字

关键字类似于符号,不同之处在于,符号通常都会引用其他事物(比如变量和函数名),而关键字仅仅代表它本身。关键字的命名规则与符号类似,但必须以冒号(:)开头,且数字可用紧跟着冒号。

:a-keywd
;=> :a-keywd
:1+!
;=> :1+!

关键字最常见的用法是充当关联结构(如 map 和 set)的键值。在 Python 和 JavaScript 中,我们通常会用字符串充当键值,然而,比较两个字符串是相当低效的做法,关键字相当于是单例对象,只需要比较它们的内存地址就可以了,效率显然要高很多。此外,关键字也可以用于表示枚举值。

字符与字符串

在 Clojure 中,用反斜杠后面紧跟着一个字符表示字符类型:

(class \a)
;=> java.lang.Character

而字符串和 java 一样用双引号表示:

(class Hello)
;=> java.lang.String
字符串中间可以换行:
Hello
world!
;=> “Hello\nworld!”

str 函数用以将一个值转化为字符串:

(str 1) ;=> “1”

str 也可以接受多个参数,此时它会将参数转化为字符串后拼接起来,并且会跳过 nil

(str 1 2 nil 3) ;=> “123”

正则表达式

在 Clojure 中可以使用井号(#)加字符串的方式定义一个正则表达式:

(def words #\w+)

也可以使用 re-pattern 函数,不过注意要对正则表达式中的特殊字符用双反斜杠(\)转义:

(def words2 (re-pattern \\w+))

使用 re-seq 可以找到字符串中所有的匹配项:

(re-seq words aaa bbb|ccc,ddd)
;=> (“aaa” “bbb” “ccc” “ddd”)

我们可以在正则表达式中添加分组:

(def middle-part #"\w+\-(\w+)\-\w+")
;=> #'user/middle-part
(re-seq middle-part "aaa-AAA-aaa bbb-BBB-bbb ccc-CCC-ccc")
;=> (["aaa-AAA-aaa" "AAA"] ["bbb-BBB-bbb" "BBB"] ["ccc-CCC-ccc" "CCC"])

此时 re-seq 将返回一个二维序列。

复合类型

Clojure 提供了一组功能非常强大的容器,包括列表(list)、向量(vector)、映射表(map)、集合(set),由于篇幅有限这里仅对它们做一些简单的介绍。

所有这些 Clojure 容器都是不可变的,当我们使用诸如 replace 这样的函数改变容器内的元素时,我们将得到一个新的容器,而旧容器始终保持不变。

也许你会觉得这样的做法十分低效:“每次我要对容器进行更改时都需要把整个容器都复制一遍?”。其实恰恰相反,Clojure 容器是十分高效的。不可变也意味着更容易实现“共享”——新旧容器之间可以共享大部分内容,当我们对容器做出更改时,通常只会生成一个新的根结点而不用拷贝整个容器。

**列表(list)**是 Clojure(以及各种 Lisp 系语言)中最常见的结构,不过在 Clojure 中它的主要作用不是作为容器而是用来表示函数(或宏)调用。列表以一对括号表示,列表中的元素以空格或逗号分隔。

(+ 1 1)
;=> 2

在 Clojure 中,最常用的容器是向量(vector)。向量以一对中括号([ ])表示:

[1 2 3]
;=> [1 2 3]

向量的一大特点是它支持高效的随机访问。虽然向量的底层不是数组(array),但它进行随机访问的效率和数组相差无几。

(nth [1 2 3] 1) ; => 2
(get [1 2 3] 1) ; => 2

向量本身也可以当做函数使用,效果等同于 nth:

([1 2 3] 1) ;=> 2

!但是注意,向量不能当成集合来使用,对向量使用 contains?函数将永远返回 true,哪怕该元素其实并不存在。

(contains? [1 2 3] 0) ;=> true

**映射表(map)**常用来表示一些相互关联的键值对,它使用花括号({ })来定义。

(def me {:name "Khellendros", :age 22, :gender :male})
;=> #’user/me

!注意:映射表中不能出现重复的键

使用 get 函数可以根据键查找值:

(get me :name)
;=> "Khellendros"

映射表也可以直接当成函数使用,效果等同于调用 get 函数:

(me :gender)
;=> :male

此外,关键字也能当成函数使用:

 (:age me)
;=> 22

使用关键字作为函数对映射表进行查询,是 Clojure 的一种惯用法。

映射表的键可以是任意类型,同时同一个映射表的键的类型也可以各不相同:

(def mess-map {
  :name "@#$$%",
  [1 2 3] "aaa",
  "?" 233 } )
;=> #'user/mess-map
(mess-map [1 2 3])
;=> "aaa"
(mess-map "?")
;=> 233

**集合(set)**相当于键和值相同的映射表。集合使用井号加花括号(#{ })表示,集合的内容不能重复。

(def img-exts #{"jpg" "gif" "png" "bmp"})
;=> #'user/img-exts

集合通常用来判断其是否包含某个元素:

(contains? img-exts "jpg")
;=> true

集合自身也可以当做函数使用,效果等同于 get

(img-exts "jpg")
;=> "jpg"
(img-exts "txt")
;=> nil

解构

复合类型可以进行解构。复合类型的构造可以看做是将多个量聚合成一个,而解构则是构造的逆过程,可以将复合解构拆解成多个量。

解构可以在许多地方发生,这里先以上面提到过的 let 为例:

(def nums [1 2 3])
;=> #’user/nums
(let [[a b c] nums] ; 解构nums
  (+ a b c))
;=> 6

可以看到,向量 nums 的第 0、第 1、第 2 项分别被绑定到了变量 a、b、c 上。

我们可以只取列表的前 n 项而忽略余项:

(def natural-nums (iterate inc 0)) ; 表示全体自然数
;=> #'user/natural-nums
(let [[a b c] natural-nums] (+ a b c))
;=> 3

在这里 natural-nums 表示全体自然数的总集,因此它是一个无限长的序列,但因为我们只取其前三项,因此不会出现无限循环。

如果要只区第 2 项,忽略第 0 和第 1 项可以这么写:

(let [[_ _ a] natural-nums] a)
;=> 2

下划线(_)是一个合法的符号名,使用下划线忽略某些我们不关心的值是一种惯用法。注意这里下划线被绑定了两次,因此它最终的值是 1 而不是 0。

:as 命令可以将整个结构绑定到一个局部量上:

(let [[a b c :as all] nums] (str "Sum of: " all " = " (+ a b c)))
;=> "Sum of: [1 2 3] = 6"

除了向量以外,映射表也可以进行绑定:

(let [{name :name, age :age} me] (str name " is " age " years old."))
:=> "Khellendros is 22 years old."

这种要把键名打两遍的做法略显繁琐,我们可以使用:keys 命令进行简化:

(let [{:keys [name age]} me] (str name  is  age  years old.))
:=> "Khellendros is 22 years old."

除了 let 以外,其他可以绑定局部量的位置都可以进行解构,包括但不限于函数参数,

loop,for 等(见下文)。

函数

Clojure 中有许多定义函数的方式,最常见的是使用宏 defn:

(defn hello ;定义函数
  "Say hello to someone." ;文档说明
  [name] ;函数参数
  (str "Hello, " name "!")) ;函数体
;=> #'user/hello
(hello "World")
;=> "Hello, World!"

函数可以有多个参数列表和函数体,此时各个参数列表的参数数量需要各不相同:

(defn vec-of
  ([a] [a])
  ([a b] [a b]))
;=> #'user/vec-of
(vec-of 1)
;=> [1]
(vec-of 1 2)
;=> [1 2]

在函数的参数声明处也可以对参数进行解构:

(defn third [[_ _ x]] x)
;=> #'user/first3
(third natural-nums)
;=> 2

代码块

函数体只能包含一个形式,如果我们要执行多个表达式怎么办呢?使用特殊形式 do 可以解决这个问题:

(do
  (println "first")
  (println "second")
  "not return"
  "return")
;;first
;;second
;=> "return"

block 会将最后一个形式的值当做返回值。

匿名函数

一些函数会使用一个回调函数作为参数,回调函数通常都只有一两行代码,如果我们懒得给它们起名字,可以使用匿名函数。匿名函数使用 fn 定义:

(def double-n (fn [n] (* n 2)))
;=> #’user/double-n
(double-n 10)
;=> 20

匿名函数还有一种简写形式,称作原位函数:

(def double-n-2 #(* % 2))
;=> #’user/double-n-2
(double-n-2 10)
;=> 20

原位函数使用井号加括号(#( ))定义,其中%[n]表示第 n 个函数参数,%1 表示第一个参数,%2 表示第二个……以此类推。单独一个%等价于%1

递归

Clojure 不提供类似 java 的 while 和 for 循环,需要进行迭代时可以使用递归。在 Clojure 中可以使用特殊形式 recur 进行尾递归

(defn count-down [n]
  (when (pos? n) ; 如果n是正数就继续执行,否则返回nil
  (println n) ; 输出n
  (recur (dec n)))) ; 递减n,然后递归调用count-down
;=> #'user/count-down
(count-down 10)
;;10
;;9
;;8
;;…
;;1
;=> nil

recur 类似于 C 语言的 goto 语句,“跳转点”默认为 recur 所在的函数开始处,我们也可以用特殊形式 loop 自定义跳转点:

(defn count [start end]
  (loop [n start, end end]
  (when (< n end)
  (println n)
  (recur (inc n) end))))
;=> #'user/count
(count 0 10)
;; 0
;; 1
;; 2
;; …
;; 9
;=> nil

在 loop 中,我们绑定了两个局部量:n 和 end,当 recur 被调用时,他会将参数传递给 loop 绑定的变量。

注意:recur 只能放置在一个函数或 loop 的出口处

遍历

虽然没有传统意义上的 while 和 for 循环,但是 Clojure 提供了类似 java 的 foreach 循环的设施——doseqfor。为了方便理解,我们会通过对比 java 代码来对它们进行说明。

doseq 主要用于产生副作用(比如输出到控制台)。我们通过一个实际的例子来讲解一下 doseq 的用法:

(doseq [n [1 2 3]] ; 将列表[1 2 3]中的每一个元素依次绑定到n
  (println n))
;; 1
;; 2
;; 3
;=> nil

下面是功能相同的 java 代码:

List<Integer> nums = Arrays.asList(1, 2, 3);
for (int num : nums) {
    System.out.println(num);
}

doseq 还能对多个结构进行遍历:

(doseq [m [1 2], n [3 4]]
  (println
  (str m " + " n " = " (+ m n))))
;;1 + 3 = 4
;;1 + 4 = 5
;;2 + 3 = 5
;;2 + 4 = 6
;=> nil

上述例子清晰的展示了 doseq 是如何对两个向量进行遍历的。以下是等效的 java 代码:

List<Integer> nums1 = Arrays.asList(1, 2);
List<Integer> nums2 = Arrays.asList(3, 4);
for (int m : nums1) {
    for (int n : nums2) {
        String tmp = m + " + " + n + " = " + (m + n);
        System.out.println(tmp);
    }
}

for 的功能比 doseq 更为强大,它拥有遍历、过滤、变换等多种功能。

; 求笛卡尔积
(for [m [1 2], n [\a \b]] [m n])
;=> ([1 \a] [1 \b] [2 \a] [2 \b])
; 变换
(for [num [1 2 3 4]] (inc num))
;=> (2 3 4 5)
;过滤出偶数
(for [n (range 0 10) :when (even? n)] n)
;=> (0 2 4 6 8)

遍历映射表

对映射表进行遍历时,会将映射表转化为二维序列:

(seq me)
([:name "Khellendros"] [:age 22] [:gender :male])

因此我们可以像操作普通二维序列一样遍历映射表。

需要注意的是,Clojure 映射表的默认实现是哈希表,因此元素的遍历顺序是无法预期的。

与 Java 互操作

在 Clojure 中可以使用已有的 java 类库,包括调用静态方法,构造类实例对象,调用方法,读取、设置字段值等。

调用静态方法/静态字段

可以用 (类名/静态方法名 [方法参数…]) 的形式调用静态方法和静态字段:

(Math/PI)
;=> 3.141592653589793
(Math/abs -1)
;=> 1

构造实例对象

(类名. [构造方法参数…]) 或者 (new 类名 [构造方法参数…]) 可以构造实例对象:

(def nums (java.util.ArrayList.))
;(def nums (new java.util.ArrayList))
;=> #’user/nums
nums
;=> []

方法调用

使用 (.方法名 对象 [方法参数…]) 调用方法:

(.add nums 1)
;=> true
nums
;=> [1]

读取、设置字段

读取字段的方法是 (.-字段名 对象)

(def point (java.awt.Point. 0 1))
;=> #’user/point
(.-x point)
;=> 0

使用 set!函数可以设置字段值。

(set! (.-x point) 2)
;=> 2
(.-x point)
;=> 2

注:在 Clojure 中,以!结尾的函数往往意味着其会带来副作用(比如 set!会改变对象的属性)。我们应该审慎的使用这些函数。

小试牛刀:index-of 函数

我们上面提到 contains?函数对向量无效,我们不妨自己实现一个功能类似的函数 index-of。

index-of 函数使用起来应该是这样的:

(index-of [1 1 4 5 1 4] 4)
;=> (2 5)
(index-of [1 1 4 5 1 4] 0)
;=> ()

如果序列内包含我们想要查找的目标,index-of 会返回由所有匹配项的下表组成的序列。如果没有找到则返回空序列。

首先,我们需要将列表项与其对应的下标关联在一起。还记得我们之前定义的 natural-nums 吗?把它和向量结合起来就可以了:

(defn indexed-vec [vec]
  (map vector natural-nums vec))
;=> #’user/indexed-vec
(indexed-vec [\a \b \c \d])
;=> ([0 \a] [1 \b] [2 \c] [3 \d])

map 函数用于将一个函数应用到一个序列的每一项上,例如:

(map inc [1 2 3])
;=> (2 3 4)

如果传递给 map 两个序列,那么它就可以通过一个二元函数将两个序列结合起来:

(map + [1 2 3] [3 2 1])
;=> (4 4 4)

同理,如果传入 3 个序列就要使用一个三元函数,以此类推。如果两个序列的长度不一致,较长的序列会被“截断”。

而 vector 函数的作用自然是构造一个向量,因此,(map vector natural-nums vec)就会把一个自然数序列(代表下标)和 vec 像拉链一样“拉”在一起。现在向量已经和下标关联起来了,我们就可以使用 for 简单的实现 index-of 了。

(defn index-of [vec item]
  (for [[index value] (indexed-vec vec) :when (= value item)] index) )
;=> #’user/index-of
(index-of [1 1 4 5 1 4] 4)
;=> (2 5)
(index-of [1 1 4 5 1 4] 0)
;=> ()

更进一步?

index-of 工作的很好,但还不够通用。如果对映射表、集合都能用通用的接口进行调用就好了。

pos 函数(通用版本的 index-of)使用起来应该是这样的:

(pos [1 2 3 1] 1) ;=> (0 3)
(pos {:a 1, :b 2, :c 1} 1) ;=>(:a :c)
(pos #{1 2 3} 0) ;=> ()

要做到这一点,首先我们需要定义一个通用版本的 indexed:

(defn indexed [xs]
  (cond
  (map? xs) (seq xs)
  (set? xs) (seq xs)
  :else (indexed-vec xs)))

pos 相较于 index-of 只是把 indexed-vec 换成了 indexed:

(defn pos [xs item]
   (for [[index value] (indexed xs) :when (= item value)] index))

再进一步,我们完全可以把 item 参数换成一个判断函数。来看看这个最终版本的 pos-if 吧:

(defn pos-if [xs pred]
  (for [[index value] (indexed xs) :when (pred value)] index))
;=> #’user/pos-if
(pos-if [1 2 3 1] #(> %1 1))
;=> (1 2)
上一页
下一页