本文由 Yison 發表在 ScalaCool 團隊博客。git
上一篇文章 介紹了函數式編程的思惟,這一篇咱們來了解下函數式編程的魅力。程序員
咱們已經說過,函數式編程最關鍵的事情就是在作「組合」。然而,這種可被組合的「函數式組件」究竟是怎樣的?換句話說,它們一定符合某些規律和原則。github
當前咱們已知曉的是,函數式編程是近似於數學中推理的過程。那麼咱們思考下,數學中的推理具有怎樣的特色?數據庫
很快,咱們即可以發現數學推理最大的一個優勢 —「只要推理邏輯正確,結果便千真萬確」。express
其實,這也即是本篇文章要描述的函數式編程的一個很大的優點,所謂的「等式推理」。編程
那麼,咱們再進一步探討,「是所謂怎樣的原則和方法,才能使函數式編程具有如此特色?」。多線程
答案即是 引用透明性,它在數學和計算機中都有近似的定義。併發
An expression is said to be referentially transparent if it can be replaced with its corresponding value without changing the program's behavior. As a result, evaluating a referentially transparent function gives the same value for same arguments. Such functions are called pure functions.異步
簡單地,咱們能夠理解爲「一個表達式在程序中能夠被它等價的值替換,而不影響結果」。若是一個函數的輸入相同,對應的計算結果也相同,那麼它就具有「引用透明性」,它可被稱爲「純函數」。編程語言
舉個例子:
def f(x: Int, y: Int) = x + y
println(f(2, 3))
複製代碼
其實咱們徹底能夠用 5 來直接替代 f(2, 3)
,而對結果不會產生任何影響。
這個很是容易理解,那麼反過來怎樣纔算「非引用透明性」呢?
再來舉個例子:
var a = 1
def count(x: Int) = {
a = a + 1
x + a
}
count(1) // 3
count(1) // 4
複製代碼
在以上代碼中,咱們會發現屢次調用 count(1)
獲得的結果並不相同,顯然這是受到了外部變量 a
的影響,咱們把這個稱爲 反作用。
簡單理解,「反作用」就是 changing something somewhere,例如:
所以,不難發現反作用的產生每每跟「可變數據」以及「共享狀態」有關,常見的例子如咱們在採用多線程處理高併發的場景,「鎖競爭」就是一個明顯的例子。然而,在函數式編程中,因爲咱們推崇「引用透明性」以及「數據不可變性」,咱們甚至能夠對「兩個返回異步結果的函數」進行組合,從而提高了代碼的推理能力,下降了系統的複雜程度。
總結而言,引用透明性確保了「函數式組件」的獨立性,它與外界環境隔離,可被單獨分析,所以易於組合和推理。
注:這裏的異步操做函數,舉個例子能夠是數據庫的讀寫操做,咱們會在後面的文章中介紹如何實現。
以上咱們已經提到「不可變性」是促進引用透明性一個很關鍵的特性。在 Haskell 中,任何變量都是不可變的,在 Scala 中咱們可使用 val
(而不是 var
)來聲明不可變變量。
顯然,愈來愈多的編程語言都支持這一特性。如 Swift 中的 let
,ES6 中的 const
。以及一些有名的開源項目,如 Facebook 的 Immutable.js。
那麼,關於「引用透明性」的部分咱們是否已經講完了呢?
等等,前面提到「引用透明性」的關鍵點之一,就是返回相同的計算結果。這裏,咱們打算再深刻一步,研究下什麼纔是所謂「相同的計算結果」,它僅僅指的就是返回相同的值嗎?
咱們來看下這段代碼,它符合咱們所說的引用透明性:
def f1(x: Int, y: Int) = x
def f2(x: Int): Int = f2(x)
f1(1, f2(2))
複製代碼
用 Scala 開發的小夥伴看了至關氣憤,這是一段自殺式的代碼,若是咱們執行了它,那麼 f2
必然被不斷調用,從而致使死循環。
彷佛已經有了答案,所謂「相同的計算結果」,還能夠是死循環。。。
這時,一個會 Haskell 的程序員路過,迷之微笑,花了 10 秒鐘翻譯成了如下的版本:
f1 :: Int -> Int -> Int
f1 x y = x
f2 :: Int -> Int
f2 x = f2 x
複製代碼
運行 ghci
載入函數後調用 f1 1 (f2 2)
,你就會發現:納尼!居然成功返回告終果 1。這究竟是怎麼回事呢?
也許至關多開發的同窗至今不曾思考過這個問題:編程語言中的表達式求值策略是怎樣的?
其實,編程語言中存在兩種不一樣的代換模型:應用序 和 正則序。
大部分咱們熟悉如 Scala、C、Java 是「應用序」語言,當要執行一個過程時,就會對過程參數進行求值,這也是上述 Scala 代碼致使死循環的緣由,當咱們調用 f1(1, f2(2))
的時候,程序會先對 f2(2)
進行求值,從而不斷地調用 f2
函數。
然而,Haskell 採用了不同的邏輯,它會延遲對過程參數的求值,直到確實須要用到它的時候,才進行計算,這就是所謂的「正則序」,也就是咱們常說的 惰性求值。當咱們調用 f1 1 (f2 2)
後,因爲 f1
的過程當中壓根不須要用到 y
,因此它就不會對 f2 2
進行求值,直接返回 x
值,也就是 1。
注:對以上狀況進行描述的角度,還有你可能知道的「傳值調用」和「引用調用」。
那麼這樣作到底有什麼好處呢?
Haskell 是默認採用惰性求值的語言,在其它一些語言中(如 Scala 和 Swift),咱們也能夠利用 lazy
關鍵字來聲明惰性的變量和函數。
惰性求值能夠帶來不少優點,如部分人知曉的「無限長的列表結構」。固然,它也會製造一些麻煩,如使程序求值模型變得更加複雜,濫用惰性求值則會致使效率降低。
這裏,咱們並不想深究惰性求值的利和弊,這並非一個容易的問題。那麼,咱們爲何要介紹惰性求值呢?
這是由於,它與咱們一直在探討的「組合」存在些許聯繫。
函數式編程思惟,就是抽象並組合一切,包括現實中的反作用。
常見的反作用,如 IO 操做,到底如何組合呢?
來一段代碼:
println("I am a IO operation.")
複製代碼
顯然,這裏的 println
不是個純函數,它不利於組合。咱們該如何解決這個問題?
先看看 Haskell 中的惰性求值是如何實現的。
A thunk is a value that is yet to be evaluated. It is used in Haskell systems, that implement non-strict semantics by lazy evaluation.
Haskell 中的惰性求值是靠 Thunk 這種機制來實現的。咱們也能夠在其它編程語言中經過提供一個 thunk 函數來模擬相似的效果。
要理解 Thunk 其實很容易,好比針對以上的非純函數,咱們就能夠如此改造,讓它變得 「lazy」:
object Pure {
def println(msg: String) =
() => Predef.println(msg)
}
複製代碼
如此,當咱們的程序調用 Pure.println("I am a IO operation.")
的時候,它僅僅只是返回一個能夠進行 println
的函數,它是惰性的,也是可替代的。這樣,咱們就能夠在程序中將這些 IO 操做進行組合,最後再執行它們。
也許你還會思考,這裏的 thunk 函數什麼時候會被調用,以及若是要用以上的思路開發業務,咱們該如何避免在業務過程當中避免這些隨機大量的 thunk 函數。
關於這些,咱們會在後續的文章中繼續介紹,它跟所謂的 Free Monad 有關。
第二篇文章進一步探索了函數式編程的幾個特色和優點,彷佛至此仍然沒有說起 Cats。不着急,在下一篇中,咱們將步入正題,咱們計劃先從「高階類型」談起。