从本节开始,我们会接触到Clojure中一些实质性的内容。从编写函数处理数据开始,让你看到Clojure对函数的重视程度。接着介绍循环结构,以及读取器(reader)宏和派发(dispatch)形式。最后,我们会以Clojure的函数式编程和闭包作为本节的收尾。
举例说明是好办法,所以我们先来几个简单的例子,然后朝Clojure提供的强大函数式编程技术进发。
10.3.1 一些简单的Clojure函数
代码清单10-1中定义了三个函数。其中两个是非常简单的单参函数,另一个稍微有点复杂。
代码清单10-1 定义简单的函数
(defn const-fun1 [y] 1)(defn ident-fun [y] y)(defn list-maker-fun [x f] (map (fn [z] (let [w z] (list w (f w)) )) x))
在这段代码中,(const-fun1)
接受一个参数,返回1,(ident-fun)
接受一个数值并返回数值本身。数学家会管它们叫常量函数和恒等函数。还有,函数定义中使用向量表示函数的参数,(let)
形式中用的也是向量。
第三个函数比较复杂。函数(list-maker-fun)
有两个参数:第一个是包含所处理的值的向量x
,第二个一定是函数。
我们来看一下如何使用list-maker-fun
,如代码清单10-2所示。
代码清单10-2 使用函数
user=> (list-maker-fun [/"a/"] const-fun1)((/"a/" 1))user=> (list-maker-fun [/"a/" /"b/"] const-fun1)((/"a/" 1) (/"b/" 1))user=> (list-maker-fun [2 1 3] ident-fun)((2 2) (1 1) (3 3))user=> (list-maker-fun [2 1 3] /"a/")java.lang.ClassCastException: java.lang.String cannot be cast toclojure.lang.IFn
把这些表达式敲到REPL中实际上是和Clojure的编译器交互。表达式(list-maker-fun [2 1 3] /"a/")
之所以无法编译,是因为(list-maker-fun)
的第二个参数应该是函数,而字符串显然不是。看到10.5节你就会知道,对于VM来说,Clojure函数是实现了clojure.lang.IFn
的对象。
这个例子表明在跟REPL交互时仍然会涉及一些静态类型问题。因为Clojure不是解释型语言。即便是在REPL中,输入的每个Clojure形式都会被编译成JVM字节码并连接到运行时系统上。Clojure函数在定义完后就被编译成JVM字节码了,所以在出现静态类型冲突时VM会报出ClassCastException
异常。
代码清单10-3中的Clojure代码更长。Schwartzian转换可有年头了,从20世纪90年代在Perl中出现后就一直在用。其基本思想是基于向量中元素的某些属性对元素进行排序。排序所依据的属性值是通过在元素上调用键控函数确定的。
代码清单10-3中定义的Schwartzian转换所调用的键控函数是key-fn
。在真正调用(schwartz)
函数时需要提供一个用作键控的函数。代码清单10-3中用的是我们的老朋友(ident-fun)
。
代码清单10-3 Schwartzian转换
1:65 user=> (defn schwartz [x key-fn] (map (fn [y] (nth y 0)) ; //第三步 (sort-by (fn [t] (nth t 1)) ; //第二步 (map (fn [z] (let [w z] //第一步 (list w (key-fn w))) ) x))))#/'user/schwartz1:66 user=> (schwartz [2 3 1 5 4] ident-fun)(1 2 3 4 5)1:67 user=> (apply schwartz [[2 3 1 5 4] ident-fun])(1 2 3 4 5)
这段代码分为三步:
- 创建一个包含键值对的列表;
- 基于键控函数的值对键值对排序;
- 仅从排好序的键值对列表中取出原始值,构建新列表(并抛弃键控函数值)。
如图10-4所示。
图10-4 Schwartzian转换
代码清单10-3中引入了一个新形式:(sort-by)
。这个函数有两个参数:一个是用来排序的函数,一个是要排序的向量。还有(apply)
形式,它也有两个参数:一个是要调用的函数,一个是传给它的向量参数。
Randall Schwartz最初用Perl编写Schwartzian转换(该转换以他的名字命名)时在刻意模仿Lisp。我们现在又用Clojure编写,算是绕了一圈又回来了。挺有意思!
Schwartzian转换的示例很实用,我们稍后还会用到它。因为它的复杂性足以用来阐明好几个概念。
接下来我们来讨论下Clojure的循环,可能和你所习惯的循环有点不太一样。
10.3.2 Clojure中的循环
Java里的循环相当简单直接,可选的循环有for
、while
,还有其他几种。其核心思想通常是重复一组指令,直到满足某一条件(一般用一个可变变量表示)。
这对Clojure是个小难题:举个例子,对于没有可变变量作为循环索引的Clojure,怎么表示for
循环呢?在传统的Lisp中通常用递归形式实现循环。但JVM不能保证尾递归优化(Scheme和其他Lisp语言有这种要求),所以在Clojure中用递归可能会导致栈溢出。
而Clojure有不会增加栈空间占用的结构。最常用的是loop-recur
,下面的代码展示了如何用loop-recur
构建一个和for
循环类似的结构。
(defn like-for [counter](loop [ctr counter] (println ctr) (if (< ctr 10) (recur (inc ctr)) ctr)))
(loop)
形式以包含符号局部名称的向量为参数——像(let)
定义的别名。然后当执行到(recur)
形式时(本例中只有ctr
别名小于10才会执行该形式),它会将控制分支返回到(loop)
形式中,但指定了新的值。这样我们就可以搭建循环式结构(比如for
和while
循环),但实现中仍有递归的味道。
现在我们转入下一主题,看一看Clojure语法的简写,帮你把程序写得更短、更精炼。
10.3.3 读取器宏和派发器
Clojure有些让很多Java程序员吃惊的语法特性。其中之一是没有操作符。它的副作用是放宽了Java对能用在名称中的字符的限制。你已经见过像(identical?)
这样的函数了,这在Java中是非法的,但对于哪些字符不能用在符号中,我们还没有说明。
表10-2列出了不能用在Clojure符号中的字符。Clojure分析器保留了这些字符自用,它们通常被称为读取器宏。
表10-2 读取器宏
/'
引号展开为(quote)
,产出不进行计算的形式;
注释标记直到行尾的注释,就像Java里的//
字符产生一个字面字符@
解引用展开为(deref)
,接受var
对象并返回对象中的值(跟(var)
形式的操作相反)。在事务内存上下文中还有其他含义(见10.6节)^
元数据将一个元数据的映射附加到对象上。请查阅Clojure文档了解详情`
语法引用经常用在宏定义中的引号形式,不太适合初学者。请查阅Clojure文档了解详情#
派发有几种不同的子形式,见表10-3根据#
后面的字符,派发读取器宏有几种不同的子形式,请见表10-3。
表10-3 派发读取器宏的子形式
#/'
展开为(var)
#{}
创建一个集字面值,在10.2.2节中用过#
创建匿名函数字面值,用在那些使用(fn)
太啰嗦的地方#_
跳过下一个形式。可以用#_( ... 多行 ...)
来创建多行注释#
/"<模式>/"创建一个正则表达式(作为java.util.regex.Pattern
对象)关于派发形式,还有几点要提一下。变量引用形式#/'
解释了REPL执行(def)
之后的表现:
1:49 user=> (def someSymbol)#/'user/someSymbol
(def)
形式返回新创建的var
对象,命名为someSymbol
,驻留在当前的命名空间中(就是用户所在的REPL),所以#/'user/someSymbol
是(def)
返回的完整值。
匿名函数字面值也是减少繁琐代码的创新。它省略了参数向量,用一种特殊的语法让Clojure读取器推断函数字面值需要多少个参数。
代码清单10-4是我们用这个语法重写的Schwartzian转换。
代码清单10-4 重写Schwartzian转换
(defn schwartz [x f] (map #(nth %1 0) ; ﹃匿名函数字面值 (sort-by #(nth %1 1) (map #(let [w %1] ﹄匿名函数字面值 (list w (f w)) ) x)))
用%1
当做函数字面值参数的占位符(后续参数可以用%2、%3等)真的很好,这样的代码也更容易看懂。这种显而易见的线索对程序员很有帮助,就像你在9.3.6节见过的Scala函数字面值里的箭头符号一样。
Clojure严重依赖于以函数为基本计算单元的概念,而不像Java以对象为语言的根本。这种方式自然会导向函数式编程,也就是我们的下一主题。
10.3.4 函数式编程和闭包
我们现在要进入恐怖的Clojure函数式编程世界。或者,我们没有,因为它不恐怖。实际上,我们这一整章都在学习函数式编程,只是没告诉你,怕把你吓跑。
7.3.2节中说过,函数式编程意味着函数是一个值。函数可以传递,放在变量中操作,就像2
或/"hello/"
一样。但那又怎么样?我们回头看看第一个例子:(def hello (fn /"Hello world/"))
。我们创建了一个函数(没有参数,返回字符串/"Hello world/"
),把它绑定到符号hello
上。函数仅仅是个值,本质上跟2
这种值没什么区别。
在10.3.1节,我们以Schwartzian转换为例介绍了以另外一个函数为输入值的函数。这也不过是一个以特定类型为输入参数的函数,唯一的区别不过是这个类型是函数。
关于闭包呢?它们真的很恐怖,是不是?哦,还好吧。我们来看一个简单的例子,这应该能让你想起我们做过的一些Scala例子:
1:5 user=> (defn adder [constToAdd] #(+ constToAdd %1))#/'user/adder1:6 user=> (def plus2 (adder 2))#/'user/plus21:7 user=> (plus2 3)51:8 user=> 1:9 user=> (plus2 5)7
上例中先定义了(adder)
函数。这是一个构造其他函数的函数。如果你熟悉Java语言的工厂方法模式,可以把它当成Clojure的工厂方法实现。以其他函数为函数的返回值没什么好奇怪的,这是将函数作为普通值这一概念的重要体现。
这个例子给匿名函数用了缩写的#
形式。函数(adder)
接受一个数值参数并返回一个函数,并且返回的是带一个参数的函数。
然后用(adder)
定义了一个新形式:(plus2)
。这个函数接受一个参数,并在这个参数上加2。这就是说绑定到(adder)
内部的constToAdd
的值是2。现在我们来构造一个新函数:
1:13 user=> (def plus3 (adder 3))#/'user/plus31:14 user=> (plus3 4)71:15 user=> (plus2 4)6
这段代码表明你还可以再构造其他函数(plus3)
,绑定不同的值到constToAdd
上。我们说函数(plus3)
和(plus2)
已经从它们所在的环境中捕获或“封装”了一个值1。需要注意的是(plus3)
和(plus2)
捕获的值是不同的,并且定义(plus3)
对(plus2)
捕获的值没有影响。
1 此处的环境即指函数(adder)
,而捕获的值即绑定到constToAdd
的值。——译者注
在自身环境内“封装”一些值的函数称为闭包,(plus2)
和(plus3)
就是闭包。在支持闭包的语言中,用一个制造者函数构造并返回另一个封装了一些东西的函数非常普遍。
接下来我们要讨论Clojure中一个强大的特性:序列。它们使用了跟Java的集合或迭代器类似的东西,但有些不同的属性。在代码中使用序列最能体现Clojure语言的力量,对于习惯了Java处理方式的程序员,Clojure的处理方式会让你耳目一新。