Clojure編程語言 擴展你的Java想象力
51CTO.com之前曾介紹過Scala編程語言,它是一種針對JVM 將函數(shù)和面向?qū)ο蠹夹g(shù)組合在一起的編程語言;Scala源于Java,卻超越Java。與Scala相同,Clojure也是JVM上的一門新的語言,就像Groovy,Jyphon和JRuby一樣,它能動態(tài)的、簡潔的、無縫的與Java進行交互操作。
關(guān)于Clojure與Scala的區(qū)別請參考51CTO.com之前的文章《Scala和Clojure,以及優(yōu)秀的企業(yè)級語言之道》
Clojure編程語言是Lisp的一門方言,最近發(fā)布了1.0版。開發(fā)者常錯誤的認(rèn)為Lisp是一門不切實際的語言,這可能是因為它特別的語法,“苦行僧”式的簡單,或經(jīng)常用于教學(xué)研究的緣故,Clojure將會打破這種偏見。Rich Hickey設(shè)計這門語言使它簡單而實用,相比Java而言,它處理同類問題會更加健壯,代碼量更少。
任何一門新的語言,無論它多好,在大規(guī)模使用前都得有自己的“殺手锏”。Clojure的“殺手锏”在于對多核CPU的并行編程方面,并行編程是現(xiàn)在提高處理器能力的主要方法。它不變的數(shù)據(jù)類型(immutable datatypes),無鎖的同步性(lockless concurrency)以及簡單的抽象性,相比Java而言,Clojure在多線程編程方面更加簡單、更加健壯。
下面將講一下Clojure編程語言的出色的特性,并從中學(xué)習(xí)讓你的Java代碼更加優(yōu)雅、bug更少的思想。我希望你讀完后,會想學(xué)更多。
代碼即數(shù)據(jù)
先看一下Listing 1 簡單的函數(shù),計算圓的面積。
Listing 1. A simple Clojure function
- (defn
- circle-area [r]
- (* Math/PI r r))
Clojure代碼與Java代碼看上去非常不同,原因很簡單,在Clojure中,代碼就是數(shù)據(jù),代碼與Lists和Vectors以及其他數(shù)據(jù)結(jié)構(gòu)一樣以同樣方式構(gòu)建。無論對于程序員還是程序而言,語法一致性都使代碼更易理解更易操作。
因此Listing 1里面的函數(shù)定義無非就是一個(用小括號括起來的)list,這個list擁有一個(中括號括起來的)vector和另一個list。***行以list語法定義了該函數(shù)。函數(shù)名后的vector里定義了參數(shù),***一行,同樣是list語法的調(diào)用方式調(diào)用了乘法計算,后面3個是操作數(shù)。
Clojure在語法上的極低限制使得代碼非常易讀,即使對于編程經(jīng)驗不多的人也是如此。主流的開發(fā)環(huán)境對Clojure編程語言都有支持——包括NetBeans,IntelliJ,Eclipse以及vi和Emacs,這使閱讀代碼更容易。Figure 1 是一個VimClojure的例子,匹配的括號是以不同顏色表示的。(這個函數(shù)將小寫字母從一個字符串中取出來,如 (get-lower "AbCd") 結(jié)果是 "bd")
Figure 1. Clojure support in Vim (Click to enlarge.)
事實上,由于語法的簡潔性,一個Clojure程序往往比相同功能的Java程序更加簡單,比如下面的Java寫的getLower()函數(shù),它是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和其他語言一樣,代碼在編譯過程中會被轉(zhuǎn)換成一顆抽象語法樹。通過Java“自省”(reflection)可以訪問這個結(jié)構(gòu)中的類、字段和方法,但只能“只讀”的訪問,沒法訪問方法的實現(xiàn)過程。相對地,Clojure的宏(macros)能更為自由地操作這棵語法樹,讓你實現(xiàn)普通代碼實現(xiàn)不了的功能。通過宏,你可以變換while條件,包裝(wrapping)和延遲計算(deferring evaluation)。
下面是一個周所周知又令人痛苦的例子。在Java中,為了處理reader或stream中的數(shù)據(jù),你不得不在鐵箍般的代碼塊中跳轉(zhuǎn)來跳轉(zhuǎn)去,看下面的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 的大多代碼是樣板化的,封裝的代碼根據(jù)情況的不同而不同,即數(shù)據(jù)處理部分的不同而不同。就像這個例子,Java常常無法提供可供重用的代碼(譯注:這里是指由于Reader或Stream等數(shù)據(jù)處理方式的不同,Java只能提供代碼樣板,而不能提供重用的代碼)。軟件工程的本質(zhì)是知道什么是能改變的,什么不是。利用Clojure的宏能靈活的創(chuàng)建可重用的代碼結(jié)構(gòu),如例子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行,宏被執(zhí)行,調(diào)用println作為“數(shù)據(jù)處理”邏輯。
在語法解析之后、程序執(zhí)行之前,宏重新整理它的代碼,body及其他功能的執(zhí)行只有在宏被調(diào)用后才發(fā)生。和Clojure的動態(tài)輸入、未檢查的異常一起,宏不僅令Clojure代碼重用性增加,而且更加易讀。
Clojure的宏和C的宏有一些相似,都在執(zhí)行處理前重新布置代碼。與C在執(zhí)行前將代碼看作文本不同的是,Clojure使用語言本身的表達特性將代碼看作數(shù)據(jù)結(jié)構(gòu)。
Listing 4***2行代碼展示了Clojure與Java交互操作非常容易:"java.io.BufferedReader."——后面的點——是對構(gòu)造函數(shù)的調(diào)用,.readLine是對方法的調(diào)用。在Listing 1里面,Math/PI 訪問的是靜態(tài)字段。Java可以容易的調(diào)用Clojure代碼,Clojure也能繼承Java類;反之亦然。#p#
純粹函數(shù)式語言的并發(fā)性
盡管Java內(nèi)置了對多線程的支持,但Java對并發(fā)性處理依舊困難。如果在應(yīng)該加鎖的地方?jīng)]有加鎖,數(shù)據(jù)就會損壞;在不需要加鎖的地方加鎖,死鎖就會出現(xiàn),或線程停掉。事實上,大多程序員是寫單線程應(yīng)用程序,或讓應(yīng)用服務(wù)器管理線程。一旦單線程應(yīng)用程序需要將問題分解成同步處理的情況,就只能寫多線程代碼了。
反模式里的死鎖
多核電路使這種需求更加迫切。在單核CPU下,多線程常常用來允許某個任務(wù)執(zhí)行,同時阻塞其他I/O任務(wù)。今天的CPU,真正的并發(fā)性通過多核在各自高負(fù)荷狀態(tài)運行而實現(xiàn),而Clojure的純粹函數(shù)式編程以及多線程結(jié)構(gòu)讓線程安全的代碼更加容易實現(xiàn)。
默認(rèn)的Clojure功能是純粹函數(shù)功能,它接收參數(shù),返回結(jié)果,不改變?nèi)魏慰梢姞顟B(tài)。不同的狀態(tài)則需要一個新對象。比如,我們先定義一個map(大括號包裹部分),然后用assoc為map增加一個鍵:
(let [m {:roses "red", :violets "blue"}]
(assoc m :sugar "sweet"))
結(jié)果是一個新的map: {:sugar "sweet", :violets "blue", :roses "red"},而原始map保持不變。
看上去,每次變化都產(chǎn)生拷貝很沒有效率,但事實上這時它的一個很好的特性:對象不變性。比如上面的2個map,它們既能共享底層的部分結(jié)構(gòu),對其中一個改變又不會對另外一個產(chǎn)生不必要的風(fēng)險。
對程序員而言純粹的函數(shù)很容易理解。由于沒有副作用,所考慮的只有函數(shù)參數(shù)與返回值,大大簡化了調(diào)試和測試。
純粹的函數(shù)對Clojure自身而言也容易理解,優(yōu)勢也更容易發(fā)揮。純粹的函數(shù)調(diào)用可以并行執(zhí)行,而不必考慮執(zhí)行順序;它們可以在獨立的cpu上執(zhí)行,不用考慮彼此之間關(guān)系。在一個交易失敗后,也能安全地被重新執(zhí)行,并且結(jié)果可以推遲到只有在需要的時候才去計算。它們也能記住計算結(jié)果——存在緩存中以備后續(xù)調(diào)用。
它確實可以做到。Clojure能讓你不費多大力氣就安全地做到這一切。
在Java中使用不變的、無副作用的函數(shù)能讓你更容易優(yōu)化以及避免bug。可能的話,聲明class及其字段為final的,在構(gòu)造函數(shù)里做初始化。你也可以通過封裝為變化的對象增加安全性,像Collections.unmodifiableCollection().
String是Java里面眾所周知的不變的對象,由于它們的不變性,JVM可以內(nèi)聯(lián)它們并緩存它們的哈希碼來減少創(chuàng)建新對象的時間。這樣的優(yōu)化在Java中很少見,但在Clojure很普遍。
線程安全狀態(tài)
并非任何東西都是不變的。本質(zhì)上,任何對磁盤、網(wǎng)絡(luò)或用戶界面的輸入輸出都是可變的。多線程介入后,對于上述可變狀態(tài)的管理變得更加困難,而Clojure提供了特殊結(jié)構(gòu)來安全地處理這些情況。
Java里,典型的線程安全的數(shù)據(jù)結(jié)構(gòu)是用synchronized實現(xiàn)的。它阻塞了一些線程,使執(zhí)行變地緩慢,并有導(dǎo)致死鎖的危險。
Clojure的Ref使用創(chuàng)新的并發(fā)模型——即軟件事務(wù)化存儲(software transactional memory)——來實現(xiàn)無鎖的多線程。就像樂觀鎖數(shù)據(jù)庫的事務(wù)一樣,多線程可以并發(fā)的、無阻塞的對同一變量執(zhí)行更新,如果同步寫入過程出現(xiàn)沖突,其中一個線程會回滾并重試。
Listing 5 定義了一個封裝set的Ref(以 #{}標(biāo)記),用它管理bookshelf上的圖書,任何線程都可以安全的上架或下架某一本書,通過使Ref關(guān)聯(lián)到新的set,并調(diào)用增加(conj) 或移除 (disj)實現(xiàn)。所有的對引用值的改變都是通過dosync交易來完成的(dosync與Java 的synchronized關(guān)鍵字沒什么關(guān)系)。
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來提取值,而不用事務(wù)(transaction).
這是個簡單的、線程安全的、存在內(nèi)存中的交易數(shù)據(jù)庫,鎖機制的復(fù)雜性被隱藏,線程之間不必互相等待,各個線程看到的是相同的數(shù)據(jù)。#p#
Clojure Agent通過線程池中的獨立線程同步執(zhí)行函數(shù),當(dāng)執(zhí)行完成時,你可以提取到執(zhí)行結(jié)果。如下面例子,這段代碼會維護“l(fā)og”——一個字符串序列:
(def log (agent []))
(send log conj "2009-03-28 10:34 Shelved Hamlet")
代碼首先創(chuàng)建一個agent,封裝了一個空的vector,然后通過發(fā)送conj函數(shù)到agent來添加記錄。conj執(zhí)行很快,但如果我們?yōu)閍gent發(fā)送一個需長時間運行的函數(shù),那么讓agent更新而不是阻塞在線程調(diào)用里面就很有價值了。
相同的并發(fā)設(shè)計思想在Java里面一樣有用。為了將可變性的維護成本降到***,我們應(yīng)非常謹(jǐn)慎地使用多個線程共享的可變狀態(tài)??赡艿脑?,盡量不要使用底層的同步機制,像synchronized 和wait(),而要盡量使用高層的抽象機制,比如Java.util.concurrent包的內(nèi)容(如果需要的話,Java里面的多線程概念在Clojure里同樣可以使用)。
Clojure的Var提供了變量在線程內(nèi)重新綁定的方式。它和全局變量的作用類似,“長距離”的傳遞數(shù)據(jù)。這是一種安全的方式,因為變量值只是在單個線程里可見,并且只是在運行時調(diào)用綁定的動態(tài)范圍內(nèi)可見。
Java里的thread-local變量與之類似:“長距離”傳遞狀態(tài),跳過堆棧調(diào)用,因此避免了交叉線程對靜態(tài)字段的訪問風(fēng)險。與Var綁定不同的是,它并不限制在單個線程中使用,也沒有嚴(yán)格定義的動態(tài)范圍。
舉例來說,Webjure Web框架通過對相關(guān)的HTTP對象*request* 和*response*的綁定來處理HTTP請求。所有的請求處理代碼都能訪問這些對象,沒有必要將它們作為參數(shù)傳到堆棧中再交給每個函數(shù)。其他線程看不到這些值,每個Http請求接收自己的對象。即便在線程內(nèi)部,新的值也只在綁定范圍內(nèi)可見——下面是對單個請求的處理 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在運行時編譯,能產(chǎn)生和Java一樣快的字節(jié)碼。然而,在編譯器得不到參數(shù)的類型時,更慢的“自省”方式的調(diào)用就是必需的了,這在所有動態(tài)類型語言都會出現(xiàn)。
下面的代碼中,我們設(shè)置Clojure在不得不使用“自省”時發(fā)出警告,然后定義函數(shù):
(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,“元數(shù)據(jù)”(metadata,與對象的主要目的不同的額外信息)使編譯器避免“自省”調(diào)用,而是實時(just-in-time)地創(chuàng)建快速的字節(jié)碼:
(defn year [#^java.util.Calendar cal]
(.get cal java.util.Calendar/YEAR))
在Java里,注解(annotation)同樣可以在源代碼外增加額外信息。然而注解不如Clojure元數(shù)據(jù)那樣強大,它們只能在開發(fā)過程中被加入,并只能用于像String這樣的簡單對象,自身也必須是靜態(tài)定義類型。因此,除了在框架開發(fā)者那里,注解實際上很少使用。
另外,雖然實時編譯非常方便,你也可以在開發(fā)時編譯Clojure,就像在Java里所做的一樣。這樣,Clojure就變成了Java的另一個庫——這樣,無論經(jīng)理還是客戶,對新語言的抵觸情緒就小很多,尤其是對Lisp的抵觸。
【編輯推薦】