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 [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 也是一個很有用的資源,可以用來查詢各種核心函式的使用方法。