跳轉到內容

Clojure 程式設計/示例

來自華夏公益教科書

這旨在成為 Clojure 的動手入門介紹。如果您希望邊學邊試示例,那麼您可能希望已經按照 https://wikibook.tw/wiki/Clojure_Programming/Getting_Started 設定了工作環境,這樣您就可以看到示例程式碼的結果。

Clojure 程式是用形式編寫的。用括號括起來的形式表示函式呼叫

(+ 1 2 3)

呼叫 '+' 函式,引數為 1 2 3,並返回 6,即引數的總和。

可以使用 defn 定義新函式

(defn average [x y] (/ (+ x y) 2))

這裡 x 和 y 是表示輸入引數的符號。呼叫函式 '/' 將 x 和 y 的總和除以 2。請注意,形式始終採用字首表示法,函式在後續引數之後。現在 average 可以像這樣呼叫

(average 3 5)

並將返回 4。在這個例子中,'average' 是一個符號,它的值是一個函式。(有關形式的詳細解釋,請參閱 http://clojure.org/reader

Clojure 提供對 JVM 的輕鬆訪問

(.show (javax.swing.JFrame.))

這在 (javax.swing.JFrame.) 的結果上呼叫 show 方法,該方法構造一個新的 Jframe。請注意方法呼叫前的句點和構造後的句點。(有關詳細資訊,請參閱 http://clojure.org/java_interop

函式可以傳遞給其他函式

(map + [1 2 3] [4 5 6])
;; Equivalent to (list (+ 1 4) (+ 2 5) (+ 3 6))

返回 (5 7 9)。Map 是一個函式,它接收另一個函式並使用從後續集合中獲取的引數呼叫它。在我們的例子中,我們提供了函式 '+' 和兩個整數向量。結果是呼叫 '+' 時使用從向量中獲取的引數獲得的結果列表。將函式用作其他函式的引數非常強大。我們可以將我們之前定義的平均函式與 map 結合使用,如下所示

(map average [1 2 3] [4 5 6])
;; Equivalent to (list (average 1 4) (average 2 5) (average 3 6))

返回 (5/2 7/2 9/2) 我們在這裡看到 Clojure 支援比率作為資料型別。(有關完整列表,請參閱 http://clojure.org/data_structures

函式也可以返回其他函式

(defn addx [x] (fn [y] (+ x y)))

這裡 addx 將返回一個新的函式,該函式接收 1 個引數並將 x 加到它。

(addx 5)

返回一個可以接收 1 個引數並將其加上 5 的函式。

(map (addx 5) [1 2 3 4 5])

返回 (6 7 8 9 10) 我們使用 addx 的結果呼叫了 map,addx 的結果是一個接收引數並將其加上 5 的函式。該函式被呼叫了我們提供的數字列表。

有一種建立匿名函式的簡寫方式

#(+ %1 %2)

將建立一個呼叫 '+' 的函式,引數為 %1 和 %2。

(map #(+ %1 5) [1 2 3 4 5])

將 5 加到我們提供的數字列表中。動態傳遞和建立函式的能力稱為一等函式。

函數語言程式設計將計算視為數學函式的求值,並避免狀態和可變資料。在命令式語言中,您通常會建立變數並定期更改它們的值。在 Clojure 中,您會返回新的結果,而不會修改之前存在的內容。

無副作用函式

[編輯 | 編輯原始碼]

函式副作用可以是更改輸入的值、更改全域性資料或執行 IO。

  • 命令式:void moveplayer( p, x, y )
使用新位置更新玩家物件
  • 面向物件:class player { void move( x, y ) }
同樣,會修改現有物件
  • 函式式:(moveplayer oldp x y)
返回一個全新的玩家,舊玩家不受影響

在命令式中,您只知道 p 發生了變化,因為函式名稱暗示了這一點。而且它可能還更改了其他內容,例如一些世界資料。在 FP 中,oldp 被保留(您無需擔心它或世界發生了什麼——什麼也無法改變),並且顯式地返回一個新的玩家作為移動的結果。

這裡的主要優勢是推理、可測試性和併發性。該語言強制執行沒有副作用,因此您可以推斷行為。輸入直接對映到輸出,這使得構建和考慮測試用例變得更容易。兩個執行緒可以在同一資料上同時操作,而無需擔心它們相互破壞,因為資料不會被更改。

不可變資料

[編輯 | 編輯原始碼]

考慮從列表中刪除一項。命令式解決方案將就地修改列表。函式式解決方案將返回一個全新的列表,保留原始列表。從表面上看,這似乎很浪費,但編譯器有很多方法可以最佳化它,使其非常高效。

對於習慣了指令式程式設計的人來說,沒有變數的程式碼可能需要一些時間來適應。以下是如何將變數樣式程式碼轉換為函式式程式碼的快速指南

您想累積一些更改

[編輯 | 編輯原始碼]
// sum odds 
int x = 0; 
for (int i=1; i<100; i+=2) { 
   x+=i;
}

將這些東西重新排列成不需要變數的形式

(reduce + (range 1 100 2))

(range 1 100 2) 建立一個 1 3 5 7 ... 99 數字的延遲序列。1 是起點,100 是終點,2 是步長。reduce 呼叫 + 函式。首先,它使用 range 提供的兩個數字作為引數呼叫 +。然後,它再次使用前一個結果和下一個數字呼叫 +,直到所有數字都被耗盡。Clojure 對序列、集合和高階操作提供了大量支援。隨著您學習它們,您會發現非常有表現力的方法來編寫此類任務。

您想迭代,而是使用 loop/recur 結構

[編輯 | 編輯原始碼]
(loop [i 5 acc 1]
  (if (zero? i)
    acc
    (recur (dec i) (* acc i))))

計算 5 的階乘。loop 特殊形式建立繫結,然後是表示式以供求值。在這個例子中,5 被繫結到 i,1 被繫結到 acc。然後 if 特殊形式測試 i 是否等於零。由於它不等於 0,因此 recur 在返回控制權到迴圈頂部以重新評估其表示式的主體之前,會重新繫結新值到 i 和 acc。遞減的 i (dec i) 被重新繫結到 i,而 acc 和 i 的乘積 (* acc i) 被重新繫結到 acc。這個迴圈遞迴呼叫,直到 i 等於 0;acc 儲存 i 接受的每個值的乘積的結果。請注意,繫結行為類似於變數。

此外,recur 可以針對 loop 或函式定義

(defn factorial 
    ([n] 
        (factorial n 1)) 
    ([n acc] 
        (if  (= n 0)   acc 
             (recur (dec n) (* acc n)))))

在上面的例子中,factorial 函式可以接收 1 個引數 [n],這會導致求值

(factorial n 1)

.

或者提供 2 個引數會導致求值

(if (= n 0) acc 
    (recur (dec n) (* acc n)))

recur 很重要,因為它會重新繫結函式的輸入,而不是在堆疊中新增一個遞迴呼叫。如果我們改為使用 (factorial (dec n) (* acc n)),我們將有類似的行為,但對於 n 的較大值,您可能會導致堆疊溢位。還要注意,我們為 factorial 引入了兩個定義,一個帶一個引數,另一個帶兩個引數。使用者呼叫一個引數版本,它會被轉換為兩個引數版本以進行求值。函式的元數是指函式接受的引數數量。

當然,我們可以寫一個更簡單的定義,類似於之前的求和奇數示例

(defn factorial [n] 
  (reduce * (range 2 (inc n))))

您需要儲存結果並多次使用它

[編輯 | 編輯原始碼]

有一個有用的宏 let,它將符號繫結到一個值以供本地使用,

(let [g (+ 0.2 (rand 0.8))] 
  (Color3f.  g g g))

在這個 let 表示式中,生成一個 0 到 0.8 之間的隨機數,加上 0.2,結果被繫結到符號 g。使用 g 的紅、綠、藍值構造顏色,這將是 0.2 到 1 的灰度強度範圍

您想對同一個物件進行多次方法呼叫

[編輯 | 編輯原始碼]

使用 Java 庫通常會讓你想要使用區域性變數。記住 doto。doto 的優點是它在應用多個呼叫後返回物件。

(doto (javax.swing.JFrame.)
  (.setLayout (java.awt.GridLayout. 2 2 3 3))
  (.add (javax.swing.JTextField.))
  (.add (javax.swing.JLabel. "Enter some text"))
  (.setSize 300 80)
  (.setVisible true))

修改永久狀態變數

[編輯 | 編輯原始碼]

Clojure 支援許多可變型別,但是瞭解它們之間的區別以及它們的行為方式很重要。提供的型別是 refs、agents、atoms 和 vars。

Refs 類似於 ML 中的 ref 單元、Scheme 中的 boxes 或其他語言中的指標。它是一個“盒子”,你可以改變它的內容。但與其他語言不同的是,你可以只在事務中進行更改。這確保了兩個執行緒在更新或訪問 ref 中儲存的內容時不會發生衝突。

(def r (ref nil))

將 r 宣告為一個 ref,初始值為 nil。

(dosync (ref-set r 5))

在事務中將 r 設定為 5。

@r

獲取 r 的值,即 5。注意 @r 是 (deref r) 的簡寫,並且適用於所有 Clojure 的可變型別。r 本身是一個 ref,而不是一個值。

Agents 透過函式非同步修改。你將一個函式傳送給 agent,它將在稍後將該函式應用於其當前值。它是非同步的,因為 send 的呼叫會立即返回。該函式被排隊到執行緒池中以供執行,提供了對多執行緒的便捷訪問。

(def a (agent 1))
(send a inc)
(await a)
@a

在這個例子中,我們定義了一個初始值為 1 的 agent。我們向 agent 傳送了一個函式 inc,它會遞增其引數。現在 send 將該函式排隊以供執行緒池執行。await 將阻塞,直到 agent 上所有待處理的函式都執行完畢。@a 返回我們 agent 的值,現在是 2,因為 1 被遞增了。

Atoms 透過函式同步修改。你呼叫 swap!,你提供的函式會在 swap! 返回之前應用於 atom 的值。

(def a (atom 1))
(swap! a inc)

注意,swap! 返回函式應用於當前 atom 值的結果。Refs 是“協調的”,而 agents 和 atoms 是“非協調的”。這意味著在多執行緒環境中,refs 在事務中被修改,以確保一次只有一個執行緒可以修改該值。而 atoms 和 agents 會將更改函式排隊,以確保更改原子地發生。它們都是“安全的”,只是使用不同的策略來提供這種“安全性”。

Vars 類似於其他語言中的全域性變數。“根繫結”是一個初始預設值,由所有執行緒共享。“繫結”結構的行為就好像 var 被修改了,但在退出繫結結構的作用域時,它會自動恢復到其以前的值。

(def something 5)

建立一個值為 5 的 Var something。宣告函式實際上將它們建立為 Vars。你應該避免使用 def,尤其要避免使用 def 設定已經宣告的繫結。隨後呼叫 (def something 6) 不是執行緒安全的操作。

“為什麼 Clojure 沒有區域性變數?”是一個經常被提出的問題。區域性修改與全域性修改一樣難以推理,與併發無關。例如,一個典型的 Java for 迴圈會設定其他區域性變數,並且包含 break/return。如果最初構建不需要變數的解決方案需要更多思考,請嘗試付出努力——它會讓你得到回報。

但是,為了支援對命令式演算法的直接翻譯,有一個有用的宏叫做 with-local-vars,它會宣告區域性變數,這些變數可以用 var-set 改變,並可以用 var-get 或 @ 作為簡寫來讀取。

(defn factorial [x]
  (with-local-vars [acc 1, cnt x]
    (while (> @cnt 0)
      (var-set acc (* @acc @cnt))
      (var-set cnt (dec @cnt)))
    @acc))

這是使用變數的階乘版本。正如你所看到的,它不像之前描述的版本那麼好,僅僅是為了演示區域性 Var 繫結。此函式在多執行緒環境中呼叫是完全安全的,因為變數是區域性的。但是區域性變數不能洩漏到其作用域之外。

(def f (with-local-vars [a 2] #(+ 2 @a))) 
(var user/f) 
(f)

導致 java.lang.IllegalStateException: Var null is unbound。原因是 f 返回一個新函式,該函式向 f 中定義的區域性變數新增 2。因此,返回的函式試圖保留 f 的區域性變數。現在區域性變數可能會發生變化,但是如果變化發生在多執行緒環境中,並且該變數洩漏到其原始作用域之外,那麼該變化將不再是區域性的。

閉包是在符號保留在其定義之外時使用的術語。

(let [secret (ref "nothing")] 
  (defn read-secret [] @secret) 
  (defn write-secret [s] (dosync (ref-set secret s))))

在這裡,我們建立了兩個函式,它們都訪問一個 ref secret。我們是在 let 內部建立它們的,所以 secret 在我們當前的作用域中不可見。

secret

導致 java.lang.Exception: Unable to resolve symbol: secret in this context

但是,函式本身保留了 secret,並可以使用它來進行通訊。

(read-secret)

結果為“nothing”。

(write-secret "hi")

結果為“hi”。

(read-secret)

結果為“hi”。注意,這些函式可能被傳遞給不同的執行緒,但仍然有效,因為 secret 是一個 ref,所以對它的訪問是透過事務控制的。

所以,讓我們編寫一個多執行緒程式。但首先,我們需要一些額外的輔助函式。

(defn random-word []
  (nth ["hello" "bye" "foo" "bar" "baz"] (rand 5)))

nth 從我們提供的單詞向量中選擇一個值,在本例中,我們呼叫 rand 來獲取 0(包含)和 5(不包含)之間的數字。你可以使用 (doc rand) 來查詢有關 rand 函式的資訊。

(defn secret-modifier [id]
  (let [after (int (rand 10000))]
  (Thread/sleep after)
  (write-secret
    (str id " whispered " (random-word) " after " after " ms")))
  id)

注意,sleep 是 java 類 Thread 的靜態方法。靜態方法和成員必須使用斜槓訪問,如所示。此函式將被“傳送”給 agent,因此它必須接受一個輸入引數(即當前 agent 值)並返回一個新值。在我們的例子中,我們不想更改 agent 的值,所以我們將返回輸入引數。

現在是多執行緒部分。

(def agents (map agent (range 4)))
(doseq [a agents]
  (send-off a secret-modifier))

我們聲明瞭四個初始值為 0 1 2 3 的 agent,並使用 send-off 來讓它們執行 secret-modifier。如果你反應足夠快,並輸入 (read-secret),你會看到 secret 正在被各個執行緒更新。10 秒後,所有執行緒將完成,因為它們會在修改 secret 之前隨機休眠 0 到 10 秒之間的時間。所以,10 秒後,secret 將不再改變,並且會類似於這樣:“1 whispered hello after 9591 ms”。

現在,我們本來可以用 java 執行緒做類似的事情。

(dotimes [i 5]
  (.start (Thread. (fn []
                     (write-secret
                       (str i " whispered " (random-word)))))))

但是 agents 有一些方便的優點。你可以檢查 agent 是否引發了異常。

(agent-errors (first agents))

或者等待所有 agent 完成執行。

(apply await agents)

函式可以透過 send 或 send-off 傳遞給 agent,這會將函式放入佇列中。send 佇列由一個執行緒池服務,該執行緒池的大小與可用 CPU 數量相同。send-off 佇列由一個執行緒池服務,該執行緒池會增長以立即提供新執行緒。在上面的例子中,我們只是想看到不同執行緒上的事情發生,所以我們使用了 send-off。但是,如果我們的目標是計算吞吐量,我們將使用 send 來執行計算任務,因為這將最佳利用我們的處理器。如果一個函式可能阻塞(比如我們的函式隨機休眠一段時間),那麼它不應該用 send 派遣,因為它會佔用“計算”佇列。

所以,讓我們編寫一個多執行緒的愚蠢洗牌器。

(dotimes [i 100000]
  (doseq [a agents]
    (send a #(+ %1 (- 2 (int (rand 5)))))))
(apply await agents)
(map deref agents)

返回類似 (245 -549 -87 -97) 的東西。在這個例子中,我們實際上修改了 agent 的值,即:使用它來儲存狀態。另外,我們使用 send 來利用適合我們系統的執行緒池。但是請注意,一個 agent 只能在一個執行緒中執行一次,因為呼叫會被排隊,以確保 agent 的值被正確修改。正如你所看到的,agents 可以用來協調狀態更改,並提供對兩個有用執行緒池的便捷訪問。

Clojure 提供了雜湊對映,在許多程式設計任務中非常有用。對映寫在 {} 之間,就像向量寫在 [] 之間,列表寫在 () 之間。

{:name "Tim", :occupation "Programmer"}

是一個將關鍵字 :name 與 "Tim" 和 :occupation 與 "Programmer" 關聯的對映。注意,逗號被 Clojure 視為空格,但可以可選地提供,以幫助直觀地對在一起的東西進行分組。關鍵字以 : 開頭,並提供了一種方便的方式來命名欄位,但鍵不必是關鍵字。

(defn map-count [map key] 
  (assoc map key (inc (get map key 0))))

此函式接受一個對映作為輸入,查詢一個鍵,並遞增該鍵被計數的次數。(get map key 0) 只查詢對映中的 key,如果找到,則返回其值,否則返回 0。inc 會在這個值上加 1。(assoc map key value) 將返回一個新的對映,其中 key 與 value 相關聯。所以正如你所看到的,對映本身並沒有被修改。返回一個新的對映,其中有一個新的值與提供的鍵相關聯。

(reduce map-count {} ["hi" "mum" "hi" "dad" "hi"])

結果為 {"dad" 1, "mum" 1, "hi" 3},因為 reduce 從一個空的對映開始,該對映用作第一次函式呼叫的輸入,然後結果對映被用於下一個函式呼叫,以此類推。字串用作鍵。現在,我們可以編寫一個更有用的函式,它接收一個字串並計算其中的單詞。

(defn count-words [s] 
  (reduce map-count {} 
          (re-seq #"\w+" (.toLowerCase s)))) 
(count-words "hi mum hi dad hi")

給出相同的結果。注意,這裡的 re-seq 根據提供的正則表示式將輸入字串拆分為單詞。

你會遇到的一件事是“對映是其鍵的函式”,這意味著。

user=> ({:a 1, :b 2, :c 3} :a) 
1

在這裡,我們建立了一個對映 {:a 1, :b 2, :c 3},然後可以像函式一樣呼叫它,引數是 :a,它會找到與該鍵關聯的值,即 1。對映和鍵透過委託給 get 來實現這個技巧,get 是一個查詢東西的函式。

user=> (get {:a 1, :b 2} :a) 
1

get 也接受一個可選的第三個引數,如果對映中沒有找到鍵,則返回該引數。

user=> (get {:a 1, :b 2} :e 0) 
0

但是,你不需要呼叫 get,你只需呼叫對映即可。

user=> ({:a 1, :b 2, :c 3} :e 0) 
0

鍵可以以相同的方式呼叫(與我們上面所做的相反)。

user=> (:e {:a 1, :b 2} 99) 
99 
user=> (:b {:a 1, :b 2} 99)
2

assoc 返回一個新的對映,其中一個值與一個鍵相關聯。

user=> (assoc {:a 1, :b 2} :b 3) 
{:a 1, :b 3}

利用這些知識,你應該能夠破譯以下更隱秘的 count-words 版本,它做的事情完全一樣。

(defn count-words [s] 
  (reduce #(assoc %1 %2 (inc (%1 %2 0))) {} 
          (re-seq #"\w+" (.toLowerCase s))))

以下是用對映作為 agent 值的示例。

(defn action [mdl key val] (assoc mdl key val))
(def ag (agent {:a 1 :b 2}))
@ag
(send ag action :b 3)   ; send automatically supplies ag as the first argument to action
(await ag)
@ag

我們傳送的函式會更新對映,將 'key' 的值設定為 'val'。事後看來很明顯,但你在編寫函式時可能會最初感到困惑,第一個引數總是 agent 持有的值,這個值在函式中不需要解引用。

延遲求值也值得注意,我建議閱讀 http://blog.thinkrelevance.com/2008/12/1/living-lazy-without-variables

如果你喜歡透過示例學習,請記住 https://wikibook.tw/wiki/Clojure_Programming/Examples/API_Examples 也是一個很有用的資源,可以用來查詢各種核心函式的使用方法。

華夏公益教科書