1. Clojure 和 LISP 的關係
  2. Literal Representation Data
    1. Numeric
    2. Character, String And Regular Expression
    3. Symbol & Keyword
  3. 註解
  4. LISP FORM
  5. List
    1. Functions of List
  6. Collection
    1. Sequencetial Collection
    2. Hashed Collection
  7. Function
  8. Special Form
    1. 1. def
    2. 2. if
    3. 3. do
    4. 4. let
    5. 5. quote
    6. 6. fn
    7. 7. loop & recur
  9. What Macro ?
  10. Flow Control
  11. Side Effect
  12. 重新回顧 Clojure Verbs:LISP Form、Special Form、Macro
  13. 結語
  14. Code Block 的小袐密

Clojure 和 LISP 的關係

在語法上,目前台面上看到的大部份主流語言都深受 C 影響,如 Java、JavaScript、C#、PHP 等, 而 C 的語法深受另一個更早期的語言影響 – ALGOL。 另外和 ALOGL 同時期,也有個語言也影響現代語言,目前語言常見的功能,像是 High Order Function、Garbage Collection、REPL、Recursion 等, 最早是在 LISP 中出現的。

LISP 是 LISt Processor 的簡寫,最大的特點就是它的語法:

(car (cdr '(1 2 3 4 5)))

這種語法而上面這段程式所做的行為是:

  1. '(1 2 3 4 5) 這個資料(這是一個 List)做為 cdr 這個 Function 輸入
  2. 把步驟 1 的輸出再當成參數輸入到 car 這個 Function 中

Clojure 是一個 LISP 的方言,所以 Clojure 的語法就前一個例子那樣,只不過和 LISP 不同的是:

  1. 用字更現代,不會有義意不明的名稱如:car、cdr 等
  2. 吸收了一些 Functional Programming 後來所發展的功能,像是 Immutable Data Structure
  3. 運作在 JVM 上,所以還有和 Java 互動的功能
  4. Clojure 沒有 cons cell
  5. ...

由於本文主要針對連 LISP 都沒聽過的受眾,如果是 LISPer 想了解更多可以直接參考 Clojure Official 的 Differences with other Lisps

p.s. car 和 cdr 其實是 contents of the address part of register numbercontents of the decrement part of register number,這其實是在說早期的電腦硬體架構

Literal Representation Data

前述給了一個 LISP 的程式例子:

(car (cdr '(1 2 3 4 5)))

應該很容易注意到 LISP 中是直接用空白分隔東西的,在 LISP 中這些東西有一個專有名稱叫atom,是指說無法再被分割的資料, 反之 list 就是可以被分割的資料:

car   ; <- 這是 atom
1     ; <- 這還是 atom
(1 2) ; <- 這是 list
()    ; <- 空的 list,因為他不能被分割了,所以是 atom

但在 Clojure 中,atom 是另一個和 Concurrency 有關的名稱,所以 Clojure 不會稱那些東西atom,那我們要如果稱呼這些東西? 以官方的文件續述來看,會叫這些東西為 Element,中譯元素參考 Microsoft 語言入口網站),這個名稱也符合較為新的語言如 JavaScript, Python 等對其資料結構內容物的稱呼, Clojure 在很多名稱上去除了古老 LISP 的用詞。

再來讓我們看看這些元素,元素有型態和值,比方說 1 表示他是值為 1Integer,Clojure 在程式語言的分類中,是屬於 強型別 的語言, 也就是在運算中不會任意幫使用者更動原本元素該有的型態,一定要轉型才可以,如以下例子就會出錯:

(+ "number = " 1)

而以下例子則正確:

(str "number = " 1)

原因是 str Function 有把進來的參數轉成字串。

因為 Clojure 寄身在 Java 上,在 Clojure 其實也可以用 Java Object 的方式定義資料,但目前我們只先關心 Literal Data Type, 所謂 Literal,是用特定符號表達程式中資料的寫法, 如前面的例子中 1 表示 Integer,"nuber = " 表示 String。

Numeric

在 Clojure 中,Literal 的寫法可以表示的數字型態有:

  1. Integer
  2. Float Point
  3. Big Number
  4. Ratio

Character, String And Regular Expression

Symbol & Keyword

註解

註解不是程式碼,是為了在解式碼間記錄一些資訊,可能是程式的說明。 Clojure 的註解用 ; Semicolon 做為開頭,在 ; 後面的字串都不會被當做程式碼。

一般在 Clojure 的程式碼中,常常會看到 ;;;;;; 這三種樣子的註解開頭,在慣例上只是為了表示三種不同階級的註解,功能上並沒有差別。

  • ; 表示單行註解
  • ;; 表示某個程式區塊的註解
  • ;;; 表示檔案的註解

LISP FORM

在 Clojure 中,一段程式的寫法為:

(str "Hello" " " "World" "!")

而一組 List Collection 的資料結構為:

'(str "Hello" " " "World" "!")

雖然都是用小括號的寫法( ),但差異在左括號前有沒有 Quote ('),若左括號前有 ' 時, 該 List 不會被 Eval,也就是不會被當成程式碼執行,'( <任意元素> ) 的這個寫法也可以用當成程式碼寫的方法:

'(1 2 3 4 5)

Quote Special Form 也會得到和使用 ' 相同結果:

(quote (1 2 3 4 5))

'( )(quote ( )) 的簡易寫法,正式名稱為 Quote Special Form,是 Special Form 的其中一種, 而相對於 Special Form 的就是 Lisp Form 了。

左括號右邊的第一個東西會被當成 Function,第二個之後的東西會被當成參數的輸入進到 Function 中, 這是 LISP 的基本規則,也就是 LISP Form:

(+ 1 2 3 4 5)

這樣寫會把 1 當成 Function,2 3 4 5 當成參數,想當然會出錯,因為 1 不是 Function

(1 2 3 4 5)

LISP Form 再更精確說明,應該是 對一個 List 求值(Eval), 假設有一個 List 如下:

(* 2 3)

括號中,第一個元素為*,第二個和第三個元素為 23,當 Clojure 對這個 List 求值時會有以下步驟:

  1. 確認第一個元素是否為 Function,在本例是中 * 是一個 function,因此進行 LISP Form 的規則。
  2. 對第二個元素求值,得到 2
  3. 對第三個元素求值,得到 3
  4. 第二和第三個元素求值的結果作為輸入參數到第一個元素所表示的 Function
  5. 整個 List 求值的結果為 6

也許你會有點疑惑:“為什麼一個數字 2 還需要被求值,他不是看起來就是 2 嗎?”,會有這個問題也是正常的,在其它語言中沒有這種“求值”的動作, 在 Clojure (或者說 LISP 系的語言)中會有一個求值動作的原因是,在 Compile 前還會有一個 Reader 解析 List, Reader 看到有加 ' 的 Symbol 或 List 才會知道不需要對他們求值。

另外,像是 2 這樣的基本元素 ( 2 是 Integer),對它們求值還是會得到它們本身:

(eval 2) ; => 2

p.s. eval 除了表達 Evaluate 這個外,也是一個可以用的 Function。

再來一個更複雜的例子:

(+ 1 (* 2 3) 4 5)

求值步驟如下:

  1. 確認第一個元素是否為 Function,在本例是中 + 是一個 Function,因此進行 LISP Form 的規則。
  2. 對第二個元素求值得到 1
  3. 對第三個元素求值...這時候問題來了,第三個元素是一個 List:
    (* 2 3)
    
    這時 Clojure 該怎麼做? Clojure 會優先對這個 List 求值,完成後才會對下一個元素求值,求值的結果為 6
  4. 對第四個元素求值得到 4
  5. 對第五個元素求值得到 5到步驟 5. 時,原 List 變成:
    (+ 1 6 4 5)
    
  6. 元素二到五當為 Function + 的參數求值結果為 16

在這個例子不難看出,LISP Form 的 Eval 順序是 Depth-First:

depth-first

LISP Form 是 Clojure 的基本規則,LISP 的其中一個 動作 (Verb), 除了 LISP Form 外,在接下來還會介紹到 Special FormMacro

List

前面已經介紹過 LISP FormQuote Special Form,到此我們已經理解在 Clojure 中如果不想對資料求值, 只想表示該資料為一個 List 時,只要加上 Quote即可,也就是說的作用就是不求值(Delaying evaluation), 從這點不難理解為什麼 LISP 世界的人總是在說 Code is data, data is code., Data (list) 就加 Quote,Code (eval) 不加 Quote,也因為這個特性,了解怎麼操作 List 就變得相當重要。

一個操作 List 的例子:

(eval (cons '* (rest '(+ 1 2 3 4 5))))

depth-first-2

'(+ 1 2 3 4 5) 因為加了 ' (Quote),因此這是一組資料 (List), rest 是我們要介紹的第一個 list 操作,功能是取出 list 中,除了第一個元素之外的其它元素,得到新的 list 回傳:

(rest '(+ 1 2 3 4 5))

cons 也是 list 操作,功能是幫 list 加入新元素,要注意的是第一個參數是新元素,而第二個參數才是 list:

(cons '* '(1 2 3 4 5))

另外要注意的是因為 list 特性是一個串一個(Linked List),新元素放到第一個成本最小,所以前例的結果才會是 (* 1 2 3 4 5)

最後到了 eval,被 Eval 的 list 為:

(eval '(* 1 2 3 4 5))

原本的 + 經過了 restcons 後,被轉換成 *,對 Clojure 來說, 程式碼可以很輕易的操作成另一個樣子,而這還不是 Clojure 最強大的特性,但到這邊我們先就此打住,優先關心 list 的操作。

Functions of List

以下是常用的幾個 Function:

  • first
  • rest
  • last
  • butlast
  • cons
  • conj
  • count
  • reverse
  • map
  • filter
  • reduce

firstrestlastbutlast 這四個 Function 可以視為同一組:

TODO 補圖



consconj 都是可以加入新元素的 Function,cons 請把它想成是 first 和 rest 的反向操作,假設原 list 為 (1 2 3 4 5), first 的結果為 1,rest 的結果為 (2 3 4 5),cons 就是將 1(2 3 4 5) 合併回去的方法:

(cons 1 '(2 3 4 5))

conj 可以把它想成是多次的 cons,cons 的參數只有兩個,first 的元素和 rest 的 list,在 conj 中,第一個參數就是 list 了,而接下來的參數則是元素:

(conj '(2 3 4 5) 1 0 -1 -2)

如果 conj 只給一個元素,那結果會和 cons 一樣:

(conj '(2 3 4 5) 1)

p.s. cons 是 Clojure 中少數和以前 LISP 名稱和功能也一樣的 symbol,它是 construct 的縮寫。



count 回傳 list 中有多少個元素:

(count '("Apple" "Orange" "Banana" "Churry"))



reverse 將 list 的內容順序反轉:

(reverse '(1 2 3 4 5))

mapfilterreduce 這三個 Function 應該是最近 Functional Programming 正夯的清況下, 己經廣為人知的 Function 吧(這都要感謝 JavaScript),在 Twitter 有一則很有趣的貼文用了 Emoji 來比喻這三個 Function 的行為(雖然是 JavaScript 的例子):

JavaScript 中用 [] 的資料結構叫 Array,在這邊我們用 Collection 表示泛指非 Key-Value 類型的資料結構, JavaScript 的 Array 和 Clojure 的 List 都用 Collection 來代稱。

map 的例子中,(🌽 🐮 🐔) 經過 cook 這個 Function 之後(先不管 cook 的實作是什麼), 最後得到一個新的 collection (🍿 🍔 🍳)map 的行為就是把每個元素都放到 cook 中並把結果收集起來:

  1. 🌽 -> cook(🌽) -> 🍿
  2. 🐮 -> cook(🐮) -> 🍔
  3. 🐔 -> cook(🐔) -> 🍳

Clojure 的 map 和 Twitter 這個 JavaScript 的例子有點不一樣的地方在 map 的參數位置,在 JavaScript 中, Collection 是放在第一個參數,Function 放第二個,而 Clojure Function 放在第一個參數,Collection 才是放第二個參數:

(map inc '(1 2 3 4 5))

inc Function 的功能是將數字加 1,(1 2 3 4 5) 中每一個數字經過 inc 後, 得到新的 list (2 3 4 5 6)



filter 的 Function 和 map 不太一樣,map 並不限制 Function 回傳的類型,而 filter 的 Function 參數是要求回傳 Boolean, 而只有回傳值為 true 的元素才會被收集到新的 Collection,就拿 Twitter 的那個例子來說,Function isVegetarian 是判斷是否為素食(這個例子是蛋奶素), 而只有爆米花和蛋符合,所以新的 Collection 只有 (🍿 🍳)

  1. 🍿 -> isVegetarian(🍿) -> true
  2. 🍔 -> isVegetarian(🍔) -> false
  3. 🍳 -> isVegetarian(🍳) -> true



reduce 可能是這三個 Function 中對初學者最難理解的一個了,而就 Twitter 上那個 JavaScript 的範例來說,它其實不是一個好例子, 原因是 reduce 的 Function 參數有兩個參數,第一個位置的參數通常叫 accumulator ,第二位置叫 elementcurrent value, 當然 reduce function 的參數可能因為不同語言和不同 Library 有所差異,例如 JavaScript 的 Array reduce 中的 reduce function 就多了第三個位置的參數叫 current index , 但不影響 redure 最重要的目的:把 Collection 內的元素傳入 reduce function 中,取得新的值再將該值和下一個元素傳入 reduce function,重複這個動作直到沒有下個元素, 就 Twitter 的例子來看:

  1. eat(null, 🍿) -> 💩
  2. eat(💩, 🍳) -> 💩
  3. 💩

步驟 2. 回傳和原本的 accumulator 相同,還是一個 💩,因為 Emoji 中沒有更大的 💩,這個例子有趣但有點失真,雖然有這一點點小缺點,以解理 reduce 來說算是一個不差的例子了。

Clojure 的 reduce 也和 mapfilter的差不多,functino 在前,list 在後,過 reduce 有一個情況是 accumulator 的起始值,因此 reduce 有兩種情況:

  1. (reduce f coll)
  2. (reduce f val coll)

f 表示 function,coll 表示 Collection,在這邊就是 list ,而 val 為要設定給 accumulator 的起始值,以下例子:

(reduce + '(1 2 3 4 5))

設定起始值的例子:

(reduce + 10 '(1 2 3 4 5))

沒有設定起始的情況下,完全依賴 reduce function 怎麼做,以 + 這個 function 來看,沒有起始值,所以 accumulator 為 nil,element 為 1, 在 + 實作細結中如果第一個參數是 nil,也只是把它當成 0 而己:

(+ nil 1)

Collection

Sequencetial Collection

Hashed Collection

Function

Special Form

LISP FORM 的部份有提到 Quote Special Form,而 Quote Special Form 的功能是跳過求值, 在 Clojure 中 Special Form 是語言內建的定義,我們不能定義新的 Special Form,可以把他們當 Clojure 基本的語法來看待, 其本的 Special Form 有:

  1. def
  2. if
  3. do
  4. let
  5. quote
  6. fn
  7. loop & recur

另外還有幾個暫不說明的:var、try、throw、monitor-enter、monitor-exit。

既然叫 "Special Form",那當然不會是 LISP Form 那樣 Depth-First 的 Eval 規則了, 有像我們己經看過的 Quote Special Form,反而是不 Eval list,也有像 if 的程式分枝, 還有 deflet 用來定義 symbol,Special Form 是 Clojure 中的基本功能,只要有這幾個基本的 Special Form, 整個 Clojure 的生態就能用 Macro 和 Function 建構出來。

1. def

def 是 define 的意思,Clojure 文件中寫了:(def symbol doc-string? init?) 該開始看 Clojure 的文件可能不太了解這是什麼意思,在 Clojure 的文件常常會有這種“黑話”,在這邊先解釋一下這個黑話的意思:

  1. def 就是我們想了解的這個 Special Form 名稱,不需要再特別說明。
  2. symbol 表示這個元素要放的是一個合法的 symbol,忘了 symbol 是什麼可以回到 Symbol & Keyword
  3. doc-string? 表示這個元素會被當成是 Document,而它是一個 string,加了 ? 表示它可有可無。
  4. init? 表示這是一個初始值,沒有特定型態,因為最後也是一個 ?,表示它可有可無。

Clojure 官方的文件風格一開始可能不是很能理解,常常還是會遇到不清楚該放什麼東西當參數, 所以 Clojure 的使用者還有另一個地方可以看說明:ClojureDocs/clojure.core/def, ClojureDocs 是一個社群建立的文件網站,最主要的功能是還有提供 Clojure 社群使用者們分享的範例。

回到 def,以下是 def 不同參數情況的範例: 如果只有一個參數 symbol 時,是在 Clojure runtime 中宣告該 symbol 有被定義,但值會是 nil:

(def gdp)

當只給兩個參數時,第二個參數會被當成是該 symbol 的初始值:

(def gdp 0)

當給三個參數時,第二個參數為說明文件而不是初始值,第三個參數才是:

(def gdp
  "在描述地區性生產時稱本地生產總值或地區生產總值,是一定時期內(一個季度或一年),
一個區域內的經濟活動中所生產出之全部最終成果(產品和勞務)的市場價值(market value)。"
  0)

doc-string 的功用:

(doc gdp)

doc 是用來看各 symbol 寫好的 doc-string,在開發中, 常常會在 REPL 介面上用這個 Function 看 Clojure 的文件(不論是 Clojure 官方的還是第三方的,只要有寫 doc-string 都可以看到說明文字)。

如果給三個參數,但第二個參數不是字串時,Clojure 會出錯:

(def gdp
    123
  0)

2. if

if 是用來產生程式分枝的 Special Form,文件格式的說明為:(if test then else?), if Special Form 會先對 test 求值,看結果是否為,如果為真,會對 then求值, 不為真則對 else? 求值,當然在前面提過,else? 有 ? 表示它可有可無,所以如果 test 不為真, 也有可能不再做任何求值。

那為什麼 test 不寫成 perd (predict function) 就好?原因是在 if 的 test 是否為真是一個比較特別的判斷方式, 並不只是 Boolean 值而已,以下的值都為

不為真 的有:

3. do

do 在文件中格式說明為:(do expr*),其中 expr 的意思為 s-expression,結尾加 * 表示有 0~n 個, 這種表示法很像 Regular Expression 的寫法,do 的效果是對這些 s-exp 求值,最終回傳值為最後一條 s-exp 的回傳。

以下例子:

(do
  "I can show you the world,"
  (range 100)
  (def locale (nth ["嘉義" "雲林" "台南" "高雄"] (rand-int 4)))
  (def gdp (rand-int 100000))
  (if (and (= locale "高雄")
           (> gdp 64000))
    "發大財"
    "北漂"))

4. let

let 在文件中的格式說明為:(let [bindings*] exprs*),let 的結構比較特別一點, [bindings*] 的意思是一組 vector,內容是 symbol 的 binding,例如:

[a 0
 b "hello"]

binding 後,symbol ab 就可以在 let 中的 s-exp 中存取到 ab,完整的例子:

(let [var1 "value1"
       var2 "value2"]
   (str var1 " " var2))

但要注意的是,在 let 外,var1 和 var2 無法被求值:

var1
var2

5. quote

quoteLISP Form 中已經提過了,在文件中的說明為 (quote form)form 表示不管什麼東西都是可以放到 Quote Special Form 中的,quote 的功能就是不讓這些 form 被 eval。

以下這個 s-exp 會被當成一個 List:

(quote (* 17 (+ 8 9)))

不管什麼東西都可以被 quote:

(quote 🙂)

6. fn

fn 其實就是 Clojure 的 lambda function,寫法為:

(fn name? [params] exprs) (fn name? ([params] exprs) +) 其中 fn 的 name 不是為了給其它 s-exp 使用的名稱,它是為了做 recursive 的名稱。 e.g.

(fn [] "I'm no one!")
(fn fn-name [para1]
  (str "Hello, " para1 "!"))
(fn-name "peter")                       ; <- 會跳 exception
                                        ; 找不到 fn-name 這個 function
;; 用 fn 定義一個 function, 加入參數 name (命名 power)
(fn power [n e]
  (if (zero? e)
    1
    (* n (power n (dec e)))))

fn 外會找到名叫 "power" 的 function 理由是 fn 的 name? 參數是用來給 lambda 內做 recursive 用的 (power 2 10) ; 找不到 power 這個 function

如果想要用 lambda function,就是再加一層括號,並加上 function parameter。 ((fn ,,, ,,,) ,,, ,,, )

  ^         ^   ^ 
1st exp 後面的 exp 會被 eval 後做為 1st exp 的 parameter 第一個 s-exp 就算是 lambda function 也依然符合 LISP form 的 Eval 規則 ((fn power [n e] (if (zero? e)
 1
 (* n (power n (dec e))))) 2 10) 
TODO def , defn (defn function-symbol [] (function body))

Literally Lambda Lambda 有一個寫法為 hash + 小刮號 : #(exp*)

#(,,,)
 

如果只有一個參數,'%' 用來表示該參數 (#(str "Hello, " %) "Dave")

(#(str "I'm sorry " % ", I'm afraid I can't do that.") "Dave")

;; 如果有多個參數,%N (N表示數字) 能依序代表各參數 (#(list %2 %1 %3) "🙈" "🙉" "🙊")

7. loop & recur

如果有學過其它語言: for ( 起始變數 ; 終止條件 ; 變數變化 ) { }

(loop [起始變數]

(if (終止條件)
  回傳值
  (recur 變數變化))) 
e.g.
(loop [n 10]
  (if (< n 0)
    "BOOM!"
    (do
      (println n "!")
      (Thread/sleep 100)
      (recur (dec n)))))
((fn loop1 [n]
   (if (< n 0)
     "BOOM!"
     (do
       (println n "!")
       (Thread/sleep 100)
       (loop1 (dec n))))) 10)

loop 如果沒有 recur (loop [n 10] (println n "!")) ; 結果是不會重覆執行

                                    ; 也就是說 loop 一定要和 recur 一起使用 

What Macro ?

Flow Control

Side Effect

重新回顧 Clojure Verbs:LISP Form、Special Form、Macro

結語

到這邊為止,相信讀者應該對 Clojure 有基本的認識了,但要拿 Clojure 做一些有用的東西,還缺少了寫程式的環境,和管理專專的工具。

在自己的電腦安裝 Clojure 的環境請參考:

編輯器的部份請參考:

有關 Clojure 進階的功能請參考:

Code Block 的小袐密

其實本文中用到的能處理 Clojure 語法的 Code Block 並不是 Clojure 而是 ClojureScript, ClojureScript 是一個在 JavaScript 環境上跑的 Clojure,但基本語法和 Clojure 相同的, 不影響本文中 Clojure 的範例結果。

會一個 Clojure 語法就可以寫前後端,那當然會有 ClojureScript 的教學啦: