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

ScalaCool發表於2018-01-12

本文由 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。不著急,在下一篇中,我們將步入正題,我們計劃先從「高階型別」談起。

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

相關文章