Clojure编程语言 扩展你的Java想象力

huangyufeng0 2009-08-27

Clojure编程语言是Lisp的一门方言,最近发布了1.0版。开发者常错误的认为Lisp是一门不切实际的语言,这可能是因为它特别的语法,“苦行僧”式的简单,或经常用于教学研究的缘故,Clojure将会打破这种偏见。Rich Hickey设计这门语言使它简单而实用,相比Java而言,它处理同类问题会更加健壮,代码量更少。

任何一门新的语言,无论它多好,在大规模使用前都得有自己的“杀手锏”。Clojure的“杀手锏”在于对多核CPU的并行编程方面,并行编程是现在提高处理器能力的主要方法。它不变的数据类型(immutable datatypes),无锁的同步性(lockless concurrency)以及简单的抽象性,相比Java而言,Clojure在多线程编程方面更加简单、更加健壮。

下面将讲一下Clojure编程语言的出色的特性,并从中学习让你的Java代码更加优雅、bug更少的思想。我希望你读完后,会想学更多。

代码即数据

先看一下Listing 1 简单的函数,计算圆的面积。

Listing 1. A simple Clojure function

(defn  


   circle-area [r]   


    (* Math/PI r r)) 

Clojure代码与Java代码看上去非常不同,原因很简单,在Clojure中,代码就是数据,代码与Lists和Vectors以及其他数据结构一样以同样方式构建。无论对于程序员还是程序而言,语法一致性都使代码更易理解更易操作。

因此Listing 1里面的函数定义无非就是一个(用小括号括起来的)list,这个list拥有一个(中括号括起来的)vector和另一个list。第一行以list语法定义了该函数。函数名后的vector里定义了参数,最后一行,同样是list语法的调用方式调用了乘法计算,后面3个是操作数。

Clojure在语法上的极低限制使得代码非常易读,即使对于编程经验不多的人也是如此。主流的开发环境对Clojure编程语言都有支持――包括NetBeans,IntelliJ,Eclipse以及vi和Emacs,这使阅读代码更容易。Figure 1 是一个VimClojure的例子,匹配的括号是以不同颜色表示的。(这个函数将小写字母从一个字符串中取出来,如 (get-lower "AbCd") 结果是 "bd")

Figure 1. Clojure support in Vim (Click to enlarge.)

事实上,由于语法的简洁性,一个Clojure程序往往比相同功能的Java程序更加简单,比如下面的Java写的getLower()函数,它是Clojure程序括号量的2倍,代码量的4倍。

Listing 2. A Java function -- more complicated than the Clojure equivalent

public static String getLower(String s) {   



  StringBuffer sb = new StringBuffer();   




  for (int i = 0; i > s.length(); i++) {   




  char ch = s.charAt(i);   




  if (Character.isLowerCase(ch)) {   



       sb.append(ch);   


  }   


  }    



   return sb.toString();   



} 

Java和其他语言一样,代码在编译过程中会被转换成一颗抽象语法树。通过Java“自省”(reflection)可以访问这个结构中的类、字段和方法,但只能“只读”的访问,没法访问方法的实现过程。相对地,Clojure的宏(macros)能更为自由地操作这棵语法树,让你实现普通代码实现不了的功能。通过宏,你可以变换while条件,包装(wrapping)和延迟计算(deferring evaluation)。

下面是一个周所周知又令人痛苦的例子。在Java中,为了处理reader或stream中的数据,你不得不在铁箍般的代码块中跳转来跳转去,看下面的Listing 3.

Listing 3. Simple functionality, complicated in Java

BufferedReader rdr = null;   



try {   




    rdr = new BufferedReader(new FileReader(fileName));   




    //core processing logic goes here   




} finally {   




    if (rdr != null) {   




       try {   



          rdr.close();   



       } catch (IOException e) { }   



    }   


} 

Listing 3 的大多代码是样板化的,封装的代码根据情况的不同而不同,即数据处理部分的不同而不同。就像这个例子,Java常常无法提供可供重用的代码(译注:这里是指由于Reader或Stream等数据处理方式的不同,Java只能提供代码样板,而不能提供重用的代码)。软件工程的本质是知道什么是能改变的,什么不是。利用Clojure的宏能灵活的创建可重用的代码结构,如例子Listing 4 (摘自核心库代码)。

Listing 4. A Clojure macro

(defmacro with-open [bindings & body]   


  `(let bindings   



    (try   




      @body   




    (finally   



      (.close (first bindings))))))   


 



(with-open [rdr (java.io.BufferedReader.(java.io.FileReader. "a.txt"))]  



  (println (.readLine rdr))) 

宏接受一个vector,里面包含一个reader/stream的bindings(只是一个记号)。第二行,那个记号绑定到reader上。vector里还包含body,即被包裹在try语句中的代码。最后2行,宏被执行,调用println作为“数据处理”逻辑。

在语法解析之后、程序执行之前,宏重新整理它的代码,body及其他功能的执行只有在宏被调用后才发生。和Clojure的动态输入、未检查的异常一起,宏不仅令Clojure代码重用性增加,而且更加易读。

Clojure的宏和C的宏有一些相似,都在执行处理前重新布置代码。与C在执行前将代码看作文本不同的是,Clojure使用语言本身的表达特性将代码看作数据结构。

Listing 4最后2行代码展示了Clojure与Java交互操作非常容易:"java.io.BufferedReader."――后面的点――是对构造函数的调用,.readLine是对方法的调用。在Listing 1里面,Math/PI 访问的是静态字段。Java可以容易的调用Clojure代码,Clojure也能继承Java类;反之亦然。

纯粹函数式语言的并发性

尽管Java内置了对多线程的支持,但Java对并发性处理依旧困难。如果在应该加锁的地方没有加锁,数据就会损坏;在不需要加锁的地方加锁,死锁就会出现,或线程停掉。事实上,大多程序员是写单线程应用程序,或让应用服务器管理线程。一旦单线程应用程序需要将问题分解成同步处理的情况,就只能写多线程代码了。

反模式里的死锁

多核电路使这种需求更加迫切。在单核CPU下,多线程常常用来允许某个任务执行,同时阻塞其他I/O任务。今天的CPU,真正的并发性通过多核在各自高负荷状态运行而实现,而Clojure的纯粹函数式编程以及多线程结构让线程安全的代码更加容易实现。

默认的Clojure功能是纯粹函数功能,它接收参数,返回结果,不改变任何可见状态。不同的状态则需要一个新对象。比如,我们先定义一个map(大括号包裹部分),然后用assoc为map增加一个键:

(let [m {:roses "red", :violets "blue"}] 
(assoc m :sugar "sweet"))

结果是一个新的map: {:sugar "sweet", :violets "blue", :roses "red"},而原始map保持不变。

看上去,每次变化都产生拷贝很没有效率,但事实上这时它的一个很好的特性:对象不变性。比如上面的2个map,它们既能共享底层的部分结构,对其中一个改变又不会对另外一个产生不必要的风险。

对程序员而言纯粹的函数很容易理解。由于没有副作用,所考虑的只有函数参数与返回值,大大简化了调试和测试。
纯粹的函数对Clojure自身而言也容易理解,优势也更容易发挥。纯粹的函数调用可以并行执行,而不必考虑执行顺序;它们可以在独立的cpu上执行,不用考虑彼此之间关系。在一个交易失败后,也能安全地被重新执行,并且结果可以推迟到只有在需要的时候才去计算。它们也能记住计算结果――存在缓存中以备后续调用。

它确实可以做到。Clojure能让你不费多大力气就安全地做到这一切。

在Java中使用不变的、无副作用的函数能让你更容易优化以及避免bug。可能的话,声明class及其字段为final的,在构造函数里做初始化。你也可以通过封装为变化的对象增加安全性,像Collections.unmodifiableCollection().
String是Java里面众所周知的不变的对象,由于它们的不变性,JVM可以内联它们并缓存它们的哈希码来减少创建新对象的时间。这样的优化在Java中很少见,但在Clojure很普遍。

线程安全状态

并非任何东西都是不变的。本质上,任何对磁盘、网络或用户界面的输入输出都是可变的。多线程介入后,对于上述可变状态的管理变得更加困难,而Clojure提供了特殊结构来安全地处理这些情况。

Java里,典型的线程安全的数据结构是用synchronized实现的。它阻塞了一些线程,使执行变地缓慢,并有导致死锁的危险。

Clojure的Ref使用创新的并发模型――即软件事务化存储(software transactional memory)――来实现无锁的多线程。就像乐观锁数据库的事务一样,多线程可以并发的、无阻塞的对同一变量执行更新,如果同步写入过程出现冲突,其中一个线程会回滚并重试。

Listing 5 定义了一个封装set的Ref(以 #{}标记),用它管理bookshelf上的图书,任何线程都可以安全的上架或下架某一本书,通过使Ref关联到新的set,并调用增加(conj) 或移除 (disj)实现。所有的对引用值的改变都是通过dosync交易来完成的(dosync与Java 的synchronized关键字没什么关系)。

Listing 5. Defining a Ref

(def bookshelf (ref #{}))   


(defn shelve[book]  


  (dosync (alter bookshelf conj book)))   


(defn unshelve [book]   


   (dosync (alter bookshelf disj book))) 

你可以使用 @bookshelf来提取值,而不用事务(transaction).

这是个简单的、线程安全的、存在内存中的交易数据库,锁机制的复杂性被隐藏,线程之间不必互相等待,各个线程看到的是相同的数据。

Clojure Agent通过线程池中的独立线程同步执行函数,当执行完成时,你可以提取到执行结果。如下面例子,这段代码会维护“log”――一个字符串序列:

(def log (agent []))
(send log conj "2009-03-28 10:34 Shelved Hamlet")

代码首先创建一个agent,封装了一个空的vector,然后通过发送conj函数到agent来添加记录。conj执行很快,但如果我们为agent发送一个需长时间运行的函数,那么让agent更新而不是阻塞在线程调用里面就很有价值了。

相同的并发设计思想在Java里面一样有用。为了将可变性的维护成本降到最低,我们应非常谨慎地使用多个线程共享的可变状态。可能的话,尽量不要使用底层的同步机制,像synchronized 和wait(),而要尽量使用高层的抽象机制,比如Java.util.concurrent包的内容(如果需要的话,Java里面的多线程概念在Clojure里同样可以使用)。

Clojure的Var提供了变量在线程内重新绑定的方式。它和全局变量的作用类似,“长距离”的传递数据。这是一种安全的方式,因为变量值只是在单个线程里可见,并且只是在运行时调用绑定的动态范围内可见。

Java里的thread-local变量与之类似:“长距离”传递状态,跳过堆栈调用,因此避免了交叉线程对静态字段的访问风险。与Var绑定不同的是,它并不限制在单个线程中使用,也没有严格定义的动态范围。

举例来说,Webjure Web框架通过对相关的HTTP对象*request* 和*response*的绑定来处理HTTP请求。所有的请求处理代码都能访问这些对象,没有必要将它们作为参数传到堆栈中再交给每个函数。其他线程看不到这些值,每个Http请求接收自己的对象。即便在线程内部,新的值也只在绑定范围内可见――下面是对单个请求的处理 Listing 6.

Listing 6. Var bindings in Webjure

(binding [*request* request *response* response]   


  (binding [*matched-handler* (find-handler (request-path *request*))]   


    ((*matched-handler* :handler)))))) ; This invokes the request-handle 

类型提示

Clojure在运行时编译,能产生和Java一样快的字节码。然而,在编译器得不到参数的类型时,更慢的“自省”方式的调用就是必需的了,这在所有动态类型语言都会出现。

下面的代码中,我们设置Clojure在不得不使用“自省”时发出警告,然后定义函数:

(set! *warn-on-reflection* true) 
(defn year [cal]
(.get cal java.util.Calendar/YEAR))
Reflection warning, line: 3 - call to get can't be resolved.

然而,我们可以通知编译器使用 #^Calendar,“元数据”(metadata,与对象的主要目的不同的额外信息)使编译器避免“自省”调用,而是实时(just-in-time)地创建快速的字节码:

(defn year [#^java.util.Calendar cal]
(.get cal java.util.Calendar/YEAR))

相关推荐