Lisp 是一種編程語言,以表達性和功能強大著稱,但人們通常認為它不太適合應用於一般情況。Clojure 是一種運行在 Java™ 平台上的 Lisp 方言,它的出現徹底改變了這一現狀。如今,在任何具備 Java 虛擬機的地方,您都可以利用 Lisp 的強大功能。在本文中,了解如何開始使用 Clojure,學習它的一些語法,同時利用 Eclipse 的 Clojure 插件提供幫助。
本文介紹了 Clojure 編程語言。Clojure 是一種 Lisp 方言。本文假設您不熟悉 Lisp,但需要您具備 Java 技術方面的知識。要編寫 Clojure 程序,需要 Java Development Kit V5 或更高版本以及 Clojure 庫。本文使用的是 JDK V1.6.0_13 和 Clojure V1。此外,您還需要利用 Eclipse 的 Clojure 插件(clojure-dev),因此還要用到 Eclipse。在本文中,我們使用了 Eclipse V3.5 和 clojure-dev 0.0.34。
不久前,要想在 Java Virtual Machine (JVM) 上運行程序,還需要使用 Java 編程語言來編寫程序。但那個時代已經一去不復返了。現在更多的選擇,比如 Groovy、Ruby(通過 JRuby)及 Python (通過 Jython),帶來了一種更為過程化的腳本式的編程風格,或者它們各自擁有獨特的面向對象編程特色。這兩種都是 Java 程序員所熟悉的。也許有人會說用這些語言與用 Java 語言編寫程序沒什麼區別,只要習慣不同的語法就可以了。
雖然 Clojure 還不算是 JVM 的一種新的編程語言,但它與 Java 技術及前面提到過的其他任何 JVM 語言都有很大的區別。它是一種 Lisp 方言。從 20 世紀 50 年代開始至今,Lisp 語言家族已經存在很長時間了。Lisp 使用的是截然不同的 S-表達式或前綴 注釋。這個注釋可以被歸結為 (function arguments...)
。通常總是從函數名開始,然後列出要向這個函數添加的零個或多個參數。函數及其參數通過圓括號組織在一起。數量眾多的括號也成為了 Lisp 的一大特征。
您可能已經發現,Clojure 是一種函數式編程語言。專業人士可能會說它太過單一,但實際上它卻囊括了函數式編程的所有精華:避免了不穩定狀態、遞歸、更高階的函數等。Clojure 還是一個動態類型的語言,您可以選擇添加類型信息來提高代碼中的關鍵路徑的性能。Clojure 不僅可在 JVM 上運行,而且在設計上還兼顧了 Java 的互操作性。最後,Clojure 在設計上也考慮了並發性,並具有並發編程的一些獨特特性。
對大多數人來說,學習一種新的編程語言的最佳方法是從練習編寫代碼開始。按照這個思路,我們將提出一些簡單的編程問題,然後用 Clojure 來解決這些問題。我們將深入剖析每種解決方案以便您能更好地理解 Clojure 是如何工作的、該如何使用它、它最擅長什麼。不過,像其他語言一樣,要想使用 Clojure,我們需要先為它建立一個開發環境。幸好,建立 Clojure 環境非常容易。
要建立 Clojure 語言環境,您所需要的就是一個 JDK 和 Clojure 庫(一個 JAR 文件)。開發和運行 Clojure 程序有兩種常用方式。其中最常用的一種方法是使用它的 REPL(read-eval-print-loop)。
$ java -cp clojure-1.0.0.jar clojure.lang.Repl Clojure 1.0.0- user=>
此命令從 Clojure JAR 所在的目錄運行。按需要,將路徑調整到 JAR。您還可以創建一個腳本並執行此腳本。為此,需要執行一個名為 clojure.main 的 Java 類。
$ java -cp clojure-1.0.0.jar clojure.main /some/path/to/Euler1.clj 233168
同樣,您需要將路徑調整到 Clojure JAR 及腳本。Clojure 終於有了 IDE 支持。Eclipse 用戶可以通過 Eclipse 升級網站來安裝 clojure-dev 插件。安裝完畢且確保處於 Java 透視圖中後,就可以創建一個新的 Clojure 項目和新的 Clojure 文件了,如下所示。
有了 clojure-dev,您就能夠獲得一些基本語法的亮點,包括圓括號匹配(Lisp 所必需的)。您還可以在被直接嵌入到 Eclipse 的一個 REPL 中放入任意腳本。這個插件還很新,在本文寫作之時,它的特性還在不斷發展。現在,我們已經解決了基礎設置的問題,接下來讓我們通過編寫一些 Clojure 程序來進一步研究這個編程語言。
Lisp 這一名字來自於 “列表處理”,人們常說 Lisp 中的任何東西都是一個列表。在 Clojure 中,列表被統一成了序列。在第一個示例中,我們將處理下述的編程問題。
如果我們要列出 10 以下且為 3 或 5 的倍數的所有自然數,我們將得到 3、5、6 和 9。這幾個數的和是 23。我們的題目是求出 1,000 以下且為 3 或 5 的倍數的自然數的和。
這個題目取自 Project Euler,Project Euler 是一些可以通過巧妙(有時也不是很巧妙)的計算機編程解決的數學題集。實際上,這就是我們的問題 1。清單 3 給出了這個問題的解決方案,其中使用了 Clojure。
(defn divisible-by-3-or-5? [num] (or (== (mod num 3) 0)(== (mod num 5) 0))) (println (reduce + (filter divisible-by-3-or-5? (range 1000))))
第一行定義了一個函數。記住:函數是 Clojure 程序的基石。大多數 Java 編程員都習慣於把對象作為其程序的基石,所以一些人可能需要一些時間才能習慣使用函數。您可能會認為 defn
是此語言的關鍵字,但它實際上是個宏。一個宏允許您對 Clojure 做擴展以向該語言中添加新的關鍵字。也就是說,defn
並不是此語言規范的一部分,而是通過此語言的核心庫被添加上的。
在本例中,第一行實際上是創建了一個名為 divisible-by-3-or-5?
的函數。這遵循了 Clojure 的命名約定。單詞均以連字符分隔,並且此函數的名字是以一個問號結尾的,用以表示此函數是一個斷言,因它會返回 true 或 false。此函數只接受一個名為 num
的單一參數。如果有更多的輸入參數,它們將顯示在這個方括號內,以空格分隔。
下面是這個函數的主體。首先,我們調用 or
函數。這是常用的 or
邏輯;它是一個函數,而不是一個操作符。我們將它傳遞給參數。而每個參數也是一個表達式。第一個表達式是以 ==
函數開始的。它對傳遞進來的這些參數的值進行比較。傳遞給它的有兩個參數。第一個參數是另一個表達式;這個表達式調用 mod
函數。這是數學裡的模運算符或 Java 語言裡的 %
運算符。它返回的是余數,所以在本示例中,余數是 num
被 3 除後的余數。該余數與 0 比較(如果余數是 0,那麼 num
可以被 3 整除)。同樣地,我們檢查 num
被 5 除後的余數是否是 0。如果這兩種情況的余數有一個是 0,那麼此函數返回 true。
在接下來的一行,我們創建一個表達式並把它打印出來。讓我們從圓括號的最裡面開始。在這裡,我們調用了 range 函數並將數 1,000 傳遞給它。這會創建一個由 0 開始,所有小於 1,000 的數組成的序列。這組數正是我們想要檢查是否可被 3 或 5 整除的那些數。向外移,我們會調用 filter
函數。此函數接受兩個參數:第一個是另一個函數,該函數必須是一個斷言,因它必須要返回 true 或 false;第二個參數是一個序列 — 在本例中,此序列是 (0, 1, 2, ... 999)
。filter
函數被應用到這個斷言,如果該斷言返回 true,序列中的元素就被加到此結果。這個斷言就是在上一行中定義的 divisible-by-3-or-5?
函數。
因此,這個過濾器表達式的結果是一個整數序列,其中每個整數都小於 1,000 且能被 3 或 5 整除。而這也正好是我們感興趣的那組整數,現在,我們只需將它們相加。為此,我們使用 reduce 函數。這個函數接受兩個參數:一個函數和一個序列。它將此函數應用到序列中的前兩個元素。然後再將此函數應用到之前的結果以及序列中的下一個元素。在本例中,該函數就是 +
函數,即加函數。它能將該序列中的所有元素都加起來。
從清單 3 中,不難看出在這一小段代碼中發生了很多事情。而這也恰好是 Clojure 吸引人之處。發生的操作雖然很多,但如果熟悉了這些注釋,代碼將很容易讀懂。同樣的事情,若用 Java 代碼去做,則需要用到更多的代碼量。讓我們接下來看看另一個例子。
通過這個例子,我們來探討一下 Clojure 內的遞歸和惰性。這對於很多 Java 程序員而言是另一個新概念。Clojure 允許定義 “懶惰” 的序列,即其中的元素只有在需要的時候才進行計算。借此,您就可以定義無窮序列,這在 Java 語言中是從未有過的。要了解這一點是多麼地有用,可以看看下面這個例子,該例涉及到了函數式語言的另一個十分重要的方面:遞歸。同樣地,我們仍然使用 Project Euler 中的一個編程問題,但是這次,它是問題 2。
Fibonacci 序列中的每個新的項都是其前面兩項相加的結果。從 1 和 2 開始,前 10 項將是:1, 2, 3, 5, 8, 13, 21, 34, 55, 89, ...
現在,我們要找到此序列中所有偶數項之和,且不超過 400 萬。為了解決這個問題,Java 程序員一般會想到要定義一個函數來給出第 n 個 Fibonacci 數。這個問題的一個簡單實現如下所示。
(defn fib [n] (if (= n 0) 0 (if (= n 1) 1 (+ (fib (- n 1)) (fib (- n 2))))))
它檢查 n 是否為 0;如果是,就返回 0。然後檢查 n 是否為 1。如果是,就返回 1。否則,計算第 (n-1) 個 Fibonacci 數和第 (n-2) 個 Fibonacci 數並將二者加起來。這當然很正確,但是如果您已經進行了很多的 Java 編程,就會看到問題。像這樣的遞歸定義很快就會填滿堆棧,從而導致堆棧溢出。Fibonacci 數形成了一個無窮序列,所以應該用 Clojure 的無窮惰性序列描述它,如清單 5 所示。請注意雖然 Clojure 具有一個更為有效的 Fibonacci 實現,是標准庫(clojure-contrib)的一部分,但它較為復雜,因此這裡所示的這個 Fibonacci 序列來自於 Stuart Halloway 的一本書。
(defn lazy-seq-fibo ([] (concat [0 1] (lazy-seq-fibo 0 1))) ([a b] (let [n (+ a b)] (lazy-seq (cons n (lazy-seq-fibo b n))))))
在清單 5 中,lazy-seq-fibo
函數具有兩個定義。第一個定義沒有參數,因此方括號是空的。第二個定義有兩個參數 [a b]
。對於沒有參數的情況,我們獲取序列 [0 1]
並將它連到一個表達式。該表達式是對 lazy-seq-fibo
的一個遞歸調用,不過這次,它調用的是有兩個參數的情況,並向其傳遞 0 和 1。
兩個參數的情況從 let
表達式開始。這是 Clojure 內的變量賦值。表達式 [n (+ a b)]
設置變量 n
並將其設為等於 a+b
。然後它再使用 lazy-seq
宏。正如其名字所暗示的,lazy-seq
宏被用來創建一個惰性序列。其主體是一個表達式。在本例中,它使用了 cons
函數。該函數是 Lisp 內的一個典型函數。它接受一個元素和一個序列並通過將元素添加在序列之前來返回一個新序列。在本例中,此序列就是調用 lazy-seq-fibo
函數後的結果。如果這個序列不是惰性的,lazy-seq-fibo
函數就會一次又一次地被調用。不過,lazy-seq
宏確保了此函數將只在元素被訪問的時候調用。為了查看此序列的實際處理,可以使用 REPL,如清單 6 所示。
1:1 user=> (defn lazy-seq-fibo ([] (concat [0 1] (lazy-seq-fibo 0 1))) ([a b] (let [n (+ a b)] (lazy-seq (cons n (lazy-seq-fibo b n)))))) #'user/lazy-seq-fibo 1:8 user=> (take 10 (lazy-seq-fibo)) (0 1 1 2 3 5 8 13 21 34)
take
函數用來從一個序列中取得一定數量(在本例中是 10)的元素。我們已經具備了一種很好的生成 Fibonacci 數的方式,讓我們來解決這個問題。
(defn less-than-four-million? [n] (< n 4000000)) (println (reduce + (filter even? (take-while less-than-four-million? (lazy-seq-fibo)))))
在清單 7 中,我們定義了一個函數,稱為 less-than-four-million?
。它測試的是其輸入是否小於 400 萬。在接下來的表達式中,從最裡面的表達式開始會很有用。我們首先獲得一個無窮的 Fibonacci 序列。然後使用 take-while
函數。它類似於 take
函數,但它接受一個斷言。一旦斷言返回 false,它就停止從這個序列中獲取。所以在本例中,Fibonacci 數一旦大於 400 萬,我們就停止獲取。我們取得這個結果並應用一個過濾器。此過濾器使用內置的 even?
函數。該函數的功能正如您所想:它測試一個數是否是偶數。結果得到的是所有小於 400 萬且為偶數的 Fibonacci 數。現在我們對它們進行求和,使用 reduce
,正如我們在第一個例子中所做的。
清單 7 雖然能解決這個問題,但是並不完全令人滿意。要使用 take-while
函數,我們必須要定義一個十分簡單的函數,稱為 less-than-four-million?
。而結果表明,這並非必需。Clojure 具備對閉包的支持,這沒什麼稀奇。這能簡化代碼,如清單 8 中所示。
閉包在很多編程語言中非常常見,特別是在 Clojure 等函數語言中。這不僅僅是因為函數 “級別高” 且可被作為參數傳遞給其他函數,還因為它們可被內聯定義或匿名定義。清單 8 是清單 7 的一個簡化版,其中使用了閉包。
(println (reduce + (filter even? (take-while (fn [n] (< n 4000000)) (lazy-seq-fibo)))))
在清單 8 中,我們使用了 fn
宏。這會創建一個匿名函數並返回此函數。對函數使用斷言通常很簡單,而且最好使用閉包定義。而 Clojure 具有一種更為簡化的方式來定義閉包。
(println (reduce + (filter even? (take-while #(< % 4000000) (lazy-seq-fibo)))))
我們曾使用 #
創建閉包,而不是借助 fn
宏。而且我們還為傳遞給此函數的第一個參數使用了 %
符號。您也可以為第一個參數使用 %1
,如果此函數接受多個參數,還可以使用類似的 %2
、%3
等。
通過上述這兩個簡單的例子,我們已經看到了 Clojure 的很多特性。Clojure 的另一個重要的方面是其與 Java 語言的緊密集成。讓我們看另外一個例子來了解從 Clojure 使用 Java 是多麼有幫助。
Java 平台能提供的功能很多。JVM 的性能以及豐富的核心 API 和以 Java 語言編寫的第三方庫都是功能強大的工具,能夠幫助避免大量重復的工作。Clojure 正是圍繞這些理念構建的。在 Clojure 中,很容易調用 Java 方法、創建 Java 對象、實現 Java 界面以及擴展 Java 類。為了舉例說明,讓我們來看看另一個 Project Euler 問題。
Find the greatest product of five consecutive digits in the 1000-digit number. 73167176531330624919225119674426574742355349194934 96983520312774506326239578318016984801869478851843 85861560789112949495459501737958331952853208805511 12540698747158523863050715693290963295227443043557 66896648950445244523161731856403098711121722383113 62229893423380308135336276614282806444486645238749 30358907296290491560440772390713810515859307960866 70172427121883998797908792274921901699720888093776 65727333001053367881220235421809751254540594752243 52584907711670556013604839586446706324415722155397 53697817977846174064955149290862569321978468622482 83972241375657056057490261407972968652414535100474 82166370484403199890008895243450658541227588666881 16427171479924442928230863465674813919123162824586 17866458359124566529476545682848912883142607690042 24219022671055626321111109370544217506941658960408 07198403850962455444362981230987879927244284909188 84580156166097919133875499200524063689912560717606 05886116467109405077541002256983155200055935729725 71636269561882670428252483600823257530420752963450
在這個問題中,有一個 1,000-位的數字。在 Java 技術裡,該數可以通過 BigInteger
表示。但是,我們無需在整個數上進行計算 — 只需每次計算 5 位。因而,將它視為字符串會更為簡單。不過,為了進行計算,我們需要將這些數位視為整數。所幸的是,在 Java 語言中,已經有了一些 API,可用來在字符串和整數之間來回轉換。作為開始,我們首先需要處理上面這一大段不規則的文本。
(def big-num-str (str "73167176531330624919225119674426574742355349194934 96983520312774506326239578318016984801869478851843 85861560789112949495459501737958331952853208805511 12540698747158523863050715693290963295227443043557 66896648950445244523161731856403098711121722383113 62229893423380308135336276614282806444486645238749 30358907296290491560440772390713810515859307960866 70172427121883998797908792274921901699720888093776 65727333001053367881220235421809751254540594752243 52584907711670556013604839586446706324415722155397 53697817977846174064955149290862569321978468622482 83972241375657056057490261407972968652414535100474 82166370484403199890008895243450658541227588666881 16427171479924442928230863465674813919123162824586 17866458359124566529476545682848912883142607690042 24219022671055626321111109370544217506941658960408 07198403850962455444362981230987879927244284909188 84580156166097919133875499200524063689912560717606 05886116467109405077541002256983155200055935729725 71636269561882670428252483600823257530420752963450"))
這裡,我們利用了 Clojure 對多行字符串的支持。我們使用了 str
函數來解析這個多行字符串。之後,使用 def
宏來定義一個常量,稱為 big-num-str
。不過,將其轉換為一個整數序列將十分有用。這在清單 12 中完成。
(def the-digits (map #(Integer. (str %)) (filter #(Character/isDigit %) (seq big-num-str))))
同樣地,讓我們從最裡面的表達式開始。我們使用 seq
函數來將 big-num-str
轉變為一個序列。不過,結果表明此序列並非我們所想要的。REPL 可以幫助我們看出這一點,如下所示。
big-num-str
序列user=> (seq big-num-str) (\7 \3 \1 \6 \7 \1 \7 \6 \5 \3 \1 \3 \3 \0 \6 \2 \4 \9 \1 \9 \2 \2 \5 \1 \1 \9 \6 \7 \4 \4 \2 \6 \5 \7 \4 \7 \4 \2 \3 \5 \5 \3 \4 \9 \1 \9 \4 \9 \3 \4 \newline...
REPL 將字符(一個 Java char)顯示為 \c
。因此 \7
就是 char 7,而 \newline
則為 char \n(一個換行)。這也是直接解析文本所得到的結果。顯然,我們需要消除換行並轉換為整數,然後才能進行有用的計算。這也正是我們在清單 11 中所做的。在那裡,我們使用了一個過濾器來消除換行。請再次注意,我們為傳遞給 filter
函數的斷言函數使用了一個簡短的閉包。閉包使用的是 Character/isDigit
。這是來自 java.lang.Character
的靜態方法 isDigit
。因此,這個過濾器只允許數值 char,而會丟棄換行字符。
現在,消除了換行之後,我們需要進行到整數的轉換。浏覽清單 12,會注意到我們使用了 map
函數,它接受兩個參數:一個函數和一個序列。它返回一個新的序列,該序列的第 n 個元素是將此函數應用到原始序列的第 n 個元素後的結果。對於這個函數,我們再次使用了一個簡短的閉包注釋。首先,我們使用 Clojure 的 str
函數來將這個 char 轉變為字符串。我們為什麼要這麼做呢?因為,接下來,我們要使用 java.lang.Integer
的構造函數來創建一個整數。而這是由 Integer
注釋的。應該將這個表達式視為新的 java.lang.Integer(str(%))
。聯合使用它與 map
函數,我們就能如願地得到一個整數序列。現在,我們來解決這個問題。
(println (apply max (map #(reduce * %) (for [idx (range (count the-digits))] (take 5 (drop idx the-digits))))))
要理解這段代碼的含義,讓我們從 for
宏開始。它不同於 Java 語言中的 for
循環,它是一個序列推導式。首先,我們使用一些方括號來創建一個綁定。在本例中,我們將變量 idx
綁定到一個從 0 到 N-1 的序列,其中 N 是序列 the-digits
中的元素的數量(N = 1,000,因為原始的數值具有 1,000 位)。接下來,for
宏獲取一個表達式來生成一個新的序列。它將迭代 idx
序列中的每個元素,求得表達式的值,並將結果添加到這個返回序列。不難看出,這在某些方面充當了 for
循環的功能。在此推導式中所使用的表達式首先使用 drop
函數來向下移(drop)此序列的第一個 M 元素,然後使用 take
函數來取得此截短序列的前五個元素。M 將是 0,然後是 1,2,以此類推,所以結果將是一個包含序列的序列,其中的前五個元素將是 (e1, e2, e3, e4, e5),下一個元素將是 (e2, e3, e4, e5, e6),以此類推,其中的 e1、e2 等均是來自 the-digits
的元素。
有了這個包含序列的序列之後,我們就可以使用 map
函數了。我們使用 reduce
函數將五個數組成的每個序列轉換成這五個數的乘積。現在,我們就得到了一個整數序列,其中的第一個元素是元素 1-5 的乘積,第二個元素是元素 2-6 的乘積,以此類推。我們想要得到最大的乘積。為此,我們使用 max
函數。然而,max
一般接受傳遞給它的多個元素,而不是單一一個序列。為了將這個序列轉變為多個元素後再傳遞給 max
,我們使用 apply
函數。這會產生我們想要的最大數,當然還會打印出結果。現在,您已經解決了幾個問題,並同時掌握了該如何使用 Clojure。
在本文中,我們介紹了 Clojure 編程語言並從 Eclipse 的 Clojure 插件的使用中受益匪淺。我們先是簡單查看了其原理和特性,之後重點介紹了幾個代碼示例。在這些代碼示例中,我們逐漸了解了該語言的核心特性:函數、宏、綁定、遞歸、惰性序列、閉包、推導式以及與 Java 技術的集成。Clojure 還有很多其他的方面。我希望,該語言已經吸引了您,以至於您有興趣借助本文所列的一些參考資料來深入了解它。
Clojure 的詳細介紹:請點這裡
Clojure 的下載地址:請點這裡