Clojure 的複合型別稱爲群集 (Collection),可以容納基本型別跟複合型別,所有的群集都是不可變 (Immutable) 以及持久存在 (Persistent)。
Clojure 有四種群集型態,分別爲列表 (List)、向量 (Vector)、映射 (Map) 與集合 (Set),以下將對各個型態詳細介紹。
列表是 Clojure 中最常見的資料結構,寫法是先寫下單引號 (‘),再使用左右小括號將其中的元素包裹起來:
'(1 2 3 4 5)
;; => (1 2 3 4 5)
'(1 "foo" :bar "world")
;; => (1 “foo” :bar “world”)
列表是由兩個部分組合而成,一個是列表的第一個元素,再來是除去第一個元素後剩下的元素,因此可以使用 first
函式取得列表的第一個元素,rest
函式取得剩下來的元素:
(first '(:asimov :heinlein :bradbury :clarke :verne))
;; => :asimov
(rest '(:asimov :heinlein :bradbury :clarke :verne))
;; => (:heinlein :bradbury :clarke :verne)
若是想取得其後的各別單一元素,可以巢狀地使用 first
與 rest
:
(first (rest '(:asimov :heinlein :bradbury :clarke :verne)))
;; => :heinlein
(first (rest (rest '(:asimov :heinlein :bradbury :clarke :verne))))
;; => :bradbury
(first (rest (rest (rest '(:asimov :heinlein :bradbury :clarke :verne)))))
;; => :clarke
(first (rest (rest (rest (rest '(:asimov :heinlein :bradbury :clarke :verne))))))
;; => :verne
列表的最後一個元素是 nil,以表示列表已經到底:
(first (rest (rest '(1 2))))
;; => nil
除了使用實字 (Literal) 的方式寫下列表,還可以使用 list
函式創建列表:
(list :asimov :heinlein :bradbury :clarke :verne)
;; => (:asimov :heinlein :bradbury :clarke :verne)
加入新元素到列表之中,可以使用 conj
:
(conj (list 1 2 3 4) 5)
;; => (5 1 2 3 4)
也可以把列表當作堆疊來使用,使用 peek
取得列表頭部的第一個元素:
(peek (list 1 2 3 4))
;; => 1
使用 pop
取得尾部的其他元素:
(pop (list 1 2 3 4))
;; => (2 3 4)
不知道聰明的你是否注意到,列表與 Clojure 的程式碼表示方法完全一模一樣?有一個炫炮的名詞:同像性 (Homoiconicity),來稱呼這種既是程式、也是資料的表達方式。
具有同像性特色的程式語言,它表現出來的樣子已經跟編譯器使用的語法樹 (AST) 無異,亦即使用者寫出來的程式其實就已經是語法樹了。在其他語言中,語法樹資料結構被遮蓋在陰影之下,使用者如果想要新增語法,只能等待語言委員會經過漫長的投票表決之後,再實作出來。
但是具有同像性特色的程式語言,如果使用者覺得語法詞彙不敷使用,不必等待只要自己捲起袖子開工即可。至於怎麼新增自己的語法詞彙,將在之後講述巨集 (Macro) 的文章中介紹。
使用列表時,如果想要取得特定位置的元素,必須從第一個元素開始往下找尋,而向量 (Vector) 則提供了類似列表的功能,但是可以從任意位置由索引直接取得。
列表使用中括號將元素包裹起來:
[1 2 3 4]
;; => [1 2 3 4]
first
與 rest
也可以作用在向量上:
(first [1 2 3 4])
;; => 1
(rest [1 2 3 4])
;; => (2 3 4)
將新的元素加入到向量中,仍然可以使用 conj
,只是加入的位置和列表不同:
(conj [1 2 3 4] 5)
;; => [1 2 3 4 5]
由於列表在內部實作中,每個元素中只知道下一個元素的位置,插入新的元素最快速的方式便是放在頭部,而向量提供了更有效的存取方法,因此新元素可以安插至尾部。
使用 nth
搭配索引可以快速地取得其中的元素:
(nth [1 2 3 4 5] 0)
;; => 1
(nth [1 2 3 4 5] 2)
;; => 3
使用 count
可以取得列表或向量的元素總數:
(count [1 2 3 4 5])
;; => 5
向量無法表現出資料對應的關係,Clojure 提供了映射 (Map) 可以將資料以索引鍵對應資料的方式存放。映射寫法以大括弧 {}
將索引鍵與資料成對擺放於其中:
{"a" 1 :b 2 :c 2}
;; => {"a" 1, :b 2, :c 2}
也可以使用 hash-map
創建一個映射:
(hash-map "a" 1 :b 2 :c 3)
;; => {:c 3, "a" 1, :b 2}
映射分爲有序與無序兩種,使用大括弧與 hash-map
創建的映射是無序的,所以順序可能會有不同。如果想建立有序的映射,可以使用 sorted-map
創建以索引鍵排序的映射:
(sorted-map :b 2 :c 3 :a 1)
;; => {:a 1, :b 2, :c 3}
爲了更容易分辨,REPL 選擇以逗號 (,) 來分隔成對的元素,在 Clojure 中,逗號與空白是一樣的,並無二致。索引鍵必須是唯一的,不可重複出現。
你可以使用 get
函式並提供索引鍵,取得對應的資料:
(get {:a 1 :b 2 :c 2} :a)
;; => 1
(get {:a 1 :b 2 :c 2} :c)
;; => 2
(get {:a 1 :b 2 :c 2} :d)
;; => nil
範例中示範了如果提供的索引鍵不存在於映射中,會回傳 nil。你也可以將映射當成函式呼叫,搭配索引鍵當作參數,則返回的結果是對應的值:
({:a 1 :b 2 :c 3} :a)
;; => 1
除此之外,關鍵字也可以當作函式來呼叫,以映射當作參數,則會傳回該關鍵字對應的值:
(:b {:a 1 :b 2 :c 3})
;; => 2
(:c {:a 1 :b 2 :c 3})
;; => 3
若是想修改映射的內容,可以使用 assoc
以及 dissoc
來新增或刪除內容,但是要注意的是,因爲在 Clojure 中群集都是不可變的,每次新增或刪除內容時都是產生新的映射。
使用 assoc
會傳回加入新內容的映射,第一個參數是舊的映射,第二以及第三個參數則是新增的索引鍵以及對應的值:
(assoc {:a 1 :b 2 :c 3} :d 4)
;; => {:a 1, :b 2, :c 3, :d 4}
dissoc
則是根據提供的索引鍵,傳回刪除了索引鍵與資料的新映射:
(dissoc {:a 1, :b 2, :c 3, :d 4} :c)
;; => {:a 1, :b 2, :d 4}
以上的範例將索引鍵 :c
以及對應的資料刪去。
最後一個要提到的群集是集合 (Set),集合中的資料必須唯一不重複。它的寫法是使用大括弧{}
將資料包覆起來,並在最前面寫上井號 (#):
#{1 2 3 4 5}
;; => #{1 4 2 3 5}
#{:asimov :heinlein :bradbury}
;; => #{:heinlein :asimov :bradbury}
也可以使用 hash-set
函式建立一個集合:
(hash-set 1 2 3 4 5)
;; => #{1 4 3 2 5}
(hash-set :asimov :heinlein :bradbury)
;; => #{:heinlein :asimov :bradbury}
如果硬要塞入重複的資料,Clojure 會丟出例外強制停止:
#{1 2 3 4 5 2}
;; => IllegalArgumentException Duplicate key: 2
集合跟映射一樣也分成無序和有序兩個版本,如果想建立有序的集合可以使用 sorted-set
函式創建集合:
(sorted-set 2 4 5 3 1)
;; => #{1 2 3 4 5}
clojure.set 這個命名空間 (Namespace) 中包含了可以操作集合的函式,想要使用 clojure.set 的函式,可以先執行以下運算式:
(use 'clojure.set)
;; => nil
以上運算式將 clojure.set 命名空間載入到目前使用的命名空間中,可以開始使用 clojure.set 的所有符號 (命名空間將會在後續的章節中詳細介紹)。
其中 union
函式將會依據傳入的兩個集合,組合之後以集合傳回:
(union #{1 2 3} #{3 4 5})
;; => #{1 4 3 2 5}
difference
函式會回傳一個新的集合,內容爲包含帶入的第一個集合,但是不包含第二個集合的內容:
(difference #{1 2 3} #{3 4 5})
;; => #{1 2}
intersection
函式則會傳回兩個集合相同的元素:
(intersection #{1 2 3} #{3 4 5})
;; => #{3}
前面提到的列表、向量、映射與集合都是 Clojure 中的群集,群集並不是實際的資料結構,它只是一組抽象的介面或協定,只要符合這些介面就可以被稱爲群集。Clojure 提供了一些可以作用在群集的函式,符合協定的群集都可以使用這些函式。
可以作用在群集的函式,有先前提及的 count
函式可以返回群集內元素的個數以及 conj
函式將新的元素加入群集:
(count [1 2 3 4 5])
;; => 5
(count #{1 2 3 4 5})
;; => 5
(conj [1 2 3] 4)
;; => [1 2 3 4]
(conj '(1 2 3) 4)
;; => (4 1 2 3)
=
函式判斷兩個群集是否相等、empty
函式則是傳回與參數相同型態的空群集:
(= [1 2 3] [1 2 3])
;; => true
(= '(1 2 3) '(1 2))
;; => false
(empty [1 2])
;; => []
(empty {:a 1 :b 2})
;; => {}
所有群集都支援 seq
函式,它可以將帶入的群集轉換成序列 (Sequence) 這種抽象介面。可以把序列 (Sequence) 看成是看待資料的方式,它必須是循序擺放就像是列表一樣。seq
除了可以將 Clojure 中的群集轉換成序列之外,字串、Java 中的群集與陣列以及任何實作 java.util.Iterable 介面的類別也可以轉換:
(seq [1 2 3])
;; => (1 2 3)
(seq "Clojure")
;; => (\C \l \o \j \u \r \e)
(seq {:a 2 :b 1})
;; => ([:a 2] [:b 1])
序列的核心函式主要有三個:first
、rest
以及 cons
。first
在之前提到過,取得序列的第一個元素:
(first (seq [1 2 3 4 5 6]))
;; => 1
rest
函式也在之前提到,傳回除了第一個元素之外的其他元素:
(rest (seq [1 2 3 4 5 6]))
;; => (2 3 4 5 6)
而 cons
則是產生新序列的函式,它將第一個參數的新元素加入到第二個參數的序列中 (因爲不可變動的特性,實際上是產生新的序列):
(cons :a [:b :c :d])
;; => (:a :b :c :d)
(cons 0 '(1 2 3 4))
;; => (0 1 2 3 4)
cons
總是將新的元素加入到序列的開頭位置。
從本篇文章中你更深刻地了解數字、字串、布林、符號與關鍵字等資料結構,還深入認識了四種群集:列表、向量、映射和集合;知道了群集與序列只是抽象化的介面,有許多函式可以拿來運用。
還不賴吧?今天就先到這裡,下一篇文章再見囉!
(本篇文章同步刊登於 GitHub,歡迎在文章下方留言或發送 PR 給予建議與指教)