iT邦幫忙

2018 iT 邦幫忙鐵人賽
DAY 6
0
Software Development

擁抱 Clojure系列 第 6

[第 06 天] 擁抱 Clojure:資料結構與型態(三)

資料結構與型態(三)

群集

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)

若是想取得其後的各別單一元素,可以巢狀地使用 firstrest

(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]

firstrest 也可以作用在向量上:

(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])

序列的核心函式主要有三個:firstrest 以及 consfirst 在之前提到過,取得序列的第一個元素:

(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 給予建議與指教)


上一篇
[第 05 天] 擁抱 Clojure:資料結構與型態(二)
下一篇
[第 07 天] 擁抱 Clojure:繫結與函式(一)
系列文
擁抱 Clojure30

尚未有邦友留言

立即登入留言