Skip to content

Latest commit

 

History

History
620 lines (455 loc) · 17.3 KB

02.md

File metadata and controls

620 lines (455 loc) · 17.3 KB

資料結構與型態

本篇文章將介紹 Clojure 內建的的資料結構與型態,會先從簡單的資料型態如數字及字串開始,再介紹複雜的資料結構如群集 (Collection) 與序列 (Sequence)。

數字 (Number)

整數

在 Clojure 中,整數的表示法與主流程式語言無異,如果沒有特別聲明,預設爲十進位表示法。大小爲 64 位元有號整數,內部使用的型態爲 Java 中的 long:

(class 42)
;; => java.lang.Long
42
;; => 42
-42
;; => -42

除了十進位表示法之外,也提供了八進位、十六進位的表示法:

0x2a
;; => 42
052
;; => 42

在數字前面加上 0 被視爲八進位表示法、前面加上 0x 則是十六進位表示法。也可以自行決定數字的基底,只要在數字前面加上想要使用的基底 (範圍從 2 到 36),再加上 r 即可:

2r101010
;; => 42
16r2A
;; => 42

浮點數

Clojure 的浮點數表示法也與主流程式語言無異,採用的是 IEEE 754 雙精度標準,大小爲 64 位元,內部使用的型態爲 Java 中的 double:

(class 3.14)
;; => java.lang.Double
3.14
;; => 3.14
1.618
;; => 1.618

以指數的方式表現:

3.14e-2
;; => 0.0314
+1.618e-1
;; => 0.1618

有理數

Clojure 爲了支持高精度的計算,提供了有理數型態。舉例來說,在其他主流程式語言裡,1/3 的結果爲 0.3333…,或是將浮點數相加起來,原有的精確度反而在計算中喪失了:

>>> 1.0 / 3.0
0.3333333333333333
>>> 0.1 + 0.1 + 0.1
0.30000000000000004

使用有理數型態作運算,不會經過不必要的轉換而喪失準確度,只有在需要的時候,由使用者決定是否該轉換型態。內部使用的型態爲 clojure.lang.Ratio。

(class (/ 1 3))
;; => clojure.lang.Ratio
(/ 1 3)
;; => 1/3
(+ 1/10 1/10 1/10)
;; => 3/10

你可以將有理數轉型成浮點數:

(double 1/3)
;; => 0.3333333333333333

也可以將浮點數轉型成有理數:

(rationalize 0.3)
;; => 3/10

大數

一般來說,預設提供的 64 位元整數與浮點數已經綽綽有餘,但是如果需要處理超過 64 位元範圍的數值,就需要使用到 Clojure 提供的兩個大數型態:大整數 (BigInt)、大浮點數 (BigDecimal)。內部使用的型態分別爲 clojure.lang.BigInt 以及 java.math.BigDecimal。

(class 1N)
;; => clojure.lang.BigInt
(class 2M)
;; => java.math.BigDecimal

大整數的表示法爲在數字後加上大寫的 N;大浮點數則是在數字後加上大寫的 M。內建的 +-*/incdec 等運算元,作用在整數時如果超出 64 位元範圍 (overflow),會出現錯誤而拋出例外。如果想避免錯誤,讓 Clojure 把結果套用到大數上,則必須使用大數版的運算元,即在運算元後加上單引號 (‘):

(+ 9141592653589793238 9141592653589793238)
;; => ArithmeticException integer overflow
(+' 9141592653589793238 9141592653589793238)
;; => 18283185307179586476N

運算

基本的四則運算與其他主流程式語言一樣,需要注意的是 Clojure 採用前置表示法:運算元擺在小括號的第一個位置:

(+ 1 2)
;; => 3
(- 1 3)
;; => -2
(* 2 4)
;; => 8
(/ 6 3)
;; => 2

如果想要獲得整數除法運算的商,可以使用 quot 函式:

(quot 47 7)
;; => 6

取得除法運算的餘,則使用 rem 函式:

(rem 47 7)
;; => 5

字串與字符

Clojure 的字串即是 Java 的 String 類型,表現方法也跟 Java 一樣用雙引號包住文字:

(class "foo")
;; => java.lang.String

多行字串只要在需要的時候換行即可:

"This multi-
line string"
;; => "This multi-\nline string"

字符是字串的組成元素,使用方法爲將反斜線 (\) 加到文字的前面,型態爲 java.lang.Character:

(class \j)
;; => java.lang.Character
\j
;; => \j

萬國碼 (Unicode) 以及八進位表示法也可以用在字符表示上:

\u00eb
;; => \ë
\o44
;; => \$

請注意,八位元字符最前面加上的是反斜線 (\) 與小寫字母 o (Octal),並不是數字 0。

布林

當運算式使用到流程控制時,需要使用布林型態來決定該往那個分支進行。Clojure 的布林型態有 true 以及 false 兩種。內部型態爲 Java 的 java.lang.Boolean:

(class true)
;; => java.lang.Boolean
true
;; => true
false
;; => false

Clojure 提供了一些函式用來判斷是否爲真:

(true? true)
;; => true
(true? false)
;; => false

在 Clojure 的命名習慣裡,會將一個返回真假值的函式,在名稱後面加上問號 (?)。這樣的函式稱做「述詞函式」 (Predicate)。

Clojure 也提供了一些函式判斷是否爲假:

(false? false)
;; => true
(false? true)
;; => false

除了布林型態之外,nil 用來表示不存在以及虛無,與 Java 中的 null 相同。當 nil 用在條件判斷時,nil 被當作 false

(true? nil)
;; => false
(false? nil)
;; => false
(nil? nil)
;; => true

Clojure 使用 = 來判斷兩個事物是否相等,內部使用 Java 物件的 equals 方法來判斷:

(= 1 1)
;; => true
(= "Hello" "HELLO")
;; => false
(= "Hello" 1)
;; false

符號

符號 (Symbol) 是個標識符 (Identifier),用來指向它所代表的值。對它求值時,會返回它指向的值。在 Clojure 中,所有非數字開頭的名稱都是一個個符號,分別代表數字、字串、集合或函式,每個符號都隸屬於一個命名空間。內部使用的型態爲 clojure.lang.Symbol:

(def username "Rich")
;; => #'user/username
username
;; => "Rich"
(class 'username)
;; => clojure.lang.Symbol

在運算式中直接使用符號,Clojure 會嘗試對它求值,如果這個符號尚未指向任何資料,就會出現例外。所以需要在符號前面加上單引號 (‘),告訴 Clojure 這個符號不需要求值。

(class average)
;; => CompilerException java.lang.RuntimeException: Unable to resolve symbol: average in this context
(class 'average)
;; => clojure.lang.Symbol

你可以使用 symbol 函式創建一個符號:

(symbol "foo")
;; => foo
(symbol "foo" "bar")
;; => foo/bar

關鍵字

關鍵字 (Keyword) 跟符號一樣,是個標識符,但是跟符號不同的是,關鍵字並不指向任何資料,關鍵字被求值時,返回的仍是被求值的關鍵字,關鍵字只代表自己。命名時在名稱前加上冒號 (:),內部使用的資料型態爲 clojure.lang.Symbol:

(class :foo)
;; => clojure.lang.Symbol
:foo
;; => :foo

你可以使用 keyword 函式創建一個關鍵字:

(keyword "foo")
;; => :foo

關鍵字常見的使用方法是跟映射 (Map) 搭配使用,作爲映射的索引鍵。

群集

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 總是將新的元素加入到序列的開頭位置。

回顧

從本篇文章中你更深刻地了解數字、字串、布林、符號與關鍵字等資料結構,還深入認識了四種群集:列表、向量、映射和集合;知道了群集與序列只是抽象化的介面,有許多函式可以拿來運用。

還不賴吧?今天就先到這裡,下一篇文章再見囉!