7.3 Clojure语言
鉴于Clojure与众多主流编程语言截然不同,我们将更详细地介绍Clojure基础知识,这包括:
- 语法;
- 表达式;
- 定义变量;
- 定义函数;
- 数据结构(数字、字符串和集合);
- 数组迭代和循环;
- 条件。
7.3.1 语法
Lisp和Clojure都遵循代码即数据、数据即代码的原则。Lisp的这种性质被称为同像性(homoiconicity),这意味着语言的语法与使用该语言编写的程序的结构类似。Lisp的内置数据类型之一是列表,它用于编写代码。定义列表后,可在其中添加表达式。表达式包含一个函数引用及其参数。在运行阶段,到达列表结束位置后,将动态地执行列表。大致而言,整个程序都是由Clojure的内部数据结构表示的。Clojure包含一个名为阅读器(reader)的进程,它读取并执行每个列表项,再创建相应的数据结构并将其传递给编译器。下面来看一个简单的表达式示例:
(println "Hello" "from Clojure!")
请在Clojure的REPL shell中输入这些代码,REPL shell将在控制台中打印如下内容:
Hello from Clojure!nil
字符(和)分别开始和结束列表。在这里,列表的内容是单个可被阅读器读取并执行的表达式。这个表达式包含对Clojure函数println的调用。函数println是一个参数可变的(variadic)函数,这意味着它将列表中的其他元素都作为输入参数。这有点像第3章简要讨论过的Java关键字varargs。在这里,有两个参数,这些参数将在评估后传递给函数println。函数println将传入的字符串打印到控制台,但什么都不返回(如果是在Java中,将使用关键字void来声明这个函数),因此调用这个函数的结果为nil——类似于Java中的null。不同于Java,Clojure中的函数调用都有结果。
你可能会问,与定义固定的语言语法,并让编译器使用传统分析程序进行分析相比,使用包含代码的数据结构(本章主要关注表达式)有何优点呢?答案是这样可编写直接操纵代码的特殊函数(为此只需修改数据结构即可),这些特殊函数被称为宏(macro)。宏通过操纵包含表达式的列表来修改或改进既有的代码。这提供了很多可能性。创建宏是一个很复杂的主题,本书不会介绍,而只关注Clojure提供的函数和宏。
7.3.2 表达式
表达式以函数调用打头,函数调用后面紧跟着参数。稍后你将看到,表达式可以嵌套。
不同于众多的主流语言,Clojure没有运算符的概念,而是提供了返回计算结果的函数。例如,它提供了函数+,用于将当前列表中指定的所有数值相加:
(+ 10 20 30)
这将返回60。
在Clojure中,每个表达式的结果都为单个值。表达式放在列表中;第一个列表项为函数,而其他列表项都是这个函数的参数。先计算参数的结果,再将其传递给函数,这使得可将参数指定为嵌套表达式:
(+ 10 ( * 3 5 ))
将按如下顺序计算上述表达式:
(+ 10 ( * 3 5 ))(+ 10 ( 15 ))(+ 10 15)(25)
第二个参数是一个列表,其结果为15。注意,传递给函数+的第二个参数为15,因此整个表达式的结果为25。Clojure没有运算符优先级的概念,而只是按从左到右的顺序计算包含表达式的列表,因此执行计算时经常需要嵌套列表。
最重要的算术运算函数总结如下。
| 函数 | 描述 | 示例 |
|---|---|---|
+
| 将值相加 |
(+ 10 20) → +30
|
-
| 从左到右将值相减 |
(- 50 25) → 25
|
*
| 将值相乘 |
(* 10 20) → 200
|
/
| 将值相除(更详细的信息请参阅稍后将讨论的数值类型) |
(/ 25 5) → 5
|
quot
| 求商 |
(quot 13 4) → 3
|
rem
| 求余 |
(rem -13 4) → -1
|
mod
| 求模 |
(mod -13 4) → 3
|
inc
| 将值加1 |
(inc 41) → 42
|
dec
| 将值减1 |
(dec 43) → 42
|
max
| 返回最大值 |
(max 100 20 30) → 100
|
min
| 返回最小值 |
(min 0 -1 30) → -1
|
7.3.3 定义变量
Clojure并非纯粹的函数式编程语言,它可创建在不同时间指向不同数据结构的变量,这意味着可能带来副作用。变量是使用函数def定义的:
(def var-name)
如果没有指定值,变量将是未绑定的。可在定义变量的同时指定值,从而让变量指向这个值。这将创建一个新的全局绑定:
(def var-name "This is a value")
当前,变量var-name指向字符串This is a value;要让它指向另一个值,可再次使用函数def:
(def var-name 100)
这不会修改之前对var-name的引用;只有以后的代码读取变量var-name时才能看到新指定的值100。
可在每个线程中绑定变量,这样每个线程都有自己的变量副本,但这不的本书的讨论范围之内。
7.3.4 定义函数
要创建函数,最简单的方式是使用函数defn:
(defn greet [name] (println (str "Greetings, " name "!")))
要调用前面定义的函数greet,可像通常那样做:
(greet "reader of this book")
这行代码向控制台打印如下内容:Greetings, reader of this book!。对于函数defn,有几点需要说明。
- 第一个参数为函数名。
- 第二个参数必须是一个
vector对象,其中包含要定义的函数的输入参数。如果不需要参数,可使用空vector对象——[]。 - 第三个参数是函数要计算并返回的表达式。
- 返回最后一个表达式的结果。这里为
nil,因为函数调用println的结果为nil。
要让函数greet返回true(而不是nil),应将其指定为最后一个列表项,且不能将其放在括号内,因为值true不是函数:
(defn greet [name] (println (str "Greetings, " name "!")) true)
7.3.5 数据结构
在介绍Scala的两章中说过,不可修改的数据是函数式编程的基石。因此,函数不修改数据,而是返回修改后的数据副本,以免修改既有的数据副本。在Scala中,不可修改的列表就是这样的:使用运算符+在不可修改的列表中添加新元素时,将返回一个新列表,而原来的列表保持不变。
乍一看,存储多个稍微不同的数据副本会消耗宝贵的内存,看起来是过于浪费,但实际上,Clojure通过使用复杂的数据结构,巧妙地使用引用字段,因此在修改后的数据副本中,只有发生了变化的数据需要占用额外的空间。
假设我们创建了一个简单的列表,它包含4个元素:

我们修改这个列表中的第二个元素,这导致Clojure创建一个新列表。在这个新列表中,除第二个元素外,其他元素都指向原始列表中的元素:

由于每个版本的数据都是不可修改的且永远不会发生变化,因此这些引用始终是有效的。
这被称为永久性数据结构,但不要将其与对象关系管理器(ORM)使用的持久化数据库对象混为一谈。
Clojure数据结构是良好的JVM公民,它们遵循第1章和第2章介绍的JVM约定,实现了方法hashCode()和equals(),因此可用于常规Java集合(如HashMap实例)中。另外,它们还使用JVM接口,从而向调用者隐藏了实现细节。Clojure的集合类实现了迭代器,因此可用于for循环中。为实现与Java生态系统的良好兼容性,Clojure开发小组花费了很大的精力,因此Clojure也能够与其他大多数流行的JVM语言很好地兼容。
- 数值类型
出于性能和效率考虑,Clojure将JVM基本数据类型(而不是前几章介绍的包装类)作为默认的数值类型。在Clojure中,整数的默认类型为long。只要一个值可存储在long变量中,Clojure就会使用基本类型long,哪怕这个值使用int变量也能存储。对于浮点值,Clojure默认使用double变量来存储。后面你将看到,Clojure还支持标准Java类库中支持更大值和/或更高精度的类。
必须指出的是,Clojure也支持基本类型包装类。例如,使用
java.lang.Integer类时,Clojure将其自动装箱为基本类型int。
当计算结果不为整数时,将返回一个Ratio对象,这是Clojure特有的功能。我们来看一个示例:
(/ 1 3)
在大多数语言中,整数1和3相除的结果为0(也是整数),但在Clojure中,结果如下,这可能有点出乎意外:
1/3
这是一个Ratio类实例。Ratio类是Clojure运行时库中定义的一个常规JVM类,它将分子和分母存储在不同的字段中。如果要获得双精度结果,而不涉及Ratio对象,必须至少将其中一个整数改为双精度值:
(/ 1 3.0)
这将返回你更熟悉的基本类型double值0.3333333333333333。
要计算商和模,可使用函数quot和mod:
(quot 42 10) (mod 42 10)
在这里,这两个函数的结果分别为4和2。
除了内置的基本数据类型long和double外,Clojure还提供了BigInt类型,其变量可存储的数字要比long变量大得多。Clojure还支持Java类库中java.math包中的Java类BigDecimal。通过在整数前面加上字母N,可让Clojure将其转换为BigInt实例:
(+ 100 1N)
这将返回101N。调用运算符函数时,只要有一个输入参数为BigInt实例,计算结果也将为BigInt实例。BigDecimal的工作原理与BigInt类似。要将一个值转换为BigDecimal实例,只需在它前面加上字母M:
(+ 555 0.4169M)
这将返回555.4169M——完全在意料之中。
Clojure文档指出,调用标准算术运算函数时,如果指定的整数参数无法存储到long变量中,将引发异常。实际情况确实如此:
(* 123 1234567890123456789)
上述代码将引发如下异常:ArithmeticException integer overflow clojure.lang.Numbers.throwIntOverflow。对于7.3.2节的表格中列出的所有算术运算函数,都有包含撇号(')后缀的变种。这些变种在必要时将结果转换为BigInt实例:
(*' 123 1234567890123456789)
你可能会问,上述代码的结果是什么呢?为151851850485185185047N。
- 字符串和字符
与Scala一样,Clojure也使用标准JVM类(java.lang.String)来表示字符串。Clojure的数学函数不能用于字符串。虽然很多语言都使用加法运算符(通常为+)来拼接字符串,但Clojure函数+不支持字符串,而必须使用函数str:
(str "Good" "night!")
上述表达式的结果为Goodnight!。
Clojure提供了大量专门用于字符串的函数,其中很多都是在命名空间clojure.string中声明的。例如,要将列表转换为字符串,可使用clojure.string中的函数join:
(clojure.string/join "/" ["10", 20, 30M, 40N])
结果为10/20/30/40。参数指定了要在元素之间添加什么样的分隔符。虽然这个列表包含多种不同类型的值(字符串"10"、整数20、BigInt实例30M和BigDecimal实例40N),但这些值都被转换为字符串。
推荐使用require来导入clojure.string库:
(require '[clojure.string :as str])(str/join, "/" [1, 2, 3])
下表列出了命名空间clojure.string中其他常用的函数,其中的示例假设使用前述代码行(require '[clojure.string :as str])导入了clojure.string库:
名称描述示例(输入→输出) blank?如果传入的字符串为nil、空或只包含空白字符,就返回true;否则返回false(str/blank? " ")→true capitalize将字符串的第一个字符转换为大写,而其他字符都转换为小写(str/capitalize "JVM rules")→"Jvm rules" ends-with?指出字符串是否以指定的字符或字符串结尾(str/ends-with? "Hi" "i")→true last-index-of返回指定字符串最后一次出现的位置(从零开始的索引);如果没有找到这样的字符串,就返回null(str/last-index-of "HELLO" "L")→3 lower-case将字符串中的字符都转换为小写,并返回结果(str/lower-case "HeLlO")→"hello" replace将字符串中的子串替换为另一个子串(str/replace "HELLO" "ELLO" "i!") s"Hi!" reverse将字符串中的字符按相反的顺序排列,并返回结果(str/reverse "!iH")→"Hi!" split根据正则表达式对字符串进行拆分;请注意,在Clojure中,使用前缀#来表示正则表达式(str/split "a-b-c" #"-")→["a" "b" "c"] split-lines根据换行符(Windows中为CR + LF;Linux和众多其他流行的操作系统中为LF n)对字符串进行拆分(str/split-lines "A\nB\r\nC")→["A" "B" "C"] trim删除开头和末尾的空白字符(即空格、制表符、CR和LF)(str/trim " A\nBC\t\n")→"A\nBC" upper-case将字符串中所有的字符都转换为大写,并返回结果(str/upper-case "abC")→"ABC"
除这里列出的函数外,这个库中还包含其他函数,请务必阅读有关这个库的文档。
Clojure不使用基本类型char来表示字符。在Clojure中,字符为java.lang.Character实例。要创建这样的实例,可在字符前加上反斜杠:
(println \H \e \l \l \o)
这将向控制台打印H e l l o。在这里,传递给函数println的参数不是字符串,因此不需要使用双引号将它们括起。相反,指定的每个字符都将转换为一个java.lang.Character实例,而函数println只是按从左到右的顺序打印这些实例。
如果你知道字符的UTF-16码点,也可使用函数char:
(println (char 65))
这将打印A,因为字符A的ASCII编码为65,而UTF-16编码与ASCII编码兼容。请注意,在这个示例中,里面的括号必不可少,因为需要先计算参数再将其传递给函数。如果你省略里面的括号,将打印指向函数char的引用和65。
- 集合
与Scala一样,Clojure也提供了集合类实现。为提供不可修改和永久性集合,必须这样做(前面说过,这里的永久性指的是集合中的新数据副本使用指向既有数据副本的引用,以节省内存)。
Clojure为集合类提供了接口,使用类的哪个实现也由Clojure决定。调用特定的函数时,Clojure还可能决定修改实现。鉴于所有的集合都实现了相同的接口,这通常不是问题。
接下来将介绍如下集合类型:
- 列表;
- 向量;
- 集;
- 散列映射。
(1) 列表
常规列表是使用函数list创建的:
(list "item 1" "item 2")
列表实现了Clojure接口ISeq——所有Clojure集合都实现了这个接口。可对列表进行迭代(迭代将在本章后面讨论),但不同于稍后将讨论的向量,基于索引的列表迭代未经优化。Clojure提供了函数nth,这个函数迭代列表以找到指定的元素。由于需要遍历整个集合,因此这个函数的效率不是很高:
(nth (list "item A" "item B" "item C") 2)
在这里,我们让函数nth遍历列表,以取回第3个元素(索引是从0开始的)。它像预期的那样返回item C。下面说明了nth的工作原理:

如果列表包含数百个元素,检索效率将极低。所幸稍后你将看到,Clojure提供了一种更适合通过索引来获取元素的数据结构。
要在既有列表中添加元素来创建新列表,可使用函数conj。下面是一个这样的示例:
(conj (list 10 20 30) 40 50)
令人意外的是,返回的新列表为(40 50 10 20 30),这是因为列表经过了优化,将新元素放在开头。
(2) 向量
向量很像Java类ArrayList。与ArrayList对象一样(但不同于列表),向量针对根据索引获取元素进行了优化。要创建向量,可使用函数vector:
(vector 10 20 30)
上述代码返回[10 20 30]。你也可使用方括号来创建向量,因此下面的代码也管用:
[10 20 30]
在这个示例中,我们没有添加括号,因为如果这样做,就必须添加函数调用。
虽然可使用函数nth来获取指定索引处的元素,但不推荐这样做。前面讨论过,函数nth迭代整个集合来查找元素。对于向量,使用函数get的效果要好得多。函数get通过直接读取引用来获取元素,但不能用于列表:
(get [10 20 30] 1)
这个表达式返回20。
向量经过了优化,它将新元素添加到集合末尾。因此,下述在向量中添加元素的代码将返回[10 20 30 40 50],这完全在意料之中:
(conj [10 20 30] 40 50)
(3) 集
集是一组各不相同的值,你不能再添加已有的值。要创建散列集(一种不按添加顺序存储元素的集),可使用下面的字面量表示法:
#{ 10 20 30 }
在返回的集中,元素的排列顺序可能与指定顺序不同;我执行这段代码时,返回的是#{20 30 10}。
要创建按添加顺序排列元素的有序集,可使用函数sorted-set:
(sorted-set 10 20 30)
在这个示例中,返回的集为#{10 20 30},其中元素的排列顺序与指定顺序相同。需要指出的是,有序集的开销比散列集大。
与向量一样,要从集中取回值,可使用函数get;要在集中添加值,可使用函数conj。
(4) 散列映射
与向量类似,要创建散列映射,可使用函数hash-map,也可使用{}。下面的两段代码等价:
(hash-map :key1 "value1", :key2 "value2"){:key1 "value1", :key2 "value2"}
将键-值对分隔开来的逗号是可选的,但建议不要省略它们,因为这样可提高代码的可读性。与大多数标准散列映射实现一样,键-值对的排列顺序是不可预测的。在我的计算机中,打印出来的输出如下:{:key2 "value2", :key1 "value1"}。
在前面的示例中,两个键都是关键字。要创建关键字,可在名称前加上冒号(:)。在散列映射中,键并非必须是关键字,但推荐尽可能使用关键字,因为它们的效率非常高,让查找的速度非常快。关键字的结果为其本身,因此当你在REPL中输入:key1时,将看到打印出来的输出也为:key1。
要获取散列映射中的值,可使用你熟悉的函数get,这个函数也用于获取向量中的元素:
(get { :key1 "value1", :key2 "value2" } :key2)1
这将返回"value2"。
对于将关键字用作键的散列映射,获取其中的值时可省略get函数调用:
(:key1 { :key1 "value1", :key2 "value2" })
这将返回"value1"。
要通过在既有散列映射中添加和修改键-值对来创建新的散列映射,可使用函数assoc:
(assoc { :k1 "v1", :k2 "v2" } :k3 "v3", :k2 nil)
这将创建如下散列映射:{:k1 "v1", :k2 nil, :k3 "v3"}。
要合并两个散列映射,可使用函数merge:
(merge { :k1 "v1", :k2 "v2" } { :k2, nil, :k3 "v3" })
这也将返回{:k1 "v1", :k2 nil, :k3 "v3"}。
要检查散列映射是否包含指定的键,可使用函数contains?:
(contains? { :k1 "v1", :k2 "v2" } :k3)
由于这里的映射没有包含键为关键字:k3的元素,因此上述代码返回false。
- 数组迭代和循环
Clojure函数for的功能极其强大。在最简单的情形下,这个函数只是对集合进行迭代:
(for [x ["A" "B" "C"]]x)
这将返回新列表("A" "B" "C")。
可在for循环中包含多个迭代器:
(for [x [1 2 3],y [100 200]](+ x y))
与往常一样,逗号是可选的。这些代码返回新列表(101 201 102 202 103 203)。
通过使用关键字:let,可定义一个局部函数,并在每次迭代中调用它。这个局部函数只能在for循环内使用,且不能修改:
(for [x [10 20 30]:let [y (* 2 x)]](list x y))
这些代码返回一个包含三个列表的新列表:((10 20) (20 40) (30 60))。
还可使用关键字:while来添加一个返回true或false的函数;当这个指定的函数返回false时,将停止迭代:
(for [x [10 20 30]:let [y (* 2 x)]:while (<= y 40)](list x y))
这些代码返回列表((10 20) (20 40))。
最后,可使用关键字:when来指定条件。指定的条件表达式必须返回true或false。如果这个表达式为true,就将相应的值添加到列表中,否则就忽略它,并继续迭代:
(for [x (range 10):let [y (* x x)]:when (= (mod y 2) 0)]y)
上述代码返回(0 4 16 36 64),其中函数(range 10)生成一个包含0~9(含)的序列。
- 条件
前面在函数for中指定关键字:while和:when时,简要地介绍了一些条件。同样,Clojure没有提供条件运算符,而是提供了一些结果为true或false的普通函数。下表列出了一些最重要的条件函数,它们的参数数量都是可变的,这意味着你至少可以指定两个参数。
函数描述示例(输入→输出) ==如果指定的所有参数表示的值都相同,就返回true(== 42 42.0 42M 42N)→true not=只要至少有一个参数与下一个参数不相等,就返回true(not= 1 1 2)→true <如果传入的每个参数都比下一个参数小,就返回true(< -1 5 10)→true >如果每个参数都比下一个参数大,就返回true(> 10 5 -1)→true <=如果传入的每个参数都小于或等于下一个参数,就返回true(<= 5 5 6)→true >=如果传入的每个参数都大于或等于下一个参数,就返回true(>= 6 6 5)→true and逻辑与(and (> 6 5) (< -1 10) )→true or逻辑或(or (== 3 10) (> 5 3))→true not逻辑非(not (== 1 5))→true
Clojure还提供了逻辑函数if:
(if (< 100 10) "This is true" "This is completely false")
如果第一个参数为true,就计算并返回第二个参数,否则返回第三个参数。上述表达式的结果显然为"This is completely false"。对于函数if,有几点需要说明。
- 用于函数
if时,nil相当于false。 else部分(第三个参数)并非是必不可少的。如果没有指定第三个参数,且条件为false,则整个表达式的结果为nil。
