從本節開始,我們會接觸到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 to
clojure.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/schwartz
1: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/adder
1:6 user=> (def plus2 (adder 2))
#\'user/plus2
1:7 user=> (plus2 3)
5
1: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/plus3
1:14 user=> (plus3 4)
7
1:15 user=> (plus2 4)
6
這段代碼表明你還可以再構造其他函數(plus3)
,綁定不同的值到constToAdd
上。我們說函數(plus3)
和(plus2)
已經從它們所在的環境中捕獲或「封裝」了一個值1。需要注意的是(plus3)
和(plus2)
捕獲的值是不同的,並且定義(plus3)
對(plus2)
捕獲的值沒有影響。
1 此處的環境即指函數(adder)
,而捕獲的值即綁定到constToAdd
的值。——譯者注
在自身環境內「封裝」一些值的函數稱為閉包,(plus2)
和(plus3)
就是閉包。在支持閉包的語言中,用一個製造者函數構造並返回另一個封裝了一些東西的函數非常普遍。
接下來我們要討論Clojure中一個強大的特性:序列。它們使用了跟Java的集合或迭代器類似的東西,但有些不同的屬性。在代碼中使用序列最能體現Clojure語言的力量,對於習慣了Java處理方式的程序員,Clojure的處理方式會讓你耳目一新。