本文由 Yison 發表在 ScalaCool 團隊部落格。
上一篇文章 介紹了函數語言程式設計的思維,這一篇我們來了解下函數語言程式設計的魅力。
我們已經說過,函數語言程式設計最關鍵的事情就是在做「組合」。然而,這種可被組合的「函式式元件」到底是怎樣的?換句話說,它們必定符合某些規律和原則。
當前我們已知曉的是,函數語言程式設計是近似於數學中推理的過程。那麼我們思考下,數學中的推理具備怎樣的特點?
很快,我們便可以發現數學推理最大的一個優點 —「只要推理邏輯正確,結果便千真萬確」。
其實,這也便是本篇文章要描述的函數語言程式設計的一個很大的優勢,所謂的「等式推理」。
那麼,我們再進一步探討,「是所謂怎樣的原則和方法,才能使函數語言程式設計具備如此特點?」。
引用透明性
答案便是 引用透明性,它在數學和計算機中都有近似的定義。
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,例如:
- 修改了外部變數的值
- IO 操作,如寫資料到磁碟
- UI 操作,如修改了一個按鈕的可操作狀態
因此,不難發現副作用的產生往往跟「可變資料」以及「共享狀態」有關,常見的例子如我們在採用多執行緒處理高併發的場景,「鎖競爭」就是一個明顯的例子。然而,在函數語言程式設計中,由於我們推崇「引用透明性」以及「資料不可變性」,我們甚至可以對「兩個返回非同步結果的函式」進行組合,從而提升了程式碼的推理能力,降低了系統的複雜程度。
總結而言,引用透明性確保了「函式式元件」的獨立性,它與外界環境隔離,可被單獨分析,因此易於組合和推理。
注:這裡的非同步操作函式,舉個例子可以是資料庫的讀寫操作,我們會在後面的文章中介紹如何實現。
不可變性
以上我們已經提到「不可變性」是促進引用透明性一個很關鍵的特性。在 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 + y
f2 :: Int -> Int
f2 x = x
複製程式碼
執行 ghci
載入函式後呼叫 f1 1 (f2 2)
,你就會發現:納尼!竟然成功返回了結果 1。這到底是怎麼回事呢?
應用序 vs 正則序
也許相當多開發的同學至今未曾思考過這個問題:程式語言中的表示式求值策略是怎樣的?
其實,程式語言中存在兩種不同的代換模型:應用序 和 正則序。
大部分我們熟悉如 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 中的惰性求值是如何實現的。
Thunk
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。不著急,在下一篇中,我們將步入正題,我們計劃先從「高階型別」談起。