看下面這段代碼中的Java迭代器。這是使用迭代器的老套路了。實際上,Java 5里的for
循環在底層也會被轉換成這種實現:
Collection<String> c = ...;
for (Iterator&<String> it = c.iterator; it.hasNext;) {
String str = it.next;
...
}
對於簡單集合的循環處理這就夠了,比如Set
或List
。但Iterator
接口只有next
和hasNext
方法,加上一個可選的remove
方法。
1. 殘缺的Java迭代器
然而Java迭代器還有缺陷。迭代器接口所提供的集合交互方法滿足不了需求。用Iterator
只能做兩件事:
- 查看集合中是否還有更多的元素;
- 取出下一個元素,並把迭代器向前推進。
Iterator
最主要的問題是把取得下一個元素和向前推進合在了一起(如圖10-5所示)。這意味著無法先對集合中的下一個元素進行檢查,然後再決定它是需要特殊處理,還是完好無損地取出去。
圖10-5 Java迭代器的性質
從迭代器中取出下一元素的行為改變了它的狀態。也就是說可變已經內建在Java處理集合和迭代器的方法中了,因此不可能用它構建出強健的多路解決方案。
2. Clojure的鍵抽像
Clojure採用了不同的方式。Clojure與Java中的集合與迭代器相對應的核心概念是序列(sequence),或者簡稱seq。它基本上是把兩個Java類的一些特性集成到了一個概念裡。這樣做的動機有三個:
- 更強健的迭代器,特別是對於多路算法而言;
- 不可變能力,可以安全地在函數間傳遞序列;
- 實現懶序列的可能性(後面還會詳細討論)。
表10-4中列出了跟序列相關的一些核心功能。這些函數都不會改變它們的參數,如果它們需要返回不同的值,那會是一個不同的序列。
表10-4 基本的序列函數
(seq <coll>)
返回一個序列,作為所操作集合的「視圖「
(first <coll>)
返回集合的第一個元素,如有必要,先在其上調用(seq)
。如果集合為nil
,則返回nil
(rest <coll>)
返回從集合中去掉第一個元素後得到的新序列。如果集合為nil
,則返回nil
(seq? <o>)
如果o
是一個序列則返回true
(也就是實現了ISeq
)
(cons <elt> <coll>)
在集合前面增加新元素,並返回由此得到的序列
(conj <coll> <elt>)
返回將新元素加到合適一端(向量的尾端和列表的頭)的新集合
(every? <pred-fn> <coll>)
如果(pred-fn)
對集合中的每個元素都返回邏輯真,則返回true
這裡有幾個例子:
1:1 user=> (rest \'(1 2 3))
(2 3)
1:2 user=> (first \'(1 2 3))
1
1:3 user=> (rest [1 2 3])
(2 3)
1:13 user=> (seq )
nil
1:14 user=> (seq )
nil
1:15 user=> (cons 1 [2 3])
(1 2 3)
1:16 user=> (every? is-prime [2 3 5 7 11])
true
有一點要重點關注一下,列表是自身的序列,而向量不是。因此從理論上來說,不能在向量上調用(rest)
。可實際上是可以的,因為(rest)
在操作向量之前先在其上調用了(seq)
。這是序列結構中普遍存在的屬性:很多序列函數都會接受比序列更通用的對象,並在開始之前先調用(seq)
。
我們在這一節中準備探索seq的一些基本屬性和用法,尤其會重點關注懶序列和變參函數。其中第一個概念」懶「,是Java中不太會涉及的編程技術1,所以對你來說它可能比較新穎。現在我們就來看一下吧。
1 用過Hibernate的人一定知道懶加載(因為它原來經常爆異常),其基本思路」延遲「跟懶是一樣的。——譯者注
10.4.1 懶序列
在編程語言裡,懶是一個強大的概念。其基本思想是將表達式的計算推遲到需要時。體現在Clojure中就是序列可以不是完整的值列表,其中的值可以在被請求時取得(比如根據需要通過調用函數生成它們)。
在Java中,要滿足這樣的想法就得靠定制的List
實現,而且要寫大量的套路化代碼才可能實現。用Clojure中的宏只要做一點兒工作就能創建出懶序列。
想一想怎麼才能創建出一個懶惰的、可能包含無限數量值的序列。很明顯,用函數來生成序列內的元素。這個函數應該做兩件事:
- 返回序列中的下一個元素;
- 接受數量固定、有限的參數。
數學家會說這樣一個函數定義的是遞歸關係,並且這樣的關係用遞歸的方式處理再恰當不過了。
假設有一台在棧空間和其他能力上都不受限制的機器,並且可以執行兩個線程:一個用來生成無限的序列,另外一個使用該序列。那我們就可以在生成線程裡用遞歸定義懶序列,類似下面這段偽代碼:
(defn infinite-seq <vec-args>
(let [new-val (seq-fn <vec-args>)]
(cons new-val (infinite-seq <new-vec-args>))))
實際上在Clojure中這是行不通的,因為(infinite-seq)
上的遞歸會導致棧溢出。但要是加上一個結構,告訴Clojure不要瘋狂遞歸,僅根據需要進行處理,是可以做到的。
不僅如此,你還能在一個線程內做到這一點,如下例所示。代碼清單10-5中為某個數k
定義了懶序列k, k+1, k+2, ...
。
代碼清單10-5 懶序列的例子
(defn next-big-n [n] (let [new-val (+ 1 n)]
(lazy-seq ; //lazy-seq標記
(cons new-val (next-big-n new-val)) ; //無限遞歸
)))
(defn natural-k [k]
(concat [k] (next-big-n k))) ; //concat限制遞歸
1:57 user=> (take 10 (natural-k 3))
(3 4 5 6 7 8 9 10 11 12)
(lazy-seq)
形式是關鍵,它標記了發生無限遞歸的點,還有(concat)
,可以安全地處理遞歸。然後你就可以用(take)
形式從懶序列中取出所需的元素了,這個基本上是用(next-big-n)
形式定義的。
懶序列是極其強大的特性,實踐會告訴你它們是Clojure軍火庫中的強大武器。
10.4.2 序列和變參函數
Clojure函數有一個強大的特性,它天生就具備參數數量可變的能力,有時稱為函數的變元(arity)。參數數量可變的函數稱為變參函數(variadic)。
代碼清單10-1中討論過的函數(const-fun1)
可以作為一個簡單的例子。這個函數接受一個參數並拋棄它,總是返回值1。請看傳入多個參數給(const-fun1)
時會發生什麼:
1:32 user=> (const-fun1 2 3)
java.lang.IllegalArgumentException: Wrong number of args (2) passed to:
user$const-fun1 (repl-1:32)
Clojure編譯器仍然會對傳給(const-fun1)
的參數數量(和類型)做一些檢查。對於簡單地拋棄所有參數並返回一個常量值的函數來說,這似乎過於嚴格了。在Clojure中能接受任意數量參數的函數看起來會是什麼樣的呢?
代碼清單10-6展示了如何實現一個這樣的(const-fun1)
常量函數。我們管它叫(const-fun-arity1)
,變元的const-fun1
。這是在Clojure標準函數庫中(constantly)
函數的自產版。
代碼清單10-6 帶有變元的函數
1:28 user=> (defn const-fun-arity1
( 1) ; //帶不同簽名的多個defn
([x] 1) //帶不同簽名的多個defn
([x & more] 1) //帶不同簽名的多個defn
)
#\'user/const-fun-arity1
1:33 user=> (const-fun-arity1)
1
1:34 user=> (const-fun-arity1 2)
1
1:35 user=> (const-fun-arity1 2 3 4)
1
這個函數的定義不是一個參數向量後跟著函數行為的定義。而是有一系列這種組合,每個組合裡都是一個參數向量(構成了這一版本函數的有效簽名)和這一版本函數的實現。
這跟Java的方法重載類似。傳統做法一般是定義幾個特殊情況下的形式(沒有參數、一個或兩個參數)和最後一個參數為序列的額外形式。代碼清單10-6中就是參數向量為[x & more]
的那個。&
符號表明這是該函數的變參版本。
序列是Clojure的創新。實際上,用Clojure編程主要就是要思考怎麼用序列解決特定問題。
Clojure的另一項重要創新是Clojure和Java的集成,也就是我們下一節的主題。