函數式編程之Clojure

openkk 12年前發布 | 75K 次閱讀 Clojure 腳本/動態語言

1. OOP的本質?

面向對象編程(OOP)中最終要的是什么?抽象、封裝、集成、多態?實現模式?設計模式?還有更重要的么?

下面引用兩段業內名言:

 

“如果我們現在回頭看一下面向對象這個思想是從哪來的,如果以基于消息傳遞機制的Smalltalk-80的特性來衡量現在的狀態繼承和面向對象的使用方式,我們不禁要問,我們是不是已經走錯路了?” ——2010倫敦QCon大會采訪

只關注狀態,在類和基于映像的語言里缺乏良好的并發模型和消息機制。 ——Dave Thomas博士

 

到底什么被忽視了?從這兩段話中我們可以看出:是OOP的并發模型和消息機制被現代OO編程語言忽視了,尤其是Java。

在當代OO語言中,可變狀態讓并發編程變得非常復雜,只能依靠悲觀鎖來進行并發的控制。

至于消息傳遞機制,大都OO語言本身并沒有提供有效的機制,而是運用設計模式來達到目的的,但這又會使編程的過程復雜化,也會在一定程度上影響代碼的可讀性。

至今,業界已經承認OOP并不是萬能的。而OOP的真正優勢在于對現實世界的建模,而不是數據處理。我們應該辯證的看待不同范式的編程語言,死磕一個必然會使思想禁錮,甚至編程靈感盡失。

2. FP是什么?

現在我們來看看在函數式編程(FP)中是怎樣解決這些問題的。

2.1 函數式編程概覽

· 一種編程范式

· 程序運算即為數學上的函數計算

· 以λ演算(lambda calculus)為基礎

· 函數為first-class,可以很方便的運用閉包創造出高階函數

· 避免狀態、變量和副作用

· 支持懶惰計算(lazy evaluation)和引用透明性

2.2 函數式編程詳解

2.2.1 不可變數據

;; Immutable data

(def a '(1 2 3))

(def b (cons 0 a))

;; b -> '(1 2 3)

(println "The 'a' is " a)

;; b -> 0 append a

(println "The 'b' is " b)

運行結果:

The 'a' is (1 2 3)

The 'b' is (0 1 2 3)

引用a和b在定義時被賦值,并且在之后的任何時刻都不可能被改變。并且,在底層,a和b的值會重用一部分數據存儲的。如下圖:

函數式編程之Clojure

從圖中可以看出,a和b引用的其實是同一個序列,只是起點不同而已。

Clojure使用的這種技術叫做PDS(Persistent Data Structures)。Clojure的數據結構是不可變的,也是持久的。持久數據結構的好處包括:

1) 可以大幅提高程序效率。

2) 為并發編程提供有力支持。

3) 更容易進行數據版本控制。

另外,與不可變數據相關的另一個函數式編程概念是——引用透明。引用透明意味著相同的輸入一定會返回相同的輸出,即:一個函數的計算過程不會因任何外部環境的改變而發生變化。相信我們真正理解不可變數據之后,引用透明這個概念也會非常好理解的。

2.2.2 一級類型——函數

“把函數作為語言的一級類型”的意思是說,語言本身支持把一個函數作為另一個函數的輸入和輸出。

首先,我們來看一個把函數作為另一個函數的輸入的例子:

;; the function as a 'first-class' 1

(defn my-func1

  "A demo of first-class"

  [d f]

  (f d))

(my-func1 "It's first-class!" println)

運行結果:

It's first-class!

上面的代碼做了這些事:

1. 定義了一個名字叫“my-func1”的函數。

2. 為這個函數寫了一段內容為“A demo of first-class”的注釋。

3. 聲明了這個函數的兩個形參:d和f。

4. 調用f,而d作為參數傳給f。因此形參f必須代表一個需要傳入一個參數的函數,這樣才能被正確調用。

5. 我們調用了函數“my-func1”,并將內容為“It’s first-class”的字符串綁定到了形參d上、將println這個函數綁定到了形參f上。

6. 根據“my-func1”函數中的定義,它本質上執行了這段代碼:

(println "It's first-class!") ;; 還記得“my-func1”函數體中的(f d)么?

初讀Clojure的代碼需要注意幾點:

1. Clojure是基于JVM的Lisp方言,所以會有很多的括號(但是與Lisp相比已經簡化了很多),這一點需要習慣。

2. 讀Lisp家族的代碼需要從最里面的括號開始讀,一直讀到最外面的括號。這是一種嵌套結構。

3. Clojure中的一對括號(即,“(”和“)”)叫一個form,每一個from中可以有一個或多個元素。并且,form中的第一個元素應該是一個函數 的標識名(Symbol),后面的元素應該是傳給這個函數的參數。所有的Clojure代碼都會遵守form的這種格式。

4. 每一個form都是一個表達式,每一個表達式的返回值都是對這個表達式的求值結果。

5. 函數體的返回值不需要顯示標明,而是在函數定義中最后一個form的求值結果。

現在,我們來看看怎樣把函數作為另一個函數的輸出:

;; the function as a 'first-class' 2

(defn func-a [s] (str "Func A: " s))

(defn func-b [s] (str "Func B: " s))

(defn my-func2

  "Another demo of first-class"

  [n]

  (cond

    (> n 0) func-a

    :else func-b))

(println ((my-func2 0) "my-first-class"))

運行結果:

Func B: my-first-class

上面的代碼做了這些事:

1. 定義了一個名字叫“func-a”的函數,這個函數有一個形參s,執行該函數之后會獲得一個返回值,返回值的內容是‘"Func A: " s’。

2. 定義了一個名字叫“func-b”的函數,這個函數有一個形參s,執行該函數之后會獲得一個返回值,返回值的內容是‘"Func B: " s’。

3. 定義了一個名字叫“my-func2”的函數。這個函數有一個形參n。函數體是一個cond的函數調用(cond函數相當于Java中的switch語句)。這個函數調用表示:當n大于0時,返回函數“func-a”,否則返回“func-b”。

4. 之前已經提到過,一個函數的返回值即是其函數體中最后一個form的求值結果。在函數“my-func2”中,這最后一個form就是那個cond調用。也就是說,函數“my-func2”會根據n的值來返回函數“func-a”或“func-b”。

5. 在上面代碼的最后一行,我們首先調用了函數“my-func2”,并傳入了實參0。根據上面的代碼說明我們可以知道這次函數調用的返回值——函數“func-b”。接著我們調用了這個被返回的函數,并得到了結果。

上面這兩段代碼可以充分展現出了函數式編程的強大威力。函數可以當做代碼塊或算法單元傳入其他函數或者被其他函數返回。這一特性極大的增強了代碼的靈活性和擴展性。

2.2.3 懶惰計算

懶惰計算意味著對表達式的求值是按需進行的。當真正需要表達式的值(或值中的某部分)時,求值(或部分求值)的操作才會被執行。這種計算方式的意義在于最小化某一個時刻的計算量,從而達到最大化節省空間和時間的目的。

下面我們來看一個例子:

;; lazy and infinite sequences

;; (iterate inc 1) ;; Don't do that!!

(println (take 10 (iterate inc 1)))

運行結果:

(1 2 3 4 5 6 7 8 9 10)

上面的代碼做了這些事:

1. 首先看一下第二行代碼,這是一行注釋。iterate函數會返回一個無限迭代的序列。我們調用這個函數,并傳入了兩個實參:inc和1。inc也是一個函 數,在這里傳入inc意味著返回的序列的每一個元素(整數)都是前一個元素加1的結果。第二個參數1表示返回序列的第一個元素為1。注意!iterate函數返回的序列是無限迭代的。直接調用iterate會使程序一直計算這個無窮序列的下一個元素,直到內存溢出。

2. 最后一行代碼我們將調用iterate函數的form作為第二個參數傳入了take函數中。這個take函數調用的第一個實參為10。這意味著我們只想獲 取這個無窮序列的前10個元素。對這個調用take函數的form求值并不會造成內存溢出,因為我們只需要前10個元素。程序會很快計算完成并返回結果。 這個無窮序列的其余元素并不會被計算出來,直到真正有程序需要它們的時候。

上面的例子雖然很簡單,但是卻展示出了懶惰計算的強大威力。懶惰計算可以大大提高程序的性能,并且使我們能夠非常方便的按需取用數據。

2.2.4 閉包

閉包其實是建立在將函數作為一級類型的這個特性之上的。閉包使我們根據需要可以動 態的生成函數。我們可以先定義一個不完整的函數,也就是說函數體中的算法是有缺失的。而后,在其他代碼中將缺失的部分算法傳入,生成這個函數的一個完整版 本并返回。這其中用到了前文提到的將函數作為另一個函數的輸入和輸出的特性。

下面我們來看一個例子:

;; closure

(defn double-op

  [f]

  (fn [& args]

    (* 2 (apply f args))))

(def double-add (double-op +))

(println (double-add 1 2 3))

運行結果:

12

上面的代碼做了這些事:

1. 我們定義了一個名為“double-op”的函數。這個函數用一個形參f。這個形參f應該是一個函數,因為我們的函數體是一個用fn(fn是一個宏,可以 理解為宏也是一種能夠動態生成函數的方式,且功能上強大很多)定義的匿名函數。這個匿名函數可以接受一或者多個參數(形參名字args前的“&” 表明了這一點)。這個匿名函數會通過傳入的實參(也就是f的值)而完整化,并作為函數“double-op”的返回值。

2. 函數apply會將第一個實參(一般為一個函數)作用于其余的實參之上,也就是說調用第一個實參代表的函數,并將其余的實參作為其參數傳入。使用 apply的好處在于不必立刻在代碼中填入傳入“其余的實參”,而可以用引用名代替。這時,這些“其余的實參”可以被叫做預參數。

3. 倒數第二行代碼定義了一個名為“double-add”的引用,這個引用返回一個函數。這個返回的函數是通過向函數“double-op”傳入函數“+”而完整化后得出的。換句話說,我們在這里定義了一個名為“double-add”的函數。

4. 之后我們調用了函數“double-add”,并得到了預期的結果(把所有“其余的參數”相加并乘以2)。

閉包是函數式編程中非常重要的特性,并且在一些非函數式語言中也有閉包的身影。另外,還有兩個與閉包有關聯的兩個函數式編程概念:偏函數和柯里化。大家有興趣的話可以去google一下。

2.3 函數式編程(Clojure)的優勢

2.3.1 處理數據?用管道的方式會更加簡潔

;; Focus on results, not steps.

(println (reduce + (map #(* 2 %) (filter odd? (range 1 20)))))

運行結果:

200

我們從內向外讀代碼(從嵌套在最里面的括號開始讀),可以清除的明白這段代碼做了這些事:

1. 獲取一個1到19的整數序列。(調用range函數后結果是“(1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19)”)

2. 將這個序列的中的奇數提取出來形成另外一個序列。(調用filter函數后結果是“(1 3 5 7 9 11 13 15 17 19)”)

3. 將這個奇數序列中的每個元素乘以2。(調用map函數后結果是“(2 6 10 14 18 22 26 30 34 38)”)(其中,“#(* 2 %)”是用簡化方式定義的一個匿名函數,也可以用fn來定義)

4. 將序列中的所有元素相加。(調用reduce函數后結果是“200”)

這種管道流的代碼變現方式使得我們讀起來非常順暢。我們幾乎在讀代碼的同時就能明確代碼的含義。這種管道流代碼也非常只管,數據從內層開始經過中間函數的逐一處理,到了最外層時就生成了我們最終想要的結果。

想象一下,如果用Java寫的話需要多少行代碼?需要多少次循環?需要聲明多少個中間變量?

2.3.2 請描述一下我們要做的事情

;; Focus more on what the code does rather than how it does it.

(println (for [n (range 1 101) :when (= 0 (rem n 3))] n))

運行結果:

(3 6 9 12 15 18 21 24 27 30 33 36 39 42 45 48 51 54 57 60 63 66 69 72 75 78 81 84 87 90 93 96 99)

現在我們想找出1到100中能被3整除的數,組成序列并打印,看看上面這段代碼是怎么做的:

1. 用一個for宏就能搞定!for宏在Clojure里并不是用來做循環和迭代,而是用來對序列進行過濾等復雜操作的。

2. 調用range函數返回一個1到100的序列。

3. 通過“:when”這個關鍵字和后跟的函數來對上述序列進行過濾。

4. 在Clojure中,用方括號包裹的代碼塊一般定時來聲明形參的,并且如果形參名稱的下一個形參位置上是一個函數或引用名的話,那么就把它的值賦給這個形參。在這段代碼中我們將過濾后的序列賦給了形參n。

5. 我們調用for宏的最后一個表達式為n,這就意味這調用后的返回值為n所代表的那個序列。

6. for宏很強大,詳情請看Clojure的文檔。

Clojure里有很多的內建宏和函數。它們為語言使用者提供了很大的便利。它們使得我們在編程時可以更多的關注我們要做什么,而不是怎么去做。換句話說,這我們可以更多的去關注我們想要實現的功能和業務,而不是糾纏在那些不重要的處理細節上。

2.3.3 要親自管理可變狀態?敬而遠之吧

;; Allow the runtime to manage state.

(def counter

  (let [tick (atom 0)]

    #(swap! tick inc)))

(println (take 10 (repeatedly counter)))

運行結果:

(1 2 3 4 5 6 7 8 9 10)

這段代碼做了這些事:

1. 定義了函數“counter”。

2. 在函數體中調用了let函數。

3. 聲明了一個支持原子操作的變量0,并將這個變量賦給了引用tick。tick作為這let函數的內部綁定。

4. 在let函數調用的主體中聲明了一個匿名函數,這個匿名函數利用swap函數來改變tick多代表的值。

5. 無限調用函數“counter”并生成一個無窮序列(通過repeatedly函數),然后只取序列的前10個元素(注意,這里用到了懶惰計算)并返回。

在Clojure中,用方括號包裹的代碼塊一般是來聲明形參的。我們可以把用方括 號包裹的語句塊看成一個vector(實際上,在Clojure中數據結構vector的表示法就是用方括號包裹一到多個元素)。如果形參名稱的下一個元 素時一個form或者引用名或者字面量值,那么這下一個元素就是給這個形參的賦值。比如:

;; sum of x and y

(let [x 1 y 2] (+ x y))

在上面這段代碼中,我們調用了let函數,將1賦值給形參x、將2賦值給了形參2,并以x和y的和作為這次let函數調用的返回值。

從本節開始的代碼可知,在Clojure中是可以有可變狀態的。但是,對可變狀態的管理和狀態是完全的由語言來控制的。

Clojure使用STM(Software Transactional Memory)技術來對可變狀態及其并發操作進行控制。

Clojure的STM使用了一種叫做多版本并發控制(MVCC)的技術。這一技術也在被一些主流數據庫使用。

STM的事務與數據庫的事務類似,保證三點:更新是原子的(A)、更新是一致的(C)和更新是隔離的(I)。 數據庫的事務還可以保證更新是牢固的。因為Clojure的事務是內存事務,所以并不能保證更新的牢固性(D)。

下面我們用一張狀態圖來描繪STM的并發控制行為:

函數式編程之Clojure

圖中左上角的代碼做了這些事:

1. 用ref定義了一個支持并發的引用(這個引用指向了一個不可變的值“A”)。這個ref引用是可變的。然后,我們將這個ref引用賦給引用my-data。

2. 用dosync宏定義了一個事務。在這個事務中,我們使用ref-set函數將my-data所代表的ref引用的值由“A”改為了“B”。

3. 這個事務的執行過程是協作和并發的。“可協作的”意味著我們可以在dosync宏中執行一到多個并發操作。dosync宏保證這些操作永遠是按順序執行的。至于并發控制方面已在圖中說明了。

Clojure的STM有四種操作模式,如下表:

名稱

</td>

協作的/獨立的

</td>

同步/異步

</td>

說明

</td> </tr>

Ref

</td>

協作的

</td>

同步

</td>

Ref會為一個不可變的對象創建一個可變的引用。

</td> </tr>

Atomic

</td>

獨立的

</td>

同步

</td>

Atom是一種比ref更輕量級的機制。多個ref更新操作能夠在事務被協調的執行,而atom允許非協調的單一值的更新操作。

</td> </tr>

Agent

</td>

獨立的

</td>

異步

</td>

send函數被調用后會立即返回,更新操作會稍后在另一個線程被執行。

</td> </tr>

Vars

</td>

線程本地的

</td>

同步

</td>

Var是用defn或def定義,并用^:dynamic修飾的。它可以用binding在本地線程將某個引用重新綁定為其他值。

</td> </tr> </tbody> </table>

注:

1) 協作/獨立:狀態是否與其他狀態共同作用。

2) 同步/異步:狀態的更新是同步還是異步。

下面是Ref、Atom和Agent的更新模型:

函數式編程之Clojure

這個更新模型中描繪出了Clojure對并發更新的操作方式。更新機制包含了普通應用的、可交換的、非阻塞的和簡易的。

關于Clojure的STM方面的知識已經超出了本文的主題范圍,故在此就不再贅述了。對此感興趣的讀者可以參看Clojure的官方文檔和書籍。

2.3.4 更自然的使用“組合”來解耦代碼

下面的這段代碼來自于O’Relley出版的《Clojure Programming》一書的第2章的一個例子。這個例子很好的展示了函數式編程在組合/累加功能方面的獨特優勢。

(defn print-logger

  [writer]

  #(binding [*out* writer]

    (println %)))

((print-logger *out*) "hello")

(require 'clojure.java.io)

(defn file-logger

  [file]

  #(with-open [f (clojure.java.io/writer file :append true)]

    ((print-logger f) %)))

((file-logger "messages.log") "hello, log file.")

(defn multi-logger

  [& logger-fns]

  #(doseq [f logger-fns]

    (f %)))

((multi-logger

  (print-logger *out*)

  (file-logger "messages.log")) "hello again")

(defn timestamped-logger

  [logger]

  #(logger (format "[%1$tY-%1$tm-%1$te %1$tH:%1$tM:%1$tS] %2$s" (java.util.Date.) %)))

((timestamped-logger

  (multi-logger

    (print-logger *out*)

    (file-logger "messages.log"))) "Hello, timestamped logger~")

這段代碼做了這些事:

1. 首先,例子定義了print-logger函數。這個函數的函數體是一個匿名函數。這個匿名函數通過調用binding宏將標準輸出(用“*out*”表 示)重新綁定為形參writer所代表的值(也就是說重新定義了用println函數打印內容的輸出目的地),而后打印出調用這個匿名函數時所傳入的實 參。最后,我們將這個匿名函數作為print-logger函數的返回值。這個注意,這里用到了 閉包,通過形參writer所代表的值的傳入,我們完整化了這個匿名函數,使它真正可以工作。

2. 例子中定義的第二個函數是file-logger函數。這個函數有一個形參file,它的值應該是一個文件的路徑。這個函數的函數體也是一個匿名函數。在 這個匿名函數中,通過調用with-open宏打開了這個文件路徑多代表的文件,并創建了一個相應的Writer實例并賦值給了內部綁定 f。:append關鍵字是用來指定寫入方式是否為追加的。在最后,通過調用之前寫好的print-logger函數并傳入f。這就意味著我們把標準輸出 與一個指定文件的Writer綁定了。這就意味著,我們調用file-logger函數并傳入文件的路徑,就會得到一個可以把內容打印到指定文件的log 記錄函數了。這里同樣是一個閉包應用。我們通過將“messages.log”作為參數傳給函數file-logger,完整化了file-logger 函數體中多定義的匿名函數。待file-logger函數將這個匿名函數作為返回值返回之后我們就可以直接使用了。

3. multi-logger函數可以把print-logger函數和file-logger函數的功能合并起來。參數vector中的“& logger-fns”表明我們可以傳入多個函數。函數中的匿名函數會作為返回值返回。這個匿名函數會依次調用之前傳入多個函數,并將調用這個匿名函數時 傳入的參數傳遞給這幾個函數。我們調用multi-logger函數并將前面定義好的兩個log記錄函數傳入,就可以得到一個可以同時將內容打印到屏幕和 文件的多向日志記錄函數了。我們得到的這個函數同樣是通過閉包方式生成的。

4. 在理解了前面幾個函數后,timestamped-logger函數就很好解釋了。我們首先將時間戳字符串和要打印的內容拼接(通過調用format函數)并作為參數傳給了形參logger代表的函數。

這個例子稍顯復雜一些,但是它是像搭積木一樣一步步將功能堆疊起來的,讀起來是非常直觀的。當然這要在你理解了相關函數式編程概念之后。

2.4 一起來FP吧

上面說了這么多,只希望能夠激發起你對FP的興趣。如果你已經對FP有了一絲興 趣,那就說明我的文章沒白寫。如果你會Java,那我強烈建議你看看Clojure這個函數式語言。怎么?你對Clojure一無所知?好吧,我在后面補 上一些Clojure編程語言的基本信息。

3. 這就是Clojure函數式編程之Clojure

3.1 Clojure是什么?

  • sesese色