流程控制是枝幹、河流與道路,將如同樹葉、土地與城市一樣的函式連結起來,藉由流程控制,程式可以選擇行走的方向,前進後退、左右轉或是不斷反覆。
本篇文章將介紹 Clojure 中流程控制的方法,其中出現的運算式與函式大不相同,它們只在需要的時候才會被求值,而不像函式在呼叫之前,所以參數必須完成求值。
首先要介紹的,也是在前面就提到過的 if
運算式。if
運算式接收三個參數,第一個參數運算式只要求值結果爲真,則會對第二個參數運算式求值,否則會對第三個運算式求值,第三個運算式可以提供也可以不提供。Clojure 中除了 nil
與 false
之外都會視爲真:
(if 42 "answer")
;; => "answer"
(if false "answer")
;; => nil
(if "hi" "say hi" "say no")
;; => "say hi"
(if true "it's true" "it's false")
;; => "it's true"
(if false "it's true" "it's false")
;; => "it's false"
如果條件測試爲假,卻沒有提供第三個參數運算式 (else 部分),結果會是 nil
。
由於 if
運算式中的第二與第三個參數,只能允許是一個運算式,如果需要放置兩個運算式以上的話則需要將多個運算式以 do
包覆起來,將會逐個求值,結果爲 do
中最後一個運算式求值的結果:
(if true
(do
(println "Success")
"It's true")
(do
(println "Fail")
"It's false"))
;; => Success
;; => "It's true"
if-not
是 if
的反面,如果 if-not
的條件測試爲真,則會對第三個參數運算式求值,否則會對第二個參數求值:
(if-not true "it's true" "it's false")
;; => "it's false"
(if-not false "it's true" "it's false")
;; => "it's true"
when
是少了 else 部分的 if
運算式,when
接受一個測試函式,如果測試函式返回真,則會對 when
的本體運算式求值後返回其值,反之則回傳 nil
。
(when false "nothing")
;; => nil
(when true "anything")
=> "anything"
而 when-not
就是 when
的反面,如果接收的測試函式返回假,便對本體運算式求值後返回其值,反之則回傳 nil
。
(when-not (> 5 2) "Five")
;; => nil
(when-not (> 2 5) "Two")
;; => "Two"
如果你想要將測試函式的返回值記起來,以便之後使用,可以用 let
搭配 if
達到此功能:
(let [is-small (< 5 100)]
(if is-small
"smaller"
"bigger"))
;; => "smaller"
Clojure 提供了簡便的寫法將兩者整合在一起,有 if-let
與 when-let
兩個版本可用:
(if-let [is-smaller (< 5 100)]
"smaller"
"greater")
;; => "smaller"
(when-let [is-greater (> 100 5)]
"greater")
;; => "greater"
cond
運算式類似於其他語言中的 switch-case
或 if-elsif
,它接受一對對運算式,每對運算式都有條件式,以及當條件式成立時,待求值的運算式。它會根據每對運算式,照順序對各個條件式一一測試,只要有一條件式爲真,則求值對應的運算式,就不會再往下求值:
(let [x 1]
(cond
(> x 0) "greater"
(= x 0) "zero"
(< x 0) "smaller"))
;; => "greater"
從以上範例可以看到,每個條件判斷式之後都跟着一個運算式,依序對條件判斷式求值,若爲真則對之後的運算式求值,而不再繼續。
你可以在 cond
的最後一對運算式中,將條件判斷式擺放非 nil
及 false
的值,當前面的條件判斷式都失敗時,便會執行最後一段運算式,用來當作其他條件都失敗的預設值:
(let [temperature 20]
(cond
(> temperature 30) "Hot"
(< temperature 15) "Cold"
:default "Normal"))
;; => "Normal"
case
與 cond
非常類似,case
會以第一個參數,與之後成對的運算式中的第一個運算式相比較,若相等則回傳之後的運算式求值的結果;若沒有任何一個相等,將丟出 IllegalArgumentException 例外:
(let [color "red"]
(case color
"red" "Rose"
"white" "Paper"
"Blue" "Sky"))
;; => "Rose"
(let [capital "Canberra"]
(case capital
"Dublin" "Ireland"
"Cairo" "Egypt"
"Tokyo" "Japan"))
;; => IllegalArgumentException No matching clause: Canberra
由於 Clojure 中的資料結構都是不可變 (Immutable),所以沒有主流程式語言的 for 迴圈,因爲 for 迴圈需要在每次迭代 (Iteration) 修改變數以達到迭代的功能。透過遞迴與函式也能夠做到迴圈的功能。
如果想要遍歷序列,在每次取得序列中的元素時,就執行一次程式,可以使用 doseq
。doseq
接受一個向量跟運算式本體,向量中以類似 let
方式命名一個符號,每次循環繫結序列中的元素:
(doseq [x [1 2 3]]
(println x))
;; => 1
;; => 2
;; => 3
;; => nil
每次迭代時,符號 x
繫結了元素的內容。
doseq
在遍歷元素時還可設定條件修飾子 (Modifier),決定何時對遍歷時的運算式求值、設定迭代的終止條件、以及在每次遍歷中繫結符號作爲使用:
(doseq [x (range 5)
y [10 20 30]
:let [z (* x y)]
:when (odd? x)]
(println x y z))
;; => 1 10 10
;; => 1 20 20
;; => 1 30 30
;; => 3 10 30
;; => 3 20 60
;; => 3 30 90
;; => nil
以上範例中,x
爲 0 到 4,y
爲 10 20 30,每次迭代還會設定符號 z
爲 x
與 y
相乘的值。迭代時當 x
爲奇數才會運行 (println x y z)
。
以下範例則示範使用 :while
關鍵字,設定迭代停止條件。當 y
大於或等於 30 時,迭代便終止 :
(doseq [x (range 99)
:let [y (* x x)]
:while (< y 30)]
(println [x y]))
;; => [0 0]
;; => [1 1]
;; => [2 4]
;; => [3 9]
;; => [4 16]
;; => [5 25]
;; => nil
dotimes
與 doseq
類似,第一個參數的向量中包含符號與數字,數字代表迭代的次數,符號則繫結了每次迭代的次數。迭代的次數爲 0 到 (n - 1)。
(dotimes [x 3]
(println x))
;; => 0
;; => 1
;; => 2
;; => nil
while
非常類似於在其他命令式 (Imperative) 程式語言的兄弟,例如 Ruby 或 Java。while
接受一個測試運算式與本體運算式,當此運算式求值結果爲假時,才會終止對本體運算求值的循環。
(def x (atom 5))
(while (> @x 0)
(do
(println @x)
(swap! x dec)))
;; => 5
;; => 4
;; => 3
;; => 2
;; => 1
nil
以上範例中的 x
爲 Clojure 引用型態 (Reference type) 中的原子 (Atom) 型態,使用 atom
與參數建立原子型態,內容爲 5。若是想要取用原子型態中的資料,須使用 @
或 deref
才可取用到內容。引用型態將會在後續文章詳細解說。
範例的內容爲每次迭代都會檢查 x
是否大於 0,若否則印出其值並將 x
指向的值遞減。
以上提到的控制迭代循環的 doseq
、dotimes
以及 while
都是利用 Clojure 迭代的基本元素 loop
與 recur
來達成。loop
特殊形式 (Special form) 利用類似 let
語法來繫結循環時會用到的符號與資料的對應,之後包含了循環時運行的本體運算式,recur
運算式則設立下次循環時使用到的新值。以下範例使用 loop
與 recur
示範倒數的功能:
(loop [n 5] ; 1
(if (zero? n)
n ; 2
(do
(println n)
(recur (dec n))))) ; 3
;; => 5
;; => 4
;; => 3
;; => 2
;; => 1
;; => 0
-
loop
在此建立了n
的資料繫結,內容初始爲 5。 -
此處設立了當到達終止條件 n 爲零時,將執行的運算式。
loop
的返回值即爲此運算式求值後的結果。 -
最後在此處
recur
設立了下次循環時,loop
已建立的符號n
的新值,在這裡爲 n - 1。設定完成後,n 將會以新的值從1
處重新開始循環。
由於 JVM 缺乏尾遞迴 (Tail Recursion) 的最佳化功能,以及遞迴時會消耗記憶體的問題,Clojure 中的使用慣例是利用 loop
與 recur
達成迭代的功能。
recur
除了可以返回 loop
設立的遞迴點 (Recursion point) 之外,也可以返回由函式定義產生的遞迴點。以下範例使用 defn
與 recur
來實作倒數功能.
(defn countdown [x]
(if (zero? x)
x
(do
(println x)
(recur (dec x)))))
;; => #'user/countdown
(countdown 3)
;; => 3
;; => 2
;; => 1
;; => 0
Clojure 中的 for
與一般程式語言的 for
不同,它利用一個群集作爲來源,運用運算式以及條件式產生新的群集,這稱作列表推導 (List comprehension)。它接受類似 let
繫結綁定的方式,在向量中以符號繫結群集,之後的本體運算式將陸續被代入群集中的元素,本體運算式每次求值的結果則放入新的群集中,運行完畢後返回新的群集。
以下範例示範由代入的向量,產生向量中元素以及它的倍數組成一對的新向量:
(for [x [1 2 3 4 5]] [x (* x x)])
;; => ([1 1] [2 4] [3 9] [4 16] [5 25])
前面曾提到 doseq
運算式有幾個條件修飾子 (Modifier) 可以調整產生的結果,其實 doseq
是依靠 for
打造出來的,doseq
的修飾詞都是繼承自 for
,三種條件修飾子都是 for
提供給 doseq
。讓我們來看看 for
使用條件修飾子的樣貌:
(for [x (range 5)
y (range 5)
:let [z (+ x y)]
:when (.isProbablePrime (BigInteger/valueOf z) 5)]
(list x y))
;; => ((0 2) (0 3) (1 1) (1 2) (1 4) (2 0) (2 1) (2 3) (3 0) (3 2) (3 4) (4 1) (4 3))
以上範例中 x
與 y
分別代表內容包含 0 到 4 的列表,當兩個向量中的元素相加起來爲質數,便將兩元素放入新群集裡。範例中示範了如何使用 Java 靜態方法與呼叫 BigInteger 的執行個體方法。與 Java 的溝通將於後續的文章中詳細介紹。
Clojure 語言之中有非常多的巨集 (Macro),它擴充了語言基本提供的功能,帶來了易用與方便,在後續文章中將會有詳細的介紹。這裡先介紹的巨集,它爲程式碼帶來可讀性,稱爲穿引巨集 (Threading Macro)。
假設 Clojure 工程師年薪的 5 % 會拿來買書,其中的 30 % 則用來購買技術相關書籍,每年花在購買技術書籍的錢,可以用以下公式計算出來:
spending = ((salary * 0.05) * 0.3) * year
改寫成 Clojure 程式碼後,計算十年花費了多少錢:
(defn spending [salary year]
(* (* (* salary 0.05) 0.3) year))
(spending 10000 10)
;; => 1500.0
看起來運作的不錯,但是計算花費的函式本體一層又一層的計算,真不容易閱讀。首位穿引巨集 (Thread-first macro) 正是解決這個問題的好方法。首位穿引巨集的寫法看起來就像是箭頭,由一個橫線 (-) 加上大於符號 (>) 組成,首位穿引巨集中的第一個參數運算式求值完成後,結果會傳遞給下一個參數運算式的第一個參數。以下是改用首位穿引巨集之後的結果:
(defn spending [salary year]
(-> (* salary 0.05)
(* 0.3)
(* year)))
(spending 10000 10)
;; => 1500.0
一圖勝千言:
+-------------------+
| |
| * salary 0.05 +-----------+
| | |
+-------------------+ |
+-----+---v----+-----+
| | | |
| * | | 0.3 +-----------+
| | | | |
+-----+--------+-----+ |
+-----+---v---+------+
| | | |
| * | | year |
| | | |
+-----+-------+------+
修改過後的程式碼變得更容易閱讀。前一個運算式的結果,接上下一個運算式的第一個參數的位置,一目瞭然。
首位穿引巨集是將結果放在下一個運算式的第一個參數,而末位穿引巨集 (Thread-last macro) 則是將結果放在下一個運算式的最後一個參數,寫法爲一個橫線 (-) 加上兩個大於符號 (>)。
修改前的運算式如下所示:
(take 5 (map #(+ % 2) (range 10)))
;; => (2 3 4 5 6)
使用末位穿引巨集改寫之後:
(->> (range 10)
(map #(+ % 2))
(take 5))
;; => (2 3 4 5 6)
一圖勝千言:
+-------------+
| |
| range 10 +---------------------+
| | |
+-------------+ |
+-----+----------+--v--+
| | | |
| map | #(+ % 2) | +---------------+
| | | | |
+-----+----------+-----+ |
+------+---+--v--+
| | | |
| take | 5 | |
| | | |
+------+---+-----+
通過本篇文章,你了解了如何使用條件式來決定應該對哪個運算式求值,也了解到達成循環迭代功能的方法;還知道了強大的列表推導,可以生成列表。使用穿引巨集則可以讓程式易於閱讀和理解。
還不賴吧?今天就先到這裡,下一篇文章再見囉!