Skip to content

Files

Latest commit

 

History

History
395 lines (307 loc) · 14.1 KB

04.md

File metadata and controls

395 lines (307 loc) · 14.1 KB

流程控制

流程控制是枝幹、河流與道路,將如同樹葉、土地與城市一樣的函式連結起來,藉由流程控制,程式可以選擇行走的方向,前進後退、左右轉或是不斷反覆。

本篇文章將介紹 Clojure 中流程控制的方法,其中出現的運算式與函式大不相同,它們只在需要的時候才會被求值,而不像函式在呼叫之前,所以參數必須完成求值。

條件式

if, if-not

首先要介紹的,也是在前面就提到過的 if 運算式。if 運算式接收三個參數,第一個參數運算式只要求值結果爲真,則會對第二個參數運算式求值,否則會對第三個運算式求值,第三個運算式可以提供也可以不提供。Clojure 中除了 nilfalse 之外都會視爲真:

(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-notif 的反面,如果 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, when-not

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"

if-let, when-let

如果你想要將測試函式的返回值記起來,以便之後使用,可以用 let 搭配 if 達到此功能:

(let [is-small (< 5 100)]
  (if is-small
    "smaller"
    "bigger"))
;; => "smaller"

Clojure 提供了簡便的寫法將兩者整合在一起,有 if-letwhen-let 兩個版本可用:

(if-let [is-smaller (< 5 100)]
  "smaller"
  "greater")
;; => "smaller"
(when-let [is-greater (> 100 5)]
  "greater")
;; => "greater"

cond

cond 運算式類似於其他語言中的 switch-caseif-elsif,它接受一對對運算式,每對運算式都有條件式,以及當條件式成立時,待求值的運算式。它會根據每對運算式,照順序對各個條件式一一測試,只要有一條件式爲真,則求值對應的運算式,就不會再往下求值:

(let [x 1]
  (cond
    (> x 0) "greater"
    (= x 0) "zero"
    (< x 0) "smaller"))
;; => "greater"

從以上範例可以看到,每個條件判斷式之後都跟着一個運算式,依序對條件判斷式求值,若爲真則對之後的運算式求值,而不再繼續。

你可以在 cond 的最後一對運算式中,將條件判斷式擺放非 nilfalse 的值,當前面的條件判斷式都失敗時,便會執行最後一段運算式,用來當作其他條件都失敗的預設值:

(let [temperature 20]
  (cond
    (> temperature 30) "Hot"
    (< temperature 15) "Cold"
    :default "Normal"))
;; => "Normal" 

case

casecond 非常類似,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

如果想要遍歷序列,在每次取得序列中的元素時,就執行一次程式,可以使用 doseqdoseq 接受一個向量跟運算式本體,向量中以類似 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,每次迭代還會設定符號 zxy 相乘的值。迭代時當 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

dotimesdoseq 類似,第一個參數的向量中包含符號與數字,數字代表迭代的次數,符號則繫結了每次迭代的次數。迭代的次數爲 0 到 (n - 1)。

(dotimes [x 3]
  (println x))
;; => 0
;; => 1
;; => 2
;; => nil

while

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 指向的值遞減。

loop/recur

以上提到的控制迭代循環的 doseqdotimes 以及 while 都是利用 Clojure 迭代的基本元素 looprecur 來達成。loop 特殊形式 (Special form) 利用類似 let 語法來繫結循環時會用到的符號與資料的對應,之後包含了循環時運行的本體運算式,recur 運算式則設立下次循環時使用到的新值。以下範例使用 looprecur 示範倒數的功能:

(loop [n 5]                ; 1
  (if (zero? n)
    n                      ; 2
    (do
      (println n)
      (recur (dec n)))))   ; 3
;; => 5
;; => 4
;; => 3
;; => 2
;; => 1
;; => 0
  1. loop 在此建立了 n 的資料繫結,內容初始爲 5。

  2. 此處設立了當到達終止條件 n 爲零時,將執行的運算式。loop 的返回值即爲此運算式求值後的結果。

  3. 最後在此處 recur 設立了下次循環時,loop 已建立的符號 n 的新值,在這裡爲 n - 1。設定完成後,n 將會以新的值從 1 處重新開始循環。

由於 JVM 缺乏尾遞迴 (Tail Recursion) 的最佳化功能,以及遞迴時會消耗記憶體的問題,Clojure 中的使用慣例是利用 looprecur 達成迭代的功能。

recur 除了可以返回 loop 設立的遞迴點 (Recursion point) 之外,也可以返回由函式定義產生的遞迴點。以下範例使用 defnrecur 來實作倒數功能.

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

以上範例中 xy 分別代表內容包含 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 |     |
                                         |      |   |     |
                                         +------+---+-----+

回顧

通過本篇文章,你了解了如何使用條件式來決定應該對哪個運算式求值,也了解到達成循環迭代功能的方法;還知道了強大的列表推導,可以生成列表。使用穿引巨集則可以讓程式易於閱讀和理解。

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