Cats(二):引用透明性和等式推理

本文由 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,例如:

  • 修改了外部變量的值
  • 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

f2 :: Int -> Int
f2 x = f2 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。不着急,在下一篇中,咱們將步入正題,咱們計劃先從「高階類型」談起。

相關文章
相關標籤/搜索