讀古今文學網 > Java程序員修煉之道 > 10.4 Clojure序列 >

10.4 Clojure序列

看下面這段代碼中的Java迭代器。這是使用迭代器的老套路了。實際上,Java 5里的for循環在底層也會被轉換成這種實現:

Collection<String> c = ...;

for (Iterator&<String> it = c.iterator; it.hasNext;) {
  String str = it.next;
  ...
}
  

對於簡單集合的循環處理這就夠了,比如SetList。但Iterator接口只有nexthasNext方法,加上一個可選的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的集成,也就是我們下一節的主題。