- Clojure程序设计
- (美)Stuart Halloway Aaron Bedra
- 1035字
- 2020-06-26 14:08:18
1.1 为什么是Clojure
所有Clojure的特色功能,要么简单,要么强大,或两者兼而有之。下面举几个例子。
● 函数式编程很简单,原因是它将计算的过程与状态及标识隔离开来。优点:函数式程序更容易理解、编写、测试、调优和并行化。
● Clojure与Java的互操作极为强大,允许你直接访问Java平台的语义。优点:你能拥有与Java等同的性能和语义。最重要的是,你不必为了获得这点额外的能力而“下降”到一门低级别的语言。
● Lisp的简单在于两个关键方面:它将代码的读取与求值分开了,并且语法仅由少数几个正交的部分构成。优点:能用语法抽象来捕获设计模式;此外,当需要的时候,S表达式(S-expressions)能成为XML、JSON或是SQL。
● Lisp也很强大,它在运行期提供了一个编译器和宏(macro)系统。优点:Lisp具有晚绑定的决策能力,并且很容易定制领域特定语言(DSL,Domain Specific Language)。
● Clojure 的时间模型很简单,将值、标识、状态和时间相互分离。优点:程序可以放心地感知并记住信息,完全不必担心在这段时间里,有人正打算对其乱涂乱画一番。
● 协议(Protocols)很简单,将多态性(polymorphism)和派生(derivation)分离。优点:不必纠结于设计模式,或是依赖于脆弱的猴子补丁(monkey patching),你就能得到既安全又极富扩展性的类型与抽象。
这个功能列表可以作为本书剩余部分的路线图,所以,即便此刻你尚无法充分理解每个小细节,也不必太过忧虑。上面的每个特性,都分别用了整整一章来加以详述。
让我们构建一个小型的应用,看看其中一些特性是如何运作的。沿途你将学会如何加载并执行那些较大的示例,本书的后半部分会用到它们。
1.1.1 Clojure非常优雅
Clojure高信号,低噪音。因此,Clojure程序都非常简短。短小的程序,无论是构建、部署,还是维护,都要便宜得多。尤其当程序是简明的(concise)而不仅仅是简短(terse)的时候就更是如此了。举个例子,考虑下面这段来自于 Apache Commons的Java代码。
data/snippets/isBlank.java public class StringUtils { public static boolean isBlank(String str) { int strLen; if (str == null || (strLen = str.length()) == 0) { return true; } for (int i = 0; i < strLen; i++) { if ((Character.isWhitespace(str.charAt(i)) == false)) { return false; } } return true; } }
isBlank()方法用于检查目标字符串是否是空白的:没有任何字符,或者只包含空格符。这里是Clojure的类似实现。
src/examples/introduction.clj (defn blank? [str] (every? #(Character/isWhitespace %) str))
Clojure版本要短得多。但更重要的是,它更加简单:没有变量,没有可变状态,也没有分支结构。这可能要归功于高阶函数(higherorder functions)。高阶函数本身也是一个函数,它接受其他函数作为参数;也可以把函数作为返回值。every?函数接受一个函数f和一个容器(collection) c作为它的参数,对于容器c中的每个元素,如果函数f都返回真的话,every?函数也就返回真。
由于Clojure的这个版本没有分支结构,所以无论是阅读还是测试都更容易。在大一些的程序中,这种优势还将会进一步扩大。而且,简洁的代码也更具可读性。事实上,Clojure的这段程序读起来就像是一份关于何为空白的定义:如果一个字符串中的每个字符都是空格,那么这个字符串就是空白的。这要比一般的方法好太多了,在那些方法中,对空白的定义被隐藏在了由循环和分支语句组成的实现细节背后。
另外一个例子,考虑用Java定义一个微不足道的Person类。
data/snippets/Person.java public class Person { private String firstName; private String lastName; public Person(String firstName, String lastName) { this.firstName = firstName; this.lastName = lastName; } public String getFirstName() { return firstName; } public void setFirstName(String firstName) { this.firstName = firstName; } public String getLastName() { return lastName; } public void setLastName(String lastName) { this.lastName = lastName; } }
在Clojure中,用一行代码就可以定义这个Person。
(defrecord Person [first-name last-name])
然后像下面这样使用:
(def foo (->Person "Aaron" "Bedra")) -> #’user/foo foo -> #:user.Person{:first-name "Aaron", :last-name "Bedra"}
在第6.3节“协议”中,包含了defrecord及其相关函数的介绍。
除代码短了一个数量级以外,Clojure 采用的方法还有一处不同:Clojure 版本的 Person 是不可变的。不可变数据结构生来就是线程安全的,Clojure 中可以通过使用引用、代理和原子来更新数据,这些内容将在第5章“状态”中详加讨论。正因为记录(record)是不可变的,Clojure也就自动提供了正确的hashCode()和equals()实现。
Clojure内建了大量优雅的特性,但倘若你发现还是遗漏了某样东西的话,你可以自己添上,这完全要归功于Lisp的强大。
1.1.2 Clojure是Lisp的再度崛起
Clojure是一种Lisp方言。数十年来,拥护者们指出了Lisp与其他语言相比的诸多优点。但同时,Lisp一统天下的计划看起来却遥遥无期。
如同其他所有的Lisp一样,Clojure也面临着两个挑战。
● Clojure必须成功地说服Lisp程序员,作为一种Lisp方言,Clojure包含了Lisp的关键部分。
● 同时,Clojure 还需要成功地赢得广泛的程序员社区支持,而这正是过去那些Lisp的失败之处。
为了应对这些挑战,Clojure提供了Lisp元编程能力,与此同时还包含了一整套增强的语法,使得Clojure对于非Lisp程序员而言显得更为友好。
1.1.3 为什么是Lisp
Lisp的语言核心非常小,几乎没有什么语法,但却提供了一个强大的宏设施。借助这些特性,你可以根据你的设计需要对Lisp随意地直接定制。这样就不必使用其他那些绕来绕去的方式了。考虑以下Java代码片段。
public class Person { private String firstName; public String getFirstName() { // 以下省略…
在这段代码中,getFirstName()是一个方法(method)。方法具有多态性,可以根据你的需要加以调整。但对于Java而言,示例中其他单词的语义,其解释都是固定的。然而,有时你确实需要改变这些词语的含义。举例来说,你可能会像下面这么做。
● 重新定义private:对于产品代码保持私有,但允许来自序列化(serialization)和单元测试代码的访问。
● 重新定义class:自动为每个私有字段都生成getters和setters,除非另有指示。
● 创建class的一个子类,提供面向生命周期事件的回调钩子。例如,对于“可感知生命周期”的类而言,只要创建了这个类的一个实例,就会激发相应的事件。
我们一定见过需要上述特性的程序。由于缺乏这些特性,程序员们不得不去借助一些重复性的、容易出错的变通方法。结果是,人们在这上面白白浪费了数百万行的代码,而罪魁祸首就是编程语言中类似特性的缺失。
对大多数编程语言而言,你只能祈求语言的实现者们尽快增加上面提到的这类特性。但在Clojure中,你能凭借宏来自行添加属于你自己的语言特性(第7章“宏”)。事实上,Clojure本身就是用宏来进行扩建的,比如defrecord。
(defrecord name [arg1 arg2 arg3])
如果你需要的语义与此不同,写一个你自己的宏就行。比如你想得到记录的一个变种,它具备强类型并具有可选的空字段校验能力,你可以创建自己的defrecord宏。这个新的defrecord用法如下。
(defrecord name [Type :arg1 Type :arg2 Type :arg3] :allow-nulls false)
这种对语言进行再编程,从而改变语言自身的能力,是Lisp的独门优势。下面用不同的方式来描述这一思想。
● Lisp具有同像性(homoiconic)。也就是说,Lisp代码其实就是Lisp数据。这样就很容易让程序自己去编写其他的程序。
● 这就是语言的全部,且始终如此。保罗·格雷厄姆在其散文《书呆子的复仇》中,解释了为什么这会如此的强大。
Lisp语法也废除了运算符优先级和结合性的规则。翻遍本书的任何一个角落,你都不会看到用来说明运算符优先级或结合性的表格。凭借完全的括号表示法,就能避免产生任何这方面的歧义。
简单、整齐的Lisp语法也存在负面因素,至少对于初学者而言,成堆的括号,以及将列表作为核心数据类型都会成为一种障碍。为此,Clojure提供了有趣的功能组合,对于非Lisp程序员而言,这个Lisp显得要亲切得多。
1.1.4 它是Lisp,但括号少了
对于来自其他Lisp方言的程序员来说,Clojure的优势显而易见。
● Clojure泛化了Lisp的物理列表,将其抽象为序列(sequence)。这样既保留了列表的强大能力,同时还将这种能力扩展到了其他各种类型的数据结构。
● 依托于Java虚拟机,Clojure提供了一个范围广泛的标准库及部署平台。
● Clojure 提供的符号解析和语法引述(syntax quoting)方式,使得编写许多普通宏的时候更加容易了。
许多Clojure程序员可能会是Lisp的新手,他们也许听说过诸多关于Lisp括号的可怕传言。是的,Clojure保留了括号表示法(当然也保留了Lisp的强大!),但在以下方面对传统Lisp语法进行了改进。
● 在Clojure中,除列表之外,还提供了更为便利的正则表达式、映射表、集合,向量和元数据等多种数据结构的字面表示语法。这些特性使得Clojure代码相比其他多数Lisp语言而言,过度列表化(listy)的症状要轻很多。例如,Clojure函数的参数是在一个向量([])中指定的,而不是使用列表(())。
src/examples/introduction.clj (defn hello-world [username] (println (format "Hello, %s" username)))
向量令参数列表变得非常醒目,也使得Clojure的函数定义更易于阅读。
● 与大多数Lisp语言不同,在Clojure中,逗号就是空格。
; 这让向量看起来就像是其他语言中的数组一样。 [1, 2, 3, 4] -> [1 2 3 4]
● 地道的Clojure不会内联不必要括号。考虑一下在Common Lisp和Clojure中都有的cond宏。cond对一组成对的“测试/结果”逐个求值,当遇到第一个求值结果为真的测试时,返回其对应的结果。Common Lisp 中,每一对“测试/结果”都得像下面这样,用括号进行分组。
; Common Lisp cond
(cond ((= x 10) "equal")
((> x 10) "more"))
而在Clojure中则避免了额外的括号。
; Clojure cond
(cond (= x 10) "equal"
(> x 10) "more")
这是一种审美决定,且双方都各有其支持者。但重点在于,Clojure获得了在不减损Lisp威力的前提下,尽可能减少过度列表化的机会。
Clojure是一种卓越的Lisp方言,无论对于Lisp专家,还是Lisp新手,皆是如此。
1.1.5 Clojure是函数式语言
Clojure虽然是一种函数式语言,但不像Haskell那样纯粹。函数式编程语言具有下列属性。
● 函数是一等公民。换言之,函数能在运行期间被创建,被当做参数传递,被用作返回值,并且能像其他数据类型那样,被用于各种用途。
● 数据是不可变的。
● 函数是纯粹的,也就是说,它们不会造成任何副作用。
对许多任务而言,函数式程序更容易理解,不容易出错,且更利于重用。例如,下面这个小程序从乐曲数据库中,查询有哪些作曲家创作了《Requiem(安魂曲)》。
(for [c compositions :when (= "Requiem" (:name c))] (:composer c)) -> ("W. A. Mozart" "Giuseppe Verdi")
这里的 for,并不意味着引入了循环,而是进行了一次列表解析(list comprehension)。所以,这段代码应该这么读:“对于乐曲库中的每支乐曲 c,当 c 的名称是《Requiem》时,则获取c的作曲家信息”。本书第3.2.4小节“序列转换”中有关于列表解析的完整讨论。
这个例子的可取之处有以下4方面:
● 非常简单,没有任何循环结构、变量或是可变的状态;
● 线程安全,不需要锁机制即可得到保证;
● 可并行化,无需修改代码,你就可以将单独的步骤转移至多个线程;
● 非常通用,乐曲库可以是一个普通集合、XML或是一个数据库结果集。
这里,函数式程序与命令式程序形成鲜明对比,在命令式程序中,是用显式的语句来改变程序状态的。大多数面向对象程序都是采用命令式风格写就的,在前面列出的这几方面,它们劣势尽显(关于函数式和命令式风格的逐项对比,请阅读2.7节)。
如今人们已经知道了函数式语言的优势。然而,像Haskell那样的纯函数式语言却没能接管世界,这是因为开发者们发现,纯粹的函数式观点无法轻易地解决所有问题。
与过去的那些函数式语言相比,有4个原因使得Clojure能够吸引更多的注意。
● 对函数式编程的需要,比以往任何时候都显得更加迫切。规模庞大的多核硬件已指日可待,函数式语言提供了一种清晰的方式对其加以利用。本书第4章“函数式编程”详细讨论了这个话题。
● 当确实需要对状态进行修改时,纯粹的函数式编程语言就显得颇为尴尬了。Clojure则通过软事务内存(STM,software transactional memory)及引用、代理、原子和动态绑定,提供了结构良好的机制用于处理可变状态。
● 许多函数式语言都是基于静态类型的。而Clojure的动态类型系统,使得程序员学习函数式编程更加容易。
● Clojure的Java调用方式是非函数式的。当你调用Java程序时,你会进入那个熟悉的,可变的世界。这为函数式编程的初学者提供了一个舒适的港湾,此外当你需要时,这也是能够提供函数式风格替代品的务实之选。第9章“极尽Java之所能”详细讨论了关于Java调用方面的内容。
Clojure 中不必显式锁定,就允许并发地更改状态。这种方式是 Clojure 函数式核心的有力补充。
1.1.6 Clojure简化了并发编程
Clojure支持函数式编程,使得编写线程安全的代码非常容易。由于不可变数据结构在任何时候都不会被修改,因此避免了数据会被另外一个线程破坏的危险。
然而,仅仅是函数式编程,还不足以体现Clojure对并发程序支持之卓越。当你需要引用可变数据时,Clojure会通过软事务内存对其加以保护。在线程安全方面,相比Java提供的锁定机制,软事务内存是一种更高级的方法。你可以借助事务来保护共享状态,而不是去琢磨那些既脆弱,又易于出错的锁定策略。源于数据库方面的经验,很多程序员对何为事务早就了然于胸,所以这也是一种更富成效的做法。
例如,下面的代码创建了一个线程安全的内存数据库用于存放账号。
(def accounts (ref #{})) (defrecord Account [id balance])
ref函数创建了一个引用,代表数据库的当前状态,这个引用会得到事务的保护。更新操作实在是微不足道。下列代码向数据库中添加一个新的账号。
(dosync (alter accounts conj (->Account "CLJ" 1000.00)))
dosync 开启了一个事务,允许对accounts 进行更新。这样既确保了线程安全,同时也比锁机制更容易使用。得益于事务,你不必再操心应该锁定哪些对象,以及应该以什么顺序来锁定等等问题。在一些常见的使用场景中,因为读取操作不会被阻塞,所以事务机制能够非常高效地运转。
虽然这是个微不足道的例子,但其展现的技术是通用的,完全可用于解决现实世界中的问题。请参阅第5章“状态”,那里有更多关于Clojure中并发及软事务内存方面的讨论。
1.1.7 Clojure与Java虚拟机彼此亲密无间
从Clojure访问Java,清晰、简单、直接。你能直接调用任何JavaAPI。
(System/getProperties) -> {java.runtime.name=Java(TM) SE Runtime Environment ... many more ...
Clojure为调用Java提供了很多语法糖。我们不需要在这里深入过多细节(参阅第2.5节“调用Java”),但请注意,下面的代码中,Clojure的那个版本无论是点号(.),还是括号(()),数量都比Java版本要少。
// Java "hello".getClass().getProtectionDomain() ; Clojure (.. "hello" getClass getProtectionDomain)
Clojure提供了简单的函数用于实现Java接口,以及从Java基类派生。此外,Clojure的所有函数都实现了Callable和Runnable接口。这使得采用下面所示的匿名函数来构建Java线程竟然如此轻松。
(.start (new Thread (fn [] (println "Hello" (Thread/currentThread))))) -> Hello #<Thread Thread[Thread-0,5,main]>
这里有个有趣之处,就是Clojure打印Java对象实例的方式。Thread是这个实例的类名,然后Thread[Thread-0,5,main]是这个实例的toString方法返回值。
注意,前例中的这个新线程会持续运行直至完成,但其输出可能会以某种奇怪的方式,同REPL的提示符产生交错现象。但这并非Clojure的问题,只不过是有多个线程同时向输出流进行写入数据的结果罢了。
由于在Clojure中调用Java程序的语法干净而且简单,作为Clojure的惯例,会更加倾向于直接对Java进行调用,而不是把Java隐藏到一层Lisp化的封装背后。
好了,现在你已经看到一些为什么要使用Clojure了,是时候开始编写一些代码了。